From dfd19a97964c5ca18f954a269a90da9b48c19c4d Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Wed, 15 Jul 2026 11:55:39 -0300 Subject: [PATCH 01/10] feat(wallet): add reusable wallet modules Add program-neutral wallet access, a stable account model, reusable QML controls, transaction confirmation, submitted-transaction presentation, and isolated contract tests. --- apps/amm/CMakeLists.txt | 19 +- apps/amm/flake.lock | 41 +- apps/amm/flake.nix | 43 +- apps/amm/qml/Logos/Wallet/qmldir | 5 + apps/amm/qml/NavBar.qml | 11 +- .../liquidity/LiquidityConfirmationDialog.qml | 238 --------- .../LiquidityConfirmationSummary.qml | 91 ++++ .../swap/SwapConfirmationDialog.qml | 225 -------- .../swap/SwapConfirmationSummary.qml | 88 +++ .../qml/components/wallet/AccountControl.qml | 490 ----------------- .../qml/components/wallet/AccountDelegate.qml | 84 --- .../components/wallet/CreateAccountDialog.qml | 102 ---- .../components/wallet/CreateWalletDialog.qml | 201 ------- .../qml/components/wallet/LogosCopyButton.qml | 39 -- .../components/wallet/WalletIconButton.qml | 25 - .../qml/components/wallet/icons/settings.svg | 1 - apps/amm/qml/pages/LiquidityPage.qml | 15 +- apps/amm/qml/pages/SwapPage.qml | 42 +- apps/amm/src/AccountModel.cpp | 79 --- apps/amm/src/AccountModel.h | 47 -- apps/amm/src/AmmUiBackend.cpp | 375 ++----------- apps/amm/src/AmmUiBackend.h | 58 +- apps/amm/src/AmmUiBackend.rep | 7 - apps/shared/wallet/CMakeLists.txt | 168 ++++++ .../wallet/qml/SubmittedTransaction.qml | 86 +++ .../qml/TransactionConfirmationDialog.qml | 169 ++++++ apps/shared/wallet/qml/WalletControl.qml | 503 ++++++++++++++++++ .../wallet/qml/internal/AccountDelegate.qml | 77 +++ .../shared/wallet/qml/internal/CopyButton.qml | 26 + .../qml/internal/CreateAccountDialog.qml | 94 ++++ .../qml/internal/CreateWalletDialog.qml | 190 +++++++ .../wallet/qml/internal/WalletIconButton.qml | 31 ++ .../qml/internal/WalletMessageDialog.qml | 52 ++ .../wallet/qml/internal}/icons/account.svg | 0 .../wallet/qml/internal}/icons/back.svg | 0 .../wallet/qml/internal}/icons/checkmark.svg | 0 .../wallet/qml/internal}/icons/copy.svg | 0 .../wallet/qml/internal}/icons/power.svg | 0 .../shared/wallet/src/LogosWalletProvider.cpp | 382 +++++++++++++ apps/shared/wallet/src/LogosWalletProvider.h | 37 ++ apps/shared/wallet/src/WalletAccountModel.cpp | 61 +++ apps/shared/wallet/src/WalletAccountModel.h | 42 ++ apps/shared/wallet/src/WalletController.cpp | 238 +++++++++ apps/shared/wallet/src/WalletController.h | 67 +++ apps/shared/wallet/src/WalletProvider.cpp | 26 + apps/shared/wallet/src/WalletProvider.h | 107 ++++ .../tests/cpp/LogosWalletProviderTest.cpp | 452 ++++++++++++++++ .../wallet/tests/cpp/fixtures/logos_sdk.h | 118 ++++ apps/shared/wallet/tests/qml/main.cpp | 3 + .../tests/qml/tst_SubmittedTransaction.qml | 43 ++ .../qml/tst_TransactionConfirmationDialog.qml | 125 +++++ .../wallet/tests/qml/tst_WalletControl.qml | 339 ++++++++++++ .../wallet/tests/support/FakeWalletProvider.h | 75 +++ flake.nix | 21 + 54 files changed, 3899 insertions(+), 1959 deletions(-) create mode 100644 apps/amm/qml/Logos/Wallet/qmldir delete mode 100644 apps/amm/qml/components/liquidity/LiquidityConfirmationDialog.qml create mode 100644 apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml delete mode 100644 apps/amm/qml/components/swap/SwapConfirmationDialog.qml create mode 100644 apps/amm/qml/components/swap/SwapConfirmationSummary.qml delete mode 100644 apps/amm/qml/components/wallet/AccountControl.qml delete mode 100644 apps/amm/qml/components/wallet/AccountDelegate.qml delete mode 100644 apps/amm/qml/components/wallet/CreateAccountDialog.qml delete mode 100644 apps/amm/qml/components/wallet/CreateWalletDialog.qml delete mode 100644 apps/amm/qml/components/wallet/LogosCopyButton.qml delete mode 100644 apps/amm/qml/components/wallet/WalletIconButton.qml delete mode 100644 apps/amm/qml/components/wallet/icons/settings.svg delete mode 100644 apps/amm/src/AccountModel.cpp delete mode 100644 apps/amm/src/AccountModel.h create mode 100644 apps/shared/wallet/CMakeLists.txt create mode 100644 apps/shared/wallet/qml/SubmittedTransaction.qml create mode 100644 apps/shared/wallet/qml/TransactionConfirmationDialog.qml create mode 100644 apps/shared/wallet/qml/WalletControl.qml create mode 100644 apps/shared/wallet/qml/internal/AccountDelegate.qml create mode 100644 apps/shared/wallet/qml/internal/CopyButton.qml create mode 100644 apps/shared/wallet/qml/internal/CreateAccountDialog.qml create mode 100644 apps/shared/wallet/qml/internal/CreateWalletDialog.qml create mode 100644 apps/shared/wallet/qml/internal/WalletIconButton.qml create mode 100644 apps/shared/wallet/qml/internal/WalletMessageDialog.qml rename apps/{amm/qml/components/wallet => shared/wallet/qml/internal}/icons/account.svg (100%) rename apps/{amm/qml/components/wallet => shared/wallet/qml/internal}/icons/back.svg (100%) rename apps/{amm/qml/components/wallet => shared/wallet/qml/internal}/icons/checkmark.svg (100%) rename apps/{amm/qml/components/wallet => shared/wallet/qml/internal}/icons/copy.svg (100%) rename apps/{amm/qml/components/wallet => shared/wallet/qml/internal}/icons/power.svg (100%) create mode 100644 apps/shared/wallet/src/LogosWalletProvider.cpp create mode 100644 apps/shared/wallet/src/LogosWalletProvider.h create mode 100644 apps/shared/wallet/src/WalletAccountModel.cpp create mode 100644 apps/shared/wallet/src/WalletAccountModel.h create mode 100644 apps/shared/wallet/src/WalletController.cpp create mode 100644 apps/shared/wallet/src/WalletController.h create mode 100644 apps/shared/wallet/src/WalletProvider.cpp create mode 100644 apps/shared/wallet/src/WalletProvider.h create mode 100644 apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp create mode 100644 apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h create mode 100644 apps/shared/wallet/tests/qml/main.cpp create mode 100644 apps/shared/wallet/tests/qml/tst_SubmittedTransaction.qml create mode 100644 apps/shared/wallet/tests/qml/tst_TransactionConfirmationDialog.qml create mode 100644 apps/shared/wallet/tests/qml/tst_WalletControl.qml create mode 100644 apps/shared/wallet/tests/support/FakeWalletProvider.h diff --git a/apps/amm/CMakeLists.txt b/apps/amm/CMakeLists.txt index 40f11ac7..8d66e29f 100644 --- a/apps/amm/CMakeLists.txt +++ b/apps/amm/CMakeLists.txt @@ -1,12 +1,25 @@ -cmake_minimum_required(VERSION 3.14) +cmake_minimum_required(VERSION 3.21) project(AmmUiPlugin LANGUAGES CXX) +find_package(Qt6 6.8 REQUIRED COMPONENTS Core Gui Network Qml Quick QuickControls2) +qt_standard_project_setup(REQUIRES 6.8) + if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT}) include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake) else() message(FATAL_ERROR "LogosModule.cmake not found. Set LOGOS_MODULE_BUILDER_ROOT.") endif() +set(LOGOS_WALLET_SOURCE_DIR + "${CMAKE_CURRENT_SOURCE_DIR}/../shared/wallet" + CACHE PATH "Path to the shared Logos wallet module" +) +set(LOGOS_WALLET_GENERATED_DIR + "${CMAKE_CURRENT_SOURCE_DIR}/generated_code" + CACHE PATH "Path to generated Logos SDK sources" +) +add_subdirectory("${LOGOS_WALLET_SOURCE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/shared-wallet") + # ui_qml module with a hand-written C++ backend (QtRO .rep view contract + # generated *SimpleSource/*ViewPluginBase). Mirrors the LEZ wallet UI module. logos_module( @@ -18,8 +31,6 @@ logos_module( src/AmmUiPlugin.cpp src/AmmUiBackend.h src/AmmUiBackend.cpp - src/AccountModel.h - src/AccountModel.cpp FIND_PACKAGES Qt6Gui Qt6Network @@ -28,4 +39,6 @@ logos_module( Qt6::Network EXTERNAL_LIBS amm_client_ffi + LINK_TARGETS + logos_wallet_access ) diff --git a/apps/amm/flake.lock b/apps/amm/flake.lock index 9bc7175f..8f79e67e 100644 --- a/apps/amm/flake.lock +++ b/apps/amm/flake.lock @@ -2238,17 +2238,17 @@ "rust-rapidsnark": "rust-rapidsnark" }, "locked": { - "lastModified": 1782310268, - "narHash": "sha256-tIIIO+V+w/C/KLtKnLIv8O8/GoJ4PzgJSWrlQCXJ2P4=", + "lastModified": 1784202021, + "narHash": "sha256-BG94Ob5tbJyw7GclNBdnOWqcBLRld8sc+xeOU8LI5RQ=", "owner": "logos-blockchain", - "repo": "lssa", - "rev": "fb8cbac40e0bda4f152415ff4f181cdc6bca6d4a", + "repo": "logos-execution-zone", + "rev": "a7e06a660940a00093b1760560d37ff84aff5a05", "type": "github" }, "original": { "owner": "logos-blockchain", - "repo": "lssa", - "rev": "fb8cbac40e0bda4f152415ff4f181cdc6bca6d4a", + "repo": "logos-execution-zone", + "rev": "a7e06a660940a00093b1760560d37ff84aff5a05", "type": "github" } }, @@ -14438,11 +14438,11 @@ ] }, "locked": { - "lastModified": 1782082589, - "narHash": "sha256-Qeqxp0HYb3oTpmfD5YlFPJzpAJa7Ilb9o4sMeVvmHRI=", + "lastModified": 1782920676, + "narHash": "sha256-Vb81kiYbi8yYDZbUSW6v7QhV6uO/pj7F+lCAW1coAsY=", "owner": "logos-co", "repo": "logos-protocol", - "rev": "315a3a2e0af61bc47aad5601ee44cd7689975820", + "rev": "d7ad26d369c4e464a99f2a357f10c5947c7174e1", "type": "github" }, "original": { @@ -16410,17 +16410,17 @@ "nix-bundle-lgx": "nix-bundle-lgx_28" }, "locked": { - "lastModified": 1782313954, - "narHash": "sha256-psh5EtcIZh6kzFD4pQDT8bae9JS9eVtk5nPWxR2aGSA=", + "lastModified": 1782741385, + "narHash": "sha256-kN6xb1b/Jvu9Ninaqtmi/C4fx8poisGYbThIBIXwvHw=", "owner": "logos-blockchain", "repo": "logos-execution-zone-module", - "rev": "d2e9400ac06c3cdbfc2405b4f153fff9841a453c", + "rev": "d70225ced646934d2294fd9e8f8b03615c104b80", "type": "github" }, "original": { "owner": "logos-blockchain", "repo": "logos-execution-zone-module", - "rev": "d2e9400ac06c3cdbfc2405b4f153fff9841a453c", + "rev": "d70225ced646934d2294fd9e8f8b03615c104b80", "type": "github" } }, @@ -26763,7 +26763,8 @@ "root": { "inputs": { "logos-module-builder": "logos-module-builder", - "logos_execution_zone": "logos_execution_zone" + "logos_execution_zone": "logos_execution_zone", + "shared_wallet": "shared_wallet" } }, "rust-overlay": { @@ -26849,6 +26850,18 @@ "rev": "e91187f8ccb5bbfc7bb00dac88169112428da78f", "type": "github" } + }, + "shared_wallet": { + "flake": false, + "locked": { + "path": "../shared/wallet", + "type": "path" + }, + "original": { + "path": "../shared/wallet", + "type": "path" + }, + "parent": [] } }, "root": "root", diff --git a/apps/amm/flake.nix b/apps/amm/flake.nix index 1b36c1aa..44121c52 100644 --- a/apps/amm/flake.nix +++ b/apps/amm/flake.nix @@ -4,11 +4,28 @@ inputs = { logos-module-builder.url = "github:logos-co/logos-module-builder"; + # Shared C++ wallet access and Logos.Wallet QML sources. + shared_wallet = { + url = "path:../shared/wallet"; + flake = false; + }; + # Core wallet module (the LEZ wallet FFI Qt plugin). The input name must # match the metadata.json `dependencies` entry so the builder can resolve - # it as a module dependency. This rev pins LEZ (lssa) at fb8cbac4, which - # includes the macOS Metal-build fix, so no `--override-input` is needed. - logos_execution_zone.url = "github:logos-blockchain/logos-execution-zone-module?rev=d2e9400ac06c3cdbfc2405b4f153fff9841a453c"; + # it as a module dependency. This revision exposes generic transaction + # submission by deployed program ID. + logos_execution_zone = { + url = "github:logos-blockchain/logos-execution-zone-module?rev=d70225ced646934d2294fd9e8f8b03615c104b80"; + + # The module pins the monorepo at v0.2.0-rc6 (e37876a), which owns the + # xcrun wrapper in its flake.nix. Override that transitive input to the + # head of PR #629 (fix(macos): nuke xcrun cache) so the Metal/xcrun build + # works on macOS. Note: #629 branches off `dev`, so this also pulls dev's + # drift from rc6 — if the wallet_ffi ABI mismatches the module build, fall + # back to cherry-picking #629's one line onto e37876a and pin that commit. + inputs.logos-execution-zone.url = + "github:logos-blockchain/logos-execution-zone?rev=a7e06a660940a00093b1760560d37ff84aff5a05"; + }; }; # NOTE: this flake is no longer built standalone. The amm_client_ffi crate @@ -22,10 +39,28 @@ # as a named attribute (there is no bare `default`): run it from the repo root # with `nix run .#amm-ui`, and build just the FFI crate with # `nix build .#amm_client_ffi`. - outputs = inputs@{ logos-module-builder, ... }: + outputs = inputs@{ logos-module-builder, shared_wallet, ... }: logos-module-builder.lib.mkLogosQmlModule { src = ./.; configFile = ./metadata.json; flakeInputs = inputs; + preConfigure = '' + cmakeFlagsArray+=("-DLOGOS_WALLET_SOURCE_DIR=${shared_wallet}") + ''; + postInstall = '' + # The builder installs the view under lib/qml after this hook. Its + # import descriptor points back to this compiled shared QML module. + test -f ${./qml}/Logos/Wallet/qmldir + + walletQmlDir="shared-wallet/qml/Logos/Wallet" + if [ ! -d "$walletQmlDir" ]; then + echo "Built Logos.Wallet QML module not found" + exit 1 + fi + walletQmlInstallDir="$out/lib/Logos/Wallet" + mkdir -p "$walletQmlInstallDir" + cp -r "$walletQmlDir/." "$walletQmlInstallDir/" + test -f "$walletQmlInstallDir/qmldir" + ''; }; } diff --git a/apps/amm/qml/Logos/Wallet/qmldir b/apps/amm/qml/Logos/Wallet/qmldir new file mode 100644 index 00000000..866351b9 --- /dev/null +++ b/apps/amm/qml/Logos/Wallet/qmldir @@ -0,0 +1,5 @@ +module Logos.Wallet +optional plugin logos_wallet_qmlplugin ../../../Logos/Wallet +classname Logos_WalletPlugin +prefer :/qt/qml/Logos/Wallet/ +depends QtQuick diff --git a/apps/amm/qml/NavBar.qml b/apps/amm/qml/NavBar.qml index f5b78570..3c06f910 100644 --- a/apps/amm/qml/NavBar.qml +++ b/apps/amm/qml/NavBar.qml @@ -2,8 +2,7 @@ import QtQuick 2.15 import QtQuick.Layouts 1.15 import Logos.Theme - -import "components/wallet" +import Logos.Wallet // Self-contained navigation bar — styling is independent of any view's theme. // Use currentIndex to read the active tab; tabChanged(index) fires on selection. @@ -94,11 +93,15 @@ Item { } // Wallet / account control on the far right. - AccountControl { + WalletControl { id: accountControl Layout.leftMargin: 12 - backend: root.backend + wallet: root.backend accountModel: root.accountModel + viewportWidth: root.width + watchCall: function(result, success, failure) { + logos.watch(result, success, failure) + } } } } diff --git a/apps/amm/qml/components/liquidity/LiquidityConfirmationDialog.qml b/apps/amm/qml/components/liquidity/LiquidityConfirmationDialog.qml deleted file mode 100644 index 16b0dc66..00000000 --- a/apps/amm/qml/components/liquidity/LiquidityConfirmationDialog.qml +++ /dev/null @@ -1,238 +0,0 @@ -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Layouts 1.15 - -FocusScope { - id: root - - property var snapshot: ({}) - property bool open: false - readonly property bool isAdd: root.snapshot.action === "add" - - signal canceled - signal confirmed(var snapshot) - - visible: root.open - z: 20 - - Keys.onEscapePressed: root.cancel() - - function openWithSnapshot(nextSnapshot) { - root.snapshot = nextSnapshot; - root.open = true; - root.forceActiveFocus(); - cancelButton.forceActiveFocus(); - } - - function cancel() { - root.open = false; - root.canceled(); - } - - function confirm() { - const confirmedSnapshot = root.snapshot; - root.open = false; - root.confirmed(confirmedSnapshot); - } - - Rectangle { - anchors.fill: parent - color: "#99000000" - - MouseArea { - anchors.fill: parent - } - } - - Rectangle { - id: panel - - anchors.centerIn: parent - color: "#1D1D1D" - implicitHeight: dialogContent.implicitHeight + 24 - radius: 8 - width: Math.max(0, Math.min(360, root.width - 32)) - border.color: "#343434" - border.width: 1 - - ColumnLayout { - id: dialogContent - - anchors.fill: parent - anchors.margins: 12 - spacing: 12 - - Text { - color: "#E7E1D8" - font.bold: true - font.pixelSize: 16 - text: root.isAdd ? qsTr("Confirm add liquidity") : qsTr("Confirm remove liquidity") - - Layout.fillWidth: true - } - - ColumnLayout { - spacing: 8 - visible: root.isAdd - - Layout.fillWidth: true - - SummaryRow { - label: qsTr("Deposit %1").arg(root.snapshot.tokenA || "") - value: root.snapshot.depositA || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Deposit %1").arg(root.snapshot.tokenB || "") - value: root.snapshot.depositB || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Receive at least") - value: root.snapshot.minLpReceived || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Current ratio") - value: root.snapshot.currentRatio || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Fee tier") - value: root.snapshot.feeTier || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Slippage tolerance") - value: root.snapshot.slippageTolerance || "" - - Layout.fillWidth: true - } - } - - ColumnLayout { - spacing: 8 - visible: !root.isAdd - - Layout.fillWidth: true - - SummaryRow { - label: qsTr("Burn LP") - value: qsTr("%1 (%2)").arg(root.snapshot.burnText || "").arg(root.snapshot.burnPercent || "") - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Receive at least %1").arg(root.snapshot.tokenA || "") - value: root.snapshot.minTokenAReceived || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Receive at least %1").arg(root.snapshot.tokenB || "") - value: root.snapshot.minTokenBReceived || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Slippage tolerance") - value: root.snapshot.slippageTolerance || "" - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Post-removal share") - value: root.snapshot.postRemovalShare || "" - - Layout.fillWidth: true - } - } - - RowLayout { - spacing: 8 - - Layout.fillWidth: true - - Button { - id: cancelButton - - activeFocusOnTab: true - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: qsTr("Cancel") - - Accessible.name: cancelButton.text - - Layout.fillWidth: true - Layout.minimumHeight: 44 - - onClicked: root.cancel() - - contentItem: Text { - color: cancelButton.hovered || cancelButton.activeFocus ? "#151515" : "#E7E1D8" - elide: Text.ElideRight - font.bold: true - font.pixelSize: 13 - horizontalAlignment: Text.AlignHCenter - text: cancelButton.text - verticalAlignment: Text.AlignVCenter - } - - background: Rectangle { - border.color: cancelButton.activeFocus ? "#F26A21" : "#343434" - border.width: 1 - color: cancelButton.pressed ? "#343434" : cancelButton.hovered || cancelButton.activeFocus ? "#E7E1D8" : "#151515" - radius: 6 - } - } - - Button { - id: confirmButton - - activeFocusOnTab: true - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: qsTr("Confirm") - - Accessible.name: confirmButton.text - - Layout.fillWidth: true - Layout.minimumHeight: 44 - - onClicked: root.confirm() - - contentItem: Text { - color: "#151515" - elide: Text.ElideRight - font.bold: true - font.pixelSize: 13 - horizontalAlignment: Text.AlignHCenter - text: confirmButton.text - verticalAlignment: Text.AlignVCenter - } - - background: Rectangle { - border.color: "#F26A21" - border.width: 1 - color: confirmButton.pressed ? "#D95C1E" : confirmButton.hovered || confirmButton.activeFocus ? "#FF8A3D" : "#F26A21" - radius: 6 - } - } - } - } - } -} diff --git a/apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml b/apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml new file mode 100644 index 00000000..68af40da --- /dev/null +++ b/apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml @@ -0,0 +1,91 @@ +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + property var snapshot: ({}) + readonly property bool isAdd: root.snapshot.action === "add" + + spacing: 8 + + ColumnLayout { + Layout.fillWidth: true + spacing: 8 + visible: root.isAdd + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Deposit %1").arg(root.snapshot.tokenA || "") + value: root.snapshot.depositA || "" + } + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Deposit %1").arg(root.snapshot.tokenB || "") + value: root.snapshot.depositB || "" + } + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Receive at least") + value: root.snapshot.minLpReceived || "" + } + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Current ratio") + value: root.snapshot.currentRatio || "" + } + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Fee tier") + value: root.snapshot.feeTier || "" + } + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Slippage tolerance") + value: root.snapshot.slippageTolerance || "" + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 8 + visible: !root.isAdd + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Burn LP") + value: qsTr("%1 (%2)") + .arg(root.snapshot.burnText || "") + .arg(root.snapshot.burnPercent || "") + } + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Receive at least %1").arg(root.snapshot.tokenA || "") + value: root.snapshot.minTokenAReceived || "" + } + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Receive at least %1").arg(root.snapshot.tokenB || "") + value: root.snapshot.minTokenBReceived || "" + } + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Slippage tolerance") + value: root.snapshot.slippageTolerance || "" + } + + SummaryRow { + Layout.fillWidth: true + label: qsTr("Post-removal share") + value: root.snapshot.postRemovalShare || "" + } + } +} diff --git a/apps/amm/qml/components/swap/SwapConfirmationDialog.qml b/apps/amm/qml/components/swap/SwapConfirmationDialog.qml deleted file mode 100644 index 54f61b44..00000000 --- a/apps/amm/qml/components/swap/SwapConfirmationDialog.qml +++ /dev/null @@ -1,225 +0,0 @@ -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Layouts 1.15 - -FocusScope { - id: root - - property var theme - property var snapshot: ({}) - property bool open: false - - signal canceled - signal confirmed(var snapshot) - - visible: root.open - z: 20 - - Keys.onEscapePressed: root.cancel() - - function openWithSnapshot(nextSnapshot) { - root.snapshot = nextSnapshot; - root.open = true; - root.forceActiveFocus(); - cancelButton.forceActiveFocus(); - } - - function cancel() { - root.open = false; - root.canceled(); - } - - function confirm() { - const confirmedSnapshot = root.snapshot; - root.open = false; - root.confirmed(confirmedSnapshot); - } - - Rectangle { - anchors.fill: parent - color: "#99000000" - - MouseArea { - anchors.fill: parent - } - } - - Rectangle { - id: panel - - anchors.centerIn: parent - color: root.theme.colors.cardBg - implicitHeight: dialogContent.implicitHeight + 32 - radius: 16 - width: Math.max(0, Math.min(380, root.width - 32)) - border.color: root.theme.colors.border - border.width: 1 - - MouseArea { - anchors.fill: parent - } - - ColumnLayout { - id: dialogContent - - anchors.fill: parent - anchors.margins: 16 - spacing: 14 - - Text { - color: root.theme.colors.textPrimary - font.bold: true - font.pixelSize: 17 - text: qsTr("Confirm swap") - Layout.fillWidth: true - } - - ColumnLayout { - spacing: 10 - Layout.fillWidth: true - - Rectangle { - Layout.fillWidth: true - color: root.theme.colors.inputBg - radius: 12 - implicitHeight: payColumn.implicitHeight + 24 - - ColumnLayout { - id: payColumn - anchors.fill: parent - anchors.margins: 12 - spacing: 4 - - Text { - text: qsTr("You pay") - color: root.theme.colors.textSecondary - font.pixelSize: 12 - Layout.fillWidth: true - } - Text { - text: qsTr("%1 %2") - .arg(root.snapshot.sellAmount || "") - .arg(root.snapshot.sellToken || "") - color: root.theme.colors.textPrimary - font.bold: true - font.pixelSize: 18 - elide: Text.ElideRight - Layout.fillWidth: true - } - } - } - - Rectangle { - Layout.fillWidth: true - color: root.theme.colors.inputBg - radius: 12 - implicitHeight: receiveColumn.implicitHeight + 24 - - ColumnLayout { - id: receiveColumn - anchors.fill: parent - anchors.margins: 12 - spacing: 4 - - Text { - text: qsTr("You receive at least") - color: root.theme.colors.textSecondary - font.pixelSize: 12 - Layout.fillWidth: true - } - Text { - text: qsTr("%1 %2") - .arg(root.snapshot.minReceived || "") - .arg(root.snapshot.buyToken || "") - color: root.theme.colors.textPrimary - font.bold: true - font.pixelSize: 18 - elide: Text.ElideRight - Layout.fillWidth: true - } - } - } - } - - SwapSummary { - Layout.fillWidth: true - theme: root.theme - swapModeText: root.snapshot.swapModeText || "" - feeText: root.snapshot.feeAmount || "" - priceImpactText: root.snapshot.priceImpactPercent || "" - priceImpactPercent: Number(root.snapshot.priceImpactPercentValue) || 0 - slippageText: root.snapshot.slippageTolerance || "" - minReceivedText: qsTr("%1 %2") - .arg(root.snapshot.minReceived || "") - .arg(root.snapshot.buyToken || "") - } - - RowLayout { - spacing: 10 - Layout.fillWidth: true - Layout.topMargin: 4 - - Button { - id: cancelButton - activeFocusOnTab: true - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: qsTr("Cancel") - Layout.fillWidth: true - Layout.minimumHeight: 48 - onClicked: root.cancel() - - contentItem: Text { - color: root.theme.colors.textPrimary - elide: Text.ElideRight - font.bold: true - font.pixelSize: 14 - horizontalAlignment: Text.AlignHCenter - text: cancelButton.text - verticalAlignment: Text.AlignVCenter - } - background: Rectangle { - border.color: root.theme.colors.borderStrong - border.width: 1 - color: cancelButton.pressed - ? root.theme.colors.panelHoverBg - : cancelButton.hovered || cancelButton.activeFocus - ? root.theme.colors.panelBg - : "transparent" - radius: 14 - } - } - - Button { - id: confirmButton - activeFocusOnTab: true - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: qsTr("Confirm Swap") - Layout.fillWidth: true - Layout.minimumHeight: 48 - onClicked: root.confirm() - - contentItem: Text { - color: "#ffffff" - elide: Text.ElideRight - font.bold: true - font.pixelSize: 14 - horizontalAlignment: Text.AlignHCenter - text: confirmButton.text - verticalAlignment: Text.AlignVCenter - } - background: Rectangle { - border.width: 0 - color: confirmButton.pressed - ? "#D95C1E" - : confirmButton.hovered || confirmButton.activeFocus - ? root.theme.colors.ctaHoverBg - : root.theme.colors.ctaBg - radius: 14 - } - } - } - } - } -} diff --git a/apps/amm/qml/components/swap/SwapConfirmationSummary.qml b/apps/amm/qml/components/swap/SwapConfirmationSummary.qml new file mode 100644 index 00000000..c3ff22e4 --- /dev/null +++ b/apps/amm/qml/components/swap/SwapConfirmationSummary.qml @@ -0,0 +1,88 @@ +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + property var theme + property var snapshot: ({}) + + spacing: 10 + + Rectangle { + Layout.fillWidth: true + color: root.theme.colors.inputBg + radius: 8 + implicitHeight: payColumn.implicitHeight + 24 + + ColumnLayout { + id: payColumn + anchors.fill: parent + anchors.margins: 12 + spacing: 4 + + Text { + Layout.fillWidth: true + text: qsTr("You pay") + color: root.theme.colors.textSecondary + font.pixelSize: 12 + } + + Text { + Layout.fillWidth: true + text: qsTr("%1 %2") + .arg(root.snapshot.sellAmount || "") + .arg(root.snapshot.sellToken || "") + color: root.theme.colors.textPrimary + font.bold: true + font.pixelSize: 18 + elide: Text.ElideRight + } + } + } + + Rectangle { + Layout.fillWidth: true + color: root.theme.colors.inputBg + radius: 8 + implicitHeight: receiveColumn.implicitHeight + 24 + + ColumnLayout { + id: receiveColumn + anchors.fill: parent + anchors.margins: 12 + spacing: 4 + + Text { + Layout.fillWidth: true + text: qsTr("You receive at least") + color: root.theme.colors.textSecondary + font.pixelSize: 12 + } + + Text { + Layout.fillWidth: true + text: qsTr("%1 %2") + .arg(root.snapshot.minReceived || "") + .arg(root.snapshot.buyToken || "") + color: root.theme.colors.textPrimary + font.bold: true + font.pixelSize: 18 + elide: Text.ElideRight + } + } + } + + SwapSummary { + Layout.fillWidth: true + theme: root.theme + swapModeText: root.snapshot.swapModeText || "" + feeText: root.snapshot.feeAmount || "" + priceImpactText: root.snapshot.priceImpactPercent || "" + priceImpactPercent: Number(root.snapshot.priceImpactPercentValue) || 0 + slippageText: root.snapshot.slippageTolerance || "" + minReceivedText: qsTr("%1 %2") + .arg(root.snapshot.minReceived || "") + .arg(root.snapshot.buyToken || "") + } +} diff --git a/apps/amm/qml/components/wallet/AccountControl.qml b/apps/amm/qml/components/wallet/AccountControl.qml deleted file mode 100644 index 9956eebd..00000000 --- a/apps/amm/qml/components/wallet/AccountControl.qml +++ /dev/null @@ -1,490 +0,0 @@ -import QtQuick -import QtQml -import QtQuick.Controls -import QtQuick.Layouts - -import Logos.Theme -import Logos.Controls - -// Header wallet control (Uniswap-style), with two states: -// - not connected → a "Connect" button that opens the create-wallet modal -// - connected → a single button showing the active account address; -// clicking it opens a popup (top-right, just under the -// button) holding the account selector, create-account and -// disconnect actions. -// The selected account address is exposed via selectedAddress for the -// trade/liquidity flows to use as the "from" account. -Item { - id: root - - // Backend replica (logos.module("amm_ui")) and its account model. - property var backend: null - property var accountModel: null - - readonly property bool connected: backend !== null && backend.isWalletOpen - - // Index of the active account. selectedAddress/selectedName are derived from - // the model mirror below so they stay valid while the popup (and its list) - // is closed. - property int selectedIndex: 0 - - // Non-visual mirror of the account model: realizes every row regardless of - // popup visibility, so the active account is addressable by index at all - // times (a ListView only realizes rows while it is shown). - Instantiator { - id: accounts - model: root.accountModel - delegate: QtObject { - readonly property string address: model.address ?? "" - readonly property string name: model.name ?? "" - readonly property string balance: model.balance ?? "" - readonly property bool isPublic: model.isPublic ?? false - } - } - - function entryAt(i) { - return (i >= 0 && i < accounts.count) ? accounts.objectAt(i) : null - } - - readonly property string selectedAddress: { - const e = root.entryAt(root.selectedIndex) - return e ? e.address : "" - } - readonly property string selectedName: { - const e = root.entryAt(root.selectedIndex) - return e ? e.name : "" - } - readonly property string selectedBalance: { - const e = root.entryAt(root.selectedIndex) - return e ? e.balance : "" - } - readonly property bool selectedIsPublic: { - const e = root.entryAt(root.selectedIndex) - return e ? e.isPublic : false - } - - // Keep the selection within bounds as accounts are added/removed. - function clampSelection() { - if (accounts.count === 0) { root.selectedIndex = 0; return } - if (root.selectedIndex < 0) root.selectedIndex = 0 - else if (root.selectedIndex >= accounts.count) root.selectedIndex = accounts.count - 1 - } - Connections { - target: root.accountModel - ignoreUnknownSignals: true - function onModelReset() { root.clampSelection() } - function onRowsInserted() { root.clampSelection() } - function onRowsRemoved() { root.clampSelection() } - } - - // 0x123456…cdef style truncation for the connected button label. - function truncated(addr) { - if (!addr) return "" - return addr.length > 13 ? (addr.substring(0, 6) + "…" + addr.substring(addr.length - 4)) : addr - } - - // Copy on the QML/view side. Routing this through the backend would call - // QGuiApplication::clipboard() in the (headless) module host process, which - // has no clipboard — that call tears the backend down, dropping the wallet - // connection. A hidden TextEdit copies via the GUI process that owns it. - TextEdit { id: clipboardProxy; visible: false } - function copyToClipboard(text) { - if (!text) return - clipboardProxy.text = text - clipboardProxy.selectAll() - clipboardProxy.copy() - clipboardProxy.deselect() - clipboardProxy.text = "" - } - - implicitWidth: root.connected ? connectedButton.width : connectButton.width - implicitHeight: 40 - - // ── Disconnected: Connect ──────────────────────────────────────────── - LogosButton { - id: connectButton - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - height: 40 - visible: !root.connected - enabled: root.backend !== null - text: qsTr("Connect") - onClicked: { - // Re-open an existing wallet; only show the create modal on first run. - if (root.backend && root.backend.walletExists) - logos.watch(root.backend.openExisting(), - function(ok) { if (!ok) console.warn("openExisting failed") }, - function(error) { console.warn("openExisting error:", error) }) - else - createWalletDialog.open() - } - } - - // ── Connected: address pill that toggles the wallet menu ───────────── - Rectangle { - id: connectedButton - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - visible: root.connected - implicitHeight: 40 - implicitWidth: connectedRow.implicitWidth + Theme.spacing.medium * 2 - radius: height / 2 - // Keep an opaque dark fill in both states: the navbar is white, and the - // active "muted" fill is translucent gray, which renders light over white - // and makes the white label unreadable. Signal "open" with an accent - // border instead. - color: Theme.palette.backgroundSecondary - border.width: 1 - border.color: walletMenu.opened ? Theme.palette.overlayOrange : "transparent" - - RowLayout { - id: connectedRow - anchors.centerIn: parent - spacing: Theme.spacing.small - - Rectangle { - Layout.preferredWidth: 8 - Layout.preferredHeight: 8 - radius: 4 - color: "#39c06a" - } - LogosText { - text: root.truncated(root.selectedAddress) || qsTr("Connected") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.text - } - LogosText { - text: walletMenu.opened ? "▴" : "▾" - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - } - } - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - // CloseOnPressOutside already dismisses the popup on this same press - // (the button is outside it), so `opened` is false by the time this - // fires. Without the recency guard the dismissing click would just - // reopen it. If it just closed, leave it closed. - onClicked: { - if (walletMenu.opened || (Date.now() - walletMenu.lastClosedMs) < 200) - walletMenu.close() - else - walletMenu.open() - } - } - } - - // ── Wallet menu popup (top-right, under the connected button) ───────── - Popup { - id: walletMenu - parent: connectedButton - y: connectedButton.height + Theme.spacing.small - x: connectedButton.width - width // right-align under the button - width: 360 - padding: Theme.spacing.medium - closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside - - // Timestamp of the last dismissal, used by the toggle button to tell a - // genuine "open" click from the press that just closed the popup. - property real lastClosedMs: 0 - onClosed: { - walletMenu.lastClosedMs = Date.now() - // Always reopen on the main (selected-account) view. - if (viewStack.depth > 1) - viewStack.pop(null, StackView.Immediate) - } - - background: Rectangle { - color: Theme.palette.backgroundTertiary - border.width: 1 - border.color: Theme.palette.backgroundElevated - radius: Theme.spacing.radiusLarge - } - - // Two stacked views: the main view (active account + actions) and the - // accounts view (full list + create). The popup height follows the - // active view's natural height, animated so the resize isn't abrupt. - contentItem: StackView { - id: viewStack - clip: true - implicitWidth: walletMenu.availableWidth - implicitHeight: currentItem ? currentItem.implicitHeight : 0 - initialItem: mainView - - Behavior on implicitHeight { - NumberAnimation { duration: 160; easing.type: Easing.OutCubic } - } - - pushEnter: Transition { NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 120 } } - pushExit: Transition { NumberAnimation { property: "opacity"; from: 1; to: 0; duration: 120 } } - popEnter: Transition { NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 120 } } - popExit: Transition { NumberAnimation { property: "opacity"; from: 1; to: 0; duration: 120 } } - } - - // ── Main view: account icon + power icon, then the active account ── - Component { - id: mainView - - ColumnLayout { - spacing: Theme.spacing.medium - - // Top-right actions: open the account list / disconnect. - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacing.small - - Item { Layout.fillWidth: true } - - WalletIconButton { - iconSource: Qt.resolvedUrl("icons/account.svg") - onClicked: viewStack.push(accountsView) - } - WalletIconButton { - iconSource: Qt.resolvedUrl("icons/settings.svg") - onClicked: viewStack.push(settingsView) - } - WalletIconButton { - iconSource: Qt.resolvedUrl("icons/power.svg") - onClicked: { - walletMenu.close() - if (root.backend) root.backend.disconnectWallet() - } - } - } - - // Active account card. - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: cardColumn.implicitHeight + Theme.spacing.medium * 2 - radius: Theme.spacing.radiusLarge - color: Theme.palette.backgroundMuted - - ColumnLayout { - id: cardColumn - anchors.fill: parent - anchors.margins: Theme.spacing.medium - spacing: Theme.spacing.small - - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacing.small - - LogosText { - text: root.selectedName - font.pixelSize: Theme.typography.secondaryText - font.bold: true - } - Rectangle { - Layout.preferredWidth: tagLabel.implicitWidth + Theme.spacing.small * 2 - Layout.preferredHeight: tagLabel.implicitHeight + 4 - radius: 4 - color: Theme.palette.backgroundSecondary - LogosText { - id: tagLabel - anchors.centerIn: parent - text: root.selectedIsPublic ? qsTr("Public") : qsTr("Private") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - } - } - Item { Layout.fillWidth: true } - LogosText { - text: root.selectedBalance.length > 0 ? root.selectedBalance : "—" - font.bold: true - } - } - - RowLayout { - Layout.fillWidth: true - spacing: 0 - LogosText { - Layout.fillWidth: true - verticalAlignment: Text.AlignVCenter - text: root.selectedAddress - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textMuted - elide: Text.ElideMiddle - } - LogosCopyButton { - Layout.preferredHeight: 40 - Layout.preferredWidth: 40 - visible: root.selectedAddress.length > 0 - onCopyText: root.copyToClipboard(root.selectedAddress) - icon.color: Theme.palette.textMuted - } - } - } - } - } - } - - // ── Accounts view: back + full list + create ────────────────────── - Component { - id: accountsView - - ColumnLayout { - spacing: Theme.spacing.medium - - // Header: back to the main view + title. - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacing.small - - WalletIconButton { - iconSource: Qt.resolvedUrl("icons/back.svg") - onClicked: viewStack.pop() - } - LogosText { - Layout.fillWidth: true - text: qsTr("Accounts") - font.bold: true - color: Theme.palette.text - } - } - - // Account list: tap a row to make it the active account, then - // return to the main view so the selection is reflected. - ListView { - Layout.fillWidth: true - Layout.preferredHeight: Math.min(contentHeight, 260) - clip: true - model: root.accountModel - spacing: Theme.spacing.small - ScrollIndicator.vertical: ScrollIndicator { } - - delegate: AccountDelegate { - width: ListView.view.width - highlighted: index === root.selectedIndex - onClicked: { - root.selectedIndex = index - viewStack.pop() - } - onCopyRequested: (text) => root.copyToClipboard(text) - } - } - - LogosButton { - Layout.fillWidth: true - height: 40 - text: qsTr("Add") - // Leave the wallet menu open behind the (modal) dialog. - onClicked: createAccountDialog.open() - } - } - } - - // ── Settings view: back + editable network (sequencer) ──────────── - Component { - id: settingsView - - ColumnLayout { - spacing: Theme.spacing.medium - - // Header: back to the main view + title. - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacing.small - - WalletIconButton { - iconSource: Qt.resolvedUrl("icons/back.svg") - onClicked: viewStack.pop() - } - LogosText { - Layout.fillWidth: true - text: qsTr("Settings") - font.bold: true - color: Theme.palette.text - } - } - - LogosText { - text: qsTr("Network (sequencer URL)") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - } - - LogosTextField { - id: seqField - Layout.fillWidth: true - placeholderText: "http://127.0.0.1:3040" - // Initialize from the live value without binding, so typing - // isn't clobbered when sequencerAddr updates after a save. - Component.onCompleted: text = root.backend ? root.backend.sequencerAddr : "" - } - - LogosText { - id: seqStatus - Layout.fillWidth: true - visible: text.length > 0 - wrapMode: Text.WordWrap - font.pixelSize: Theme.typography.secondaryText - property bool ok: false - color: ok ? Theme.palette.success : Theme.palette.error - } - - LogosButton { - Layout.fillWidth: true - height: 40 - text: qsTr("Save") - onClicked: { - if (!root.backend) return - seqStatus.text = "" - logos.watch(root.backend.changeSequencerAddr(seqField.text), - function(ok) { - seqStatus.ok = ok - seqStatus.text = ok ? qsTr("Network updated.") - : qsTr("Failed to update network.") - }, - function(error) { - seqStatus.ok = false - seqStatus.text = qsTr("Error: %1").arg(error) - }) - } - } - } - } - } - - // ── Dialogs ────────────────────────────────────────────────────────── - CreateWalletDialog { - id: createWalletDialog - walletHome: root.backend ? root.backend.walletHome : "" - onCreateWallet: function(password) { - if (!root.backend) return - // createNewDefault returns the new wallet's seed phrase (empty on - // failure). On success we hand it to the dialog, which switches to - // its backup page — we do NOT close here, so the user can't skip it. - logos.watch(root.backend.createNewDefault(password), - function(mnemonic) { - if (mnemonic && mnemonic.length > 0) - createWalletDialog.mnemonic = mnemonic - else - createWalletDialog.createError = qsTr("Failed to create wallet. Please try again.") - }, - function(error) { - createWalletDialog.createError = qsTr("Error creating wallet: %1").arg(error) - }) - } - onCopyRequested: function(text) { - if (root.backend) root.backend.copyToClipboard(text) - } - } - - CreateAccountDialog { - id: createAccountDialog - onCreatePublicRequested: { - if (!root.backend) return - logos.watch(root.backend.createAccountPublic(), - function(_id) { /* model updates via NOTIFY after refresh */ }, - function(error) { console.warn("createAccountPublic failed:", error) }) - } - onCreatePrivateRequested: { - if (!root.backend) return - logos.watch(root.backend.createAccountPrivate(), - function(_id) { /* model updates via NOTIFY after refresh */ }, - function(error) { console.warn("createAccountPrivate failed:", error) }) - } - } -} diff --git a/apps/amm/qml/components/wallet/AccountDelegate.qml b/apps/amm/qml/components/wallet/AccountDelegate.qml deleted file mode 100644 index 14c03ee2..00000000 --- a/apps/amm/qml/components/wallet/AccountDelegate.qml +++ /dev/null @@ -1,84 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts - -import Logos.Theme -import Logos.Controls - -// One account row in the account dropdown. Ported from the LEZ wallet UI. -ItemDelegate { - id: root - - // Emitted when the user clicks the copy icon; the parent view connects this - // to its QML-side clipboard helper (AccountControl.copyToClipboard). - signal copyRequested(string text) - - leftPadding: Theme.spacing.medium - rightPadding: Theme.spacing.medium - topPadding: Theme.spacing.medium - bottomPadding: Theme.spacing.medium - - background: Rectangle { - color: root.highlighted || root.hovered ? - Theme.palette.backgroundMuted : - Theme.palette.backgroundTertiary - radius: Theme.spacing.radiusLarge - } - - contentItem: ColumnLayout { - spacing: Theme.spacing.small - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacing.small - - LogosText { - text: model.name ?? "" - font.pixelSize: Theme.typography.secondaryText - font.bold: true - } - - Rectangle { - Layout.preferredWidth: tagLabel.implicitWidth + Theme.spacing.small * 2 - Layout.preferredHeight: tagLabel.implicitHeight + 4 - radius: 4 - color: Theme.palette.backgroundSecondary - - LogosText { - id: tagLabel - anchors.centerIn: parent - text: model.isPublic ? qsTr("Public") : qsTr("Private") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - } - } - - Item { Layout.fillWidth: true } - - LogosText { - text: model.balance && model.balance.length > 0 ? model.balance : "—" - font.bold: true - } - } - - RowLayout { - Layout.fillWidth: true - spacing: 0 - LogosText { - id: addressLabel - Layout.fillWidth: true - verticalAlignment: Text.AlignVCenter - text: model.address ?? "" - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textMuted - elide: Text.ElideMiddle - } - LogosCopyButton { - Layout.preferredHeight: 40 - Layout.preferredWidth: 40 - onCopyText: root.copyRequested(model.address) - visible: addressLabel.text - icon.color: Theme.palette.textMuted - } - } - } -} diff --git a/apps/amm/qml/components/wallet/CreateAccountDialog.qml b/apps/amm/qml/components/wallet/CreateAccountDialog.qml deleted file mode 100644 index 80707958..00000000 --- a/apps/amm/qml/components/wallet/CreateAccountDialog.qml +++ /dev/null @@ -1,102 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts - -import Logos.Theme -import Logos.Controls - -// Public/private account creation dialog. Ported from the LEZ wallet UI. -Popup { - id: root - - signal createPublicRequested() - signal createPrivateRequested() - - modal: true - dim: true - padding: Theme.spacing.large - closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside - - // Center on the full-window overlay rather than the small navbar control - // this popup is declared inside. - parent: Overlay.overlay - anchors.centerIn: parent - width: 360 - - background: Rectangle { - color: Theme.palette.backgroundSecondary - radius: Theme.spacing.radiusXlarge - border.color: Theme.palette.backgroundElevated - } - - contentItem: ColumnLayout { - id: contentLayout - // Pin to the popup's padded width so children stay within the modal. - width: root.availableWidth - spacing: Theme.spacing.large - - LogosText { - text: qsTr("Create account") - font.pixelSize: Theme.typography.titleText - font.weight: Theme.typography.weightBold - color: Theme.palette.text - } - - LogosText { - text: qsTr("Choose account type.") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - Layout.topMargin: -Theme.spacing.small - } - - RowLayout { - Layout.fillWidth: true - spacing: Theme.spacing.medium - - ColumnLayout { - Layout.fillWidth: true - spacing: 0 - - LogosText { - text: qsTr("Private") - font.pixelSize: Theme.typography.primaryText - color: Theme.palette.text - } - LogosText { - text: qsTr("Private balance and activity.") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - wrapMode: Text.WordWrap - Layout.fillWidth: true - } - } - - LogosSwitch { - id: privateSwitch - checked: false - } - } - - RowLayout { - Layout.topMargin: Theme.spacing.medium - spacing: Theme.spacing.medium - Layout.fillWidth: true - LogosButton { - text: qsTr("Cancel") - Layout.fillWidth: true - onClicked: root.close() - } - LogosButton { - text: qsTr("Create") - Layout.fillWidth: true - onClicked: { - if (privateSwitch.checked) - root.createPrivateRequested() - else - root.createPublicRequested() - root.close() - } - } - } - } -} diff --git a/apps/amm/qml/components/wallet/CreateWalletDialog.qml b/apps/amm/qml/components/wallet/CreateWalletDialog.qml deleted file mode 100644 index 05c8d6e6..00000000 --- a/apps/amm/qml/components/wallet/CreateWalletDialog.qml +++ /dev/null @@ -1,201 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts - -import Logos.Theme -import Logos.Controls - -// Wallet creation modal. Two pages, driven by whether a mnemonic exists yet: -// 1. Password entry — emits createWallet(password); the parent creates the -// wallet and, on success, sets `mnemonic` to the returned seed phrase. -// 2. Seed-phrase backup — shows the mnemonic once and gates dismissal behind -// an explicit acknowledgement. This is the only time the phrase is shown, -// so the popup is not auto-dismissable while it is visible. -// Storage/config live at the per-app default (backend.walletHome) — no path -// picking. Opened from the navbar "Connect" button. -Popup { - id: root - - // Where the wallet will be stored, shown for transparency. - property string walletHome: "" - property string createError: "" - // Set by the parent to the BIP39 seed phrase once creation succeeds. A - // non-empty value flips the dialog to the backup page. - property string mnemonic: "" - - signal createWallet(string password) - signal copyRequested(string text) - - modal: true - dim: true - padding: Theme.spacing.large - // Once the wallet exists we must not let the user dismiss the modal (and - // lose the only view of their seed phrase) by clicking away or pressing Esc. - closePolicy: root.mnemonic.length > 0 - ? Popup.NoAutoClose - : (Popup.CloseOnEscape | Popup.CloseOnPressOutside) - // Center on the full-window overlay rather than the small navbar control - // this popup is declared inside. - parent: Overlay.overlay - anchors.centerIn: parent - width: 380 - - onOpened: { - passwordField.text = "" - confirmField.text = "" - root.createError = "" - root.mnemonic = "" - passwordField.forceActiveFocus() - } - - background: Rectangle { - color: Theme.palette.backgroundSecondary - radius: Theme.spacing.radiusXlarge - border.color: Theme.palette.backgroundElevated - } - - contentItem: ColumnLayout { - // Pin to the popup's padded width so long text wraps and fillWidth - // children don't push the layout wider than the modal. - width: root.availableWidth - spacing: 0 - - // ── Page 1: password entry ──────────────────────────────────────── - ColumnLayout { - id: passwordPage - visible: root.mnemonic.length === 0 - Layout.fillWidth: true - spacing: Theme.spacing.large - - LogosText { - text: qsTr("Create your wallet") - font.pixelSize: Theme.typography.titleText - font.weight: Theme.typography.weightBold - color: Theme.palette.text - } - - LogosText { - text: qsTr("Secure your wallet with a password. It will be stored on this device at %1.") - .arg(root.walletHome || qsTr("the default location")) - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - wrapMode: Text.WordWrap - Layout.fillWidth: true - Layout.topMargin: -Theme.spacing.small - } - - LogosTextField { - id: passwordField - Layout.fillWidth: true - placeholderText: qsTr("Password") - echoMode: TextInput.Password - Keys.onReturnPressed: createButton.tryCreate() - } - LogosTextField { - id: confirmField - Layout.fillWidth: true - placeholderText: qsTr("Confirm password") - echoMode: TextInput.Password - Keys.onReturnPressed: createButton.tryCreate() - } - - LogosText { - Layout.fillWidth: true - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.error - wrapMode: Text.WordWrap - visible: text.length > 0 - text: root.createError - } - - RowLayout { - Layout.topMargin: Theme.spacing.small - Layout.fillWidth: true - spacing: Theme.spacing.medium - LogosButton { - text: qsTr("Cancel") - Layout.fillWidth: true - onClicked: root.close() - } - LogosButton { - id: createButton - Layout.fillWidth: true - text: qsTr("Create Wallet") - function tryCreate() { - if (passwordField.text.length === 0) { - root.createError = qsTr("Password cannot be empty.") - } else if (passwordField.text !== confirmField.text) { - root.createError = qsTr("Passwords do not match.") - } else { - root.createError = "" - root.createWallet(passwordField.text) - } - } - onClicked: tryCreate() - } - } - } - - // ── Page 2: seed-phrase backup ──────────────────────────────────── - ColumnLayout { - id: backupPage - visible: root.mnemonic.length > 0 - Layout.fillWidth: true - spacing: Theme.spacing.large - - LogosText { - text: qsTr("Back up your recovery phrase") - font.pixelSize: Theme.typography.titleText - font.weight: Theme.typography.weightBold - color: Theme.palette.text - } - - LogosText { - text: qsTr("Write these words down in order and store them somewhere safe. Anyone with this phrase can control your wallet, and it will not be shown again — it is the only way to recover access.") - font.pixelSize: Theme.typography.secondaryText - color: Theme.palette.textSecondary - wrapMode: Text.WordWrap - Layout.fillWidth: true - Layout.topMargin: -Theme.spacing.small - } - - Rectangle { - Layout.fillWidth: true - radius: Theme.spacing.radiusLarge - color: Theme.palette.backgroundElevated - implicitHeight: phraseText.implicitHeight + 2 * Theme.spacing.medium - - LogosText { - id: phraseText - anchors.fill: parent - anchors.margins: Theme.spacing.medium - text: root.mnemonic - wrapMode: Text.WordWrap - lineHeight: 1.4 - font.pixelSize: Theme.typography.primaryText - font.weight: Theme.typography.weightBold - color: Theme.palette.text - } - } - - LogosButton { - Layout.fillWidth: true - text: qsTr("Copy to clipboard") - onClicked: root.copyRequested(root.mnemonic) - } - - LogosCheckbox { - id: ackCheck - Layout.fillWidth: true - text: qsTr("I have safely backed up my recovery phrase") - } - - LogosButton { - Layout.fillWidth: true - enabled: ackCheck.checked - text: qsTr("Continue") - onClicked: root.close() - } - } - } -} diff --git a/apps/amm/qml/components/wallet/LogosCopyButton.qml b/apps/amm/qml/components/wallet/LogosCopyButton.qml deleted file mode 100644 index 7c1d7bdc..00000000 --- a/apps/amm/qml/components/wallet/LogosCopyButton.qml +++ /dev/null @@ -1,39 +0,0 @@ -import QtQuick -import QtQuick.Controls - -import Logos.Theme - -Button { - id: root - - signal copyText() - - implicitWidth: 24 - implicitHeight: 24 - display: AbstractButton.IconOnly - flat: true - - property string iconSource: Qt.resolvedUrl("icons/copy.svg") - - icon.source: root.iconSource - icon.width: 24 - icon.height: 24 - icon.color: Theme.palette.textSecondary - - function reset() { - iconSource = Qt.resolvedUrl("icons/copy.svg") - } - - Timer { - id: resetTimer - interval: 1500 - repeat: false - onTriggered: root.reset() - } - - onClicked: { - root.copyText() - root.iconSource = Qt.resolvedUrl("icons/checkmark.svg") - resetTimer.restart() - } -} diff --git a/apps/amm/qml/components/wallet/WalletIconButton.qml b/apps/amm/qml/components/wallet/WalletIconButton.qml deleted file mode 100644 index 61545e0d..00000000 --- a/apps/amm/qml/components/wallet/WalletIconButton.qml +++ /dev/null @@ -1,25 +0,0 @@ -import QtQuick -import QtQuick.Controls - -import Logos.Theme - -// Small icon-only action button for the wallet menu. Uses the same Button + -// icon.source/icon.color path as LogosCopyButton, which renders reliably here -// (LogosIconButton's Image + MultiEffect shader path does not). -Button { - id: root - - property url iconSource - property color iconColor: Theme.palette.textSecondary - property int iconSize: 18 - - implicitWidth: 32 - implicitHeight: 32 - display: AbstractButton.IconOnly - flat: true - - icon.source: root.iconSource - icon.width: root.iconSize - icon.height: root.iconSize - icon.color: root.iconColor -} diff --git a/apps/amm/qml/components/wallet/icons/settings.svg b/apps/amm/qml/components/wallet/icons/settings.svg deleted file mode 100644 index 77f89ae5..00000000 --- a/apps/amm/qml/components/wallet/icons/settings.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/amm/qml/pages/LiquidityPage.qml b/apps/amm/qml/pages/LiquidityPage.qml index f4719521..9dde13a6 100644 --- a/apps/amm/qml/pages/LiquidityPage.qml +++ b/apps/amm/qml/pages/LiquidityPage.qml @@ -1,5 +1,6 @@ import QtQuick 2.15 import QtQuick.Layouts 1.15 +import Logos.Wallet import "../components/shared" import "../components/liquidity" import "../state" @@ -162,10 +163,18 @@ Item { } } - LiquidityConfirmationDialog { - id: confirmationDialog + Component { + id: liquidityConfirmationSummary - anchors.fill: parent + LiquidityConfirmationSummary { } + } + + TransactionConfirmationDialog { + id: confirmationDialog + title: snapshot.action === "add" + ? qsTr("Confirm add liquidity") + : qsTr("Confirm remove liquidity") + summary: liquidityConfirmationSummary onConfirmed: function (snapshot) { root.confirmLiquidityAction(snapshot); diff --git a/apps/amm/qml/pages/SwapPage.qml b/apps/amm/qml/pages/SwapPage.qml index 344d47a7..d524e527 100644 --- a/apps/amm/qml/pages/SwapPage.qml +++ b/apps/amm/qml/pages/SwapPage.qml @@ -1,9 +1,10 @@ +pragma ComponentBehavior: Bound + import QtQuick 2.15 -import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 +import Logos.Wallet import "../components/shared" import "../components/swap" -import "../state" Item { id: root @@ -25,7 +26,7 @@ Item { } QtObject { - id: theme + id: pageTheme property bool isDark: true property var colors: isDark ? dark : light @@ -68,7 +69,7 @@ Item { Rectangle { anchors.fill: parent - color: theme.colors.background + color: pageTheme.colors.background Behavior on color { ColorAnimation { duration: 300 } } // Theme toggle @@ -77,19 +78,19 @@ Item { anchors.right: parent.right anchors.margins: 16 width: 44; height: 24; radius: 12 - color: theme.colors.panelBg - border.color: theme.colors.border + color: pageTheme.colors.panelBg + border.color: pageTheme.colors.border border.width: 1 Text { anchors.centerIn: parent - text: theme.isDark ? "☀" : "☾" + text: pageTheme.isDark ? "☀" : "☾" font.pixelSize: 13 - color: theme.colors.textSecondary + color: pageTheme.colors.textSecondary } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor - onClicked: theme.isDark = !theme.isDark + onClicked: pageTheme.isDark = !pageTheme.isDark } } @@ -100,7 +101,7 @@ Item { SwapCard { id: swapCard Layout.alignment: Qt.AlignHCenter - theme: theme + theme: pageTheme tokens: root.tokens backend: root.backend width: Math.min(480, root.width - 32) @@ -126,9 +127,9 @@ Item { Text { Layout.alignment: Qt.AlignHCenter - text: "Buy and sell crypto on LEZ." + text: "Buy and sell crypto on LEZ." textFormat: Text.RichText - color: theme.colors.textSecondary + color: pageTheme.colors.textSecondary font.pixelSize: 15 horizontalAlignment: Text.AlignHCenter } @@ -138,7 +139,7 @@ Item { id: tokenModal anchors.fill: parent z: 10 - theme: theme + theme: pageTheme tokens: root.tokens property string targetSide: "sell" @@ -161,10 +162,19 @@ Item { } } - SwapConfirmationDialog { + Component { + id: swapConfirmationSummary + + SwapConfirmationSummary { + theme: pageTheme + } + } + + TransactionConfirmationDialog { id: swapConfirmationDialog - anchors.fill: parent - theme: theme + title: qsTr("Confirm swap") + confirmText: qsTr("Confirm swap") + summary: swapConfirmationSummary onConfirmed: function(snapshot) { // The dialog only shows a preview snapshot; the actual diff --git a/apps/amm/src/AccountModel.cpp b/apps/amm/src/AccountModel.cpp deleted file mode 100644 index 63992f86..00000000 --- a/apps/amm/src/AccountModel.cpp +++ /dev/null @@ -1,79 +0,0 @@ -#include "AccountModel.h" - -#include - -AccountModel::AccountModel(QObject* parent) - : QAbstractListModel(parent) -{ -} - -int AccountModel::rowCount(const QModelIndex& parent) const -{ - if (parent.isValid()) - return 0; - return m_entries.size(); -} - -QVariant AccountModel::data(const QModelIndex& index, int role) const -{ - if (!index.isValid() || index.row() < 0 || index.row() >= m_entries.size()) - return QVariant(); - - const AccountEntry& e = m_entries.at(index.row()); - switch (role) { - case NameRole: return e.name; - case AddressRole: return e.address; - case BalanceRole: return e.balance; - case IsPublicRole: return e.isPublic; - default: return QVariant(); - } -} - -QHash AccountModel::roleNames() const -{ - return { - { NameRole, "name" }, - { AddressRole, "address" }, - { BalanceRole, "balance" }, - { IsPublicRole, "isPublic" } - }; -} - -void AccountModel::replaceFromJsonArray(const QJsonArray& arr) -{ - beginResetModel(); - const int oldCount = m_entries.size(); - m_entries.clear(); - int idx = 0; - for (const QJsonValue& v : arr) { - AccountEntry e; - e.name = QStringLiteral("Account %1").arg(++idx); - e.balance = QString(); - if (v.isObject()) { - const QJsonObject obj = v.toObject(); - e.address = obj.value(QStringLiteral("account_id")).toString(); - e.isPublic = obj.value(QStringLiteral("is_public")).toBool(true); - } else { - e.address = v.toString(); - e.isPublic = true; - } - m_entries.append(e); - } - endResetModel(); - if (oldCount != m_entries.size()) - emit countChanged(); -} - -void AccountModel::setBalanceByAddress(const QString& address, const QString& balance) -{ - for (int i = 0; i < m_entries.size(); ++i) { - if (m_entries.at(i).address == address) { - if (m_entries.at(i).balance != balance) { - m_entries[i].balance = balance; - const QModelIndex idx = index(i, 0); - emit dataChanged(idx, idx, { BalanceRole }); - } - return; - } - } -} diff --git a/apps/amm/src/AccountModel.h b/apps/amm/src/AccountModel.h deleted file mode 100644 index 918839c3..00000000 --- a/apps/amm/src/AccountModel.h +++ /dev/null @@ -1,47 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -// One wallet account row. Mirrors the shape returned by the core wallet -// module's list_accounts() (account_id + is_public), with a display name and -// a lazily-fetched balance. -struct AccountEntry { - QString name; - QString address; - QString balance; - bool isPublic = true; -}; - -// QAbstractListModel of wallet accounts, exposed to QML via -// logos.model("amm_ui", "accountModel"). Ported from the LEZ wallet UI. -class AccountModel : public QAbstractListModel { - Q_OBJECT - Q_PROPERTY(int count READ count NOTIFY countChanged) -public: - enum Role { - NameRole = Qt::UserRole + 1, - AddressRole, - BalanceRole, - IsPublicRole - }; - Q_ENUM(Role) - - explicit AccountModel(QObject* parent = nullptr); - - int rowCount(const QModelIndex& parent = QModelIndex()) const override; - QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; - QHash roleNames() const override; - - void replaceFromJsonArray(const QJsonArray& arr); - void setBalanceByAddress(const QString& address, const QString& balance); - int count() const { return m_entries.size(); } - -signals: - void countChanged(); - -private: - QVector m_entries; -}; diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index f9eb89da..6b30cd13 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -20,6 +20,8 @@ #include #include +#include "LogosWalletProvider.h" +#include "WalletController.h" #include "logos_api.h" #include "logos_sdk.h" @@ -51,17 +53,6 @@ static bool ammDebugEnabled() if (ammDebugEnabled()) qWarning().noquote() namespace { - const char SETTINGS_ORG[] = "Logos"; - const char SETTINGS_APP[] = "AmmUI"; - // Sticky "user pressed Disconnect" flag so the wallet stays locked across - // relaunches until the user reconnects. - const char DISCONNECTED_KEY[] = "disconnected"; - const int WALLET_FFI_SUCCESS = 0; - - // Wallet home env override. Mirrors LEZ's own var so the app shares the - // canonical wallet (~/.lee/wallet) used by the wallet UI and other apps. - const char WALLET_HOME_ENV[] = "LEE_WALLET_HOME_DIR"; - // Absolute path to the deployed AMM program's RISC Zero program binary // (amm.bin — the `ProgramBinary` `.bin` from the docker guest build, decoded // on the Rust side via `ProgramBinary::decode`; NOT a raw ELF — pointing at @@ -77,13 +68,6 @@ namespace { // doesn't need a hardcoded/dummy token list. const char TOKENS_CONFIG_ENV[] = "TOKENS_CONFIG"; - // Normalise file:// URLs and OS paths to a plain local path. - QString toLocalPath(const QString& path) { - if (path.startsWith("file://") || path.contains("/")) - return QUrl::fromUserInput(path).toLocalFile(); - return path; - } - QString bytes32ToHex(const uint8_t (&b)[32]) { return QString::fromLatin1(QByteArray(reinterpret_cast(b), 32).toHex()); } @@ -157,367 +141,86 @@ namespace { } } -QString AmmUiBackend::defaultWalletHome() -{ - const QByteArray override = qgetenv(WALLET_HOME_ENV); - if (!override.isEmpty()) - return QString::fromLocal8Bit(override); - // LEZ's canonical wallet home, shared with the wallet UI and other LEZ apps - // (matches lez/wallet get_home_default_path()). - return QDir::homePath() + QStringLiteral("/.lee/wallet"); -} - -QString AmmUiBackend::defaultConfigPath() const -{ - return defaultWalletHome() + QStringLiteral("/wallet_config.json"); -} - -QString AmmUiBackend::defaultStoragePath() const -{ - return defaultWalletHome() + QStringLiteral("/storage.json"); -} - AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent) : AmmUiBackendSimpleSource(parent), - m_accountModel(new AccountModel(this)), m_logosAPI(logosAPI ? logosAPI : new LogosAPI("amm_ui", this)), - m_logos(new LogosModules(m_logosAPI)), - m_net(new QNetworkAccessManager(this)), - m_reachabilityTimer(new QTimer(this)) -{ - // PROP defaults via the generated setters. - setIsWalletOpen(false); - setLastSyncedBlock(0); - setCurrentBlockHeight(0); - setWalletHome(defaultWalletHome()); - // Assume reachable until a probe proves otherwise (avoids a startup flash). - setSequencerReachable(true); - - // Periodically re-probe the sequencer so the banner reacts to a node going - // up/down while the app is running. Probes are no-ops until a wallet (and - // thus a sequencer address) is open. - m_reachabilityTimer->setInterval(10000); - connect(m_reachabilityTimer, &QTimer::timeout, this, [this]() { checkReachability(); }); - m_reachabilityTimer->start(); - - // Always resolve against the canonical wallet home (LEE_WALLET_HOME_DIR or - // ~/.lee/wallet). We intentionally don't seed config/storage paths from - // QSettings anymore: a previously-persisted per-app path (~/.lee/amm-wallet) - // would otherwise override the default and pin the app to the old keystore. - - // A wallet exists on disk if its storage file is present (drives whether - // the navbar "Connect" reconnects or offers to create a wallet). - const QString effectiveStorage = storagePath().isEmpty() ? defaultStoragePath() : storagePath(); - setWalletExists(QFileInfo::exists(effectiveStorage)); - - // ui-host runs our constructor inside initLogos(), synchronously, BEFORE - // it enables remoting and emits READY. Any blocking RPC here would stall - // ui-host startup past its ready watchdog. Defer the open+refresh chain to - // the first event-loop tick so ui-host finishes wiring itself up first. - QTimer::singleShot(0, this, [this]() { openOrAdoptWallet(); }); - - // Save wallet on quit; host may not call destructors so this is best-effort. - connect(qApp, &QCoreApplication::aboutToQuit, this, - [this]() { saveWallet(); }, Qt::DirectConnection); -} - -AmmUiBackend::~AmmUiBackend() -{ - saveWallet(); - delete m_logos; -} - -void AmmUiBackend::openOrAdoptWallet() + m_logos(std::make_unique(m_logosAPI)), + m_wallet(std::make_unique(m_logosAPI)), + m_walletController(std::make_unique( + *m_wallet, QStringLiteral("AmmUI"))) { - // Respect an explicit user disconnect: stay locked, show "Connect". - if (QSettings(SETTINGS_ORG, SETTINGS_APP).value(DISCONNECTED_KEY, false).toBool()) - return; - - // In Basecamp the logos_execution_zone module is a single shared instance, - // so the wallet may already be open (e.g. opened by the dedicated wallet - // app). Adopt that wallet instead of fighting over it: mirror its state - // rather than re-opening from disk, which could clobber unsaved in-memory - // accounts the other app holds. A freshly-created shared wallet can be open - // with zero accounts, so we can't key off list_accounts() alone (see - // sharedWalletIsOpen). - if (sharedWalletIsOpen()) { - const QJsonArray existing = QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts()); - qDebug() << "AmmUiBackend: adopting already-open shared wallet" - << existing.size() << "accounts"; - setIsWalletOpen(true); - m_accountModel->replaceFromJsonArray(existing); - refreshBalances(); - refreshSequencerAddr(); - return; - } - - // Standalone (own core instance): auto-open a previously-created wallet. - // Use persisted paths if the user picked custom ones, else the per-app - // default. Only open if the storage actually exists, otherwise stay closed - // so QML shows the "Connect" entry point (no noisy FFI errors on first run). - const QString cfg = configPath().isEmpty() ? defaultConfigPath() : configPath(); - const QString stg = storagePath().isEmpty() ? defaultStoragePath() : storagePath(); - if (!QFileInfo::exists(stg)) - return; // No wallet yet — QML shows "Connect". - - qDebug() << "AmmUiBackend: opening wallet with config" << cfg << "storage" << stg; - const int err = m_logos->logos_execution_zone.open(cfg, stg); - if (err == WALLET_FFI_SUCCESS) { - persistConfigPath(cfg); - persistStoragePath(stg); - setIsWalletOpen(true); - refreshAccounts(); - refreshBlockHeights(); - refreshSequencerAddr(); - } else { - qWarning() << "AmmUiBackend: wallet open failed, code" << err; - } + connect(m_walletController.get(), &WalletController::stateChanged, + this, &AmmUiBackend::syncWalletState); + syncWalletState(); + m_walletController->start(); } -bool AmmUiBackend::sharedWalletIsOpen() -{ - // Treat the shared core as "already open" ONLY when it actually holds - // accounts. We used to also treat a non-empty sequencer address as proof of - // an open wallet ("a closed core returns an empty string"), but the wallet - // module now reports a DEFAULT sequencer (e.g. http://localhost:…) even on a - // CLOSED core. That made standalone launches wrongly take the adopt path — - // mirroring an empty account list and returning early — instead of opening - // the real on-disk wallet, so the user saw no accounts. Keying off accounts - // alone means a genuinely-open shared wallet (Basecamp) is still adopted, - // while a closed standalone core correctly falls through to open-from-disk. - // Tradeoff: a shared wallet that is open but holds ZERO accounts is treated - // as closed here, so it won't be adopted — an accepted edge, preferable to - // the default-sequencer false-positive that keying off the address caused. - return !QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts()).isEmpty(); -} +AmmUiBackend::~AmmUiBackend() = default; -QString AmmUiBackend::createNewDefault(QString password) +WalletAccountModel* AmmUiBackend::accountModel() const { - QDir().mkpath(defaultWalletHome()); - return createNew(defaultConfigPath(), defaultStoragePath(), password); -} - -QString AmmUiBackend::createNew(QString configPath, QString storagePath, QString password) -{ - const QString localConfig = toLocalPath(configPath); - const QString localStorage = toLocalPath(storagePath); - // create_new returns the new wallet's BIP39 mnemonic (empty on failure). We - // hand it back to the caller instead of discarding it: wallet creation is - // the only moment the seed phrase is recoverable, so the UI must force a - // backup step before the user can proceed. - const QString mnemonic = m_logos->logos_execution_zone.create_new(localConfig, localStorage, password); - if (mnemonic.isEmpty()) { - qWarning() << "AmmUiBackend: create_new failed (empty mnemonic)"; - return QString(); - } - - persistConfigPath(localConfig); - persistStoragePath(localStorage); - setWalletExists(true); - QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, false); - setIsWalletOpen(true); - refreshAccounts(); - refreshBlockHeights(); - refreshSequencerAddr(); - return mnemonic; -} - -bool AmmUiBackend::openExisting() -{ - // Adopt a shared open wallet (Basecamp), else open our own from disk. A - // freshly-created shared wallet can be open with zero accounts, so probe - // open-ness rather than keying off list_accounts() alone. - if (sharedWalletIsOpen()) { - const QJsonArray existing = QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts()); - setIsWalletOpen(true); - m_accountModel->replaceFromJsonArray(existing); - refreshBalances(); - refreshSequencerAddr(); - QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, false); - return true; - } - - const QString cfg = configPath().isEmpty() ? defaultConfigPath() : configPath(); - const QString stg = storagePath().isEmpty() ? defaultStoragePath() : storagePath(); - if (!QFileInfo::exists(stg)) - return false; - - const int err = m_logos->logos_execution_zone.open(cfg, stg); - if (err != WALLET_FFI_SUCCESS) { - qWarning() << "AmmUiBackend: openExisting failed, code" << err; - return false; - } - persistConfigPath(cfg); - persistStoragePath(stg); - setIsWalletOpen(true); - QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, false); - refreshAccounts(); - refreshBlockHeights(); - refreshSequencerAddr(); - return true; -} - -void AmmUiBackend::disconnectWallet() -{ - // UI-local lock: persist wallet state, drop our view of it, and remember - // the choice. We do NOT close the core module's wallet handle — in Basecamp - // that instance is shared with other apps. - saveWallet(); - setIsWalletOpen(false); - m_accountModel->replaceFromJsonArray(QJsonArray()); - QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, true); + return m_walletController->accountModel(); } QString AmmUiBackend::createAccountPublic() { - const QString result = m_logos->logos_execution_zone.create_account_public(); - if (!result.isEmpty()) - refreshAccounts(); - return result; + return m_walletController->createAccount(true); } QString AmmUiBackend::createAccountPrivate() { - const QString result = m_logos->logos_execution_zone.create_account_private(); - if (!result.isEmpty()) - refreshAccounts(); - return result; + return m_walletController->createAccount(false); } void AmmUiBackend::refreshAccounts() { - const QJsonArray arr = QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts()); - m_accountModel->replaceFromJsonArray(arr); - refreshBalances(); + m_walletController->refresh(); } void AmmUiBackend::refreshBalances() { - refreshBlockHeights(); - if (currentBlockHeight() > 0) - m_logos->logos_execution_zone.sync_to_block(static_cast(currentBlockHeight())); - - for (int i = 0; i < m_accountModel->count(); ++i) { - const QModelIndex idx = m_accountModel->index(i, 0); - const QString addr = m_accountModel->data(idx, AccountModel::AddressRole).toString(); - const bool isPub = m_accountModel->data(idx, AccountModel::IsPublicRole).toBool(); - m_accountModel->setBalanceByAddress(addr, getBalance(addr, isPub)); - } - saveWallet(); + m_walletController->refresh(); } QString AmmUiBackend::getBalance(QString accountIdHex, bool isPublic) { - return m_logos->logos_execution_zone.get_balance(accountIdHex, isPublic); -} - -void AmmUiBackend::refreshBlockHeights() -{ - const int lastVal = m_logos->logos_execution_zone.get_last_synced_block(); - const int currentVal = m_logos->logos_execution_zone.get_current_block_height(); - if (lastSyncedBlock() != lastVal) - setLastSyncedBlock(lastVal); - if (currentBlockHeight() != currentVal) - setCurrentBlockHeight(currentVal); + return m_walletController->balance(accountIdHex, isPublic); } -void AmmUiBackend::refreshSequencerAddr() -{ - const QString addr = m_logos->logos_execution_zone.get_sequencer_addr(); - if (sequencerAddr() != addr) - setSequencerAddr(addr); - // Probe right away so the banner reflects the (possibly new) endpoint - // without waiting for the next periodic tick. - checkReachability(); -} - -void AmmUiBackend::checkReachability() -{ - const QString addr = sequencerAddr(); - if (addr.isEmpty()) - return; - - QNetworkRequest req{QUrl(addr)}; - req.setTransferTimeout(4000); - QNetworkReply* reply = m_net->get(req); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { - // Any HTTP response (even a 404) means the node is up; only a transport - // failure (connection refused, host not found, timeout) counts as down. - const bool gotHttpStatus = - reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).isValid(); - const bool reachable = gotHttpStatus || reply->error() == QNetworkReply::NoError; - if (sequencerReachable() != reachable) - setSequencerReachable(reachable); - reply->deleteLater(); - }); -} - -void AmmUiBackend::saveWallet() +QString AmmUiBackend::createNewDefault(QString password) { - if (isWalletOpen()) - m_logos->logos_execution_zone.save(); + return m_walletController->createDefaultWallet(password); } -// These only update the in-session PROPs (so subsequent open/refresh calls -// reuse the same path). They are no longer written to QSettings: the app -// always resolves against the canonical wallet home, so there's nothing to -// remember across launches. -void AmmUiBackend::persistConfigPath(const QString& path) +QString AmmUiBackend::createNew(QString configPath, + QString storagePath, + QString password) { - setConfigPath(toLocalPath(path)); + return m_walletController->createWallet(configPath, storagePath, password); } -void AmmUiBackend::persistStoragePath(const QString& path) +bool AmmUiBackend::openExisting() { - setStoragePath(toLocalPath(path)); + return m_walletController->open(); } -bool AmmUiBackend::changeSequencerAddr(QString url) +void AmmUiBackend::disconnectWallet() { - const QString trimmed = url.trimmed(); - if (trimmed.isEmpty()) { - qWarning() << "AmmUiBackend: refusing to set empty sequencer_addr"; - return false; - } - - const QString cfg = configPath().isEmpty() ? defaultConfigPath() : configPath(); - - // Preserve the other config fields (poll timeouts, retries) — only swap the - // endpoint. The wallet reads this file on open via from_path_or_initialize_default. - QJsonObject obj; - QFile in(cfg); - if (in.open(QIODevice::ReadOnly)) { - obj = QJsonDocument::fromJson(in.readAll()).object(); - in.close(); - } - obj.insert(QStringLiteral("sequencer_addr"), trimmed); - - QFile out(cfg); - if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) { - qWarning() << "AmmUiBackend: cannot write wallet config" << cfg; - return false; - } - out.write(QJsonDocument(obj).toJson(QJsonDocument::Indented)); - out.close(); - - // Re-open so the live wallet uses the new endpoint right away. - if (isWalletOpen()) { - const QString stg = storagePath().isEmpty() ? defaultStoragePath() : storagePath(); - const int err = m_logos->logos_execution_zone.open(cfg, stg); - if (err != WALLET_FFI_SUCCESS) { - qWarning() << "AmmUiBackend: reopen after sequencer change failed, code" << err; - return false; - } - refreshSequencerAddr(); - refreshAccounts(); - } - return true; + m_walletController->disconnect(); } -void AmmUiBackend::copyToClipboard(QString text) +void AmmUiBackend::syncWalletState() { - if (QGuiApplication::clipboard()) - QGuiApplication::clipboard()->setText(text); + const WalletUiState& state = m_walletController->state(); + setIsWalletOpen(state.isWalletOpen); + setWalletExists(state.walletExists); + setConfigPath(state.configPath); + setStoragePath(state.storagePath); + setWalletHome(state.walletHome); + setLastSyncedBlock(state.lastSyncedBlock); + setCurrentBlockHeight(state.currentBlockHeight); + setSequencerAddr(state.sequencerAddress); + setSequencerReachable(state.sequencerReachable); } QString AmmUiBackend::normalizeAccountId(const QString& id) diff --git a/apps/amm/src/AmmUiBackend.h b/apps/amm/src/AmmUiBackend.h index 0fd09f09..244ccc6d 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -1,14 +1,15 @@ #ifndef AMM_UI_BACKEND_H #define AMM_UI_BACKEND_H -#include +#include + #include #include #include #include "rep_AmmUiBackend_source.h" -#include "AccountModel.h" +#include "WalletAccountModel.h" extern "C" { #include "amm_client_ffi.h" @@ -16,38 +17,29 @@ extern "C" { class LogosAPI; struct LogosModules; -class QNetworkAccessManager; -class QTimer; +class LogosWalletProvider; +class WalletController; -// Source-side implementation of the AmmUiBackend .rep interface. -// Inheriting from AmmUiBackendSimpleSource gives us the generated PROPs and -// SLOTs from AmmUiBackend.rep — all the simple ones flow over QtRO. Talks to -// the core logos_execution_zone wallet module via LogosModules. class AmmUiBackend : public AmmUiBackendSimpleSource { Q_OBJECT - Q_PROPERTY(AccountModel* accountModel READ accountModel CONSTANT) + Q_PROPERTY(WalletAccountModel* accountModel READ accountModel CONSTANT) public: explicit AmmUiBackend(LogosAPI* logosAPI = nullptr, QObject* parent = nullptr); ~AmmUiBackend() override; - AccountModel* accountModel() const { return m_accountModel; } + WalletAccountModel* accountModel() const; public slots: - // Overrides of the pure-virtual slots generated from the .rep. QString createAccountPublic() override; QString createAccountPrivate() override; void refreshAccounts() override; void refreshBalances() override; QString getBalance(QString accountIdHex, bool isPublic) override; - // Return the new wallet's BIP39 mnemonic (empty string on failure) so the - // UI can force a one-time seed-phrase backup step. QString createNewDefault(QString password) override; QString createNew(QString configPath, QString storagePath, QString password) override; bool openExisting() override; void disconnectWallet() override; - bool changeSequencerAddr(QString url) override; - void copyToClipboard(QString text) override; // AMM QVariantMap resolvePool(QString defAHex, QString defBHex) override; @@ -59,43 +51,27 @@ public slots: QVariantList tokenList() override; private: - // Per-app wallet home (kept distinct from the wallet's canonical - // ~/.lee/wallet so standalone instances stay isolated; Basecamp sharing - // is handled by adopting an already-open shared wallet on startup). - static QString defaultWalletHome(); - QString defaultConfigPath() const; - QString defaultStoragePath() const; - - void persistConfigPath(const QString& path); - void persistStoragePath(const QString& path); + void syncWalletState(); + // Normalizes an account id given as either 64-char lowercase/uppercase hex // or base58 to lowercase hex. Returns an empty QString if `id` is neither // (or the base58 decode fails), so callers can detect and skip it. QString normalizeAccountId(const QString& id); - void openOrAdoptWallet(); - // True when the shared core already has a wallet open — including a freshly - // created one with zero accounts. See the definition for why list_accounts() - // alone is insufficient. - bool sharedWalletIsOpen(); - void refreshBlockHeights(); - void refreshSequencerAddr(); - void saveWallet(); // Returns the deployed AMM program-binary bytes (a RISC Zero ProgramBinary // .bin, not a raw ELF) from $AMM_PROGRAM_BIN, or an empty QByteArray (with a // qWarning) if the env var is unset/unreadable/empty. QByteArray loadAmmElf(); - // Probe the configured sequencer over HTTP and update sequencerReachable. - void checkReachability(); - - AccountModel* m_accountModel; - LogosAPI* m_logosAPI; - LogosModules* m_logos; - - QNetworkAccessManager* m_net; - QTimer* m_reachabilityTimer; + // Direct module handle for the AMM/swap path (resolvePool/swapExactInput/ + // tokenList). The shared wallet provider exposes only wallet-level ops, not + // the raw account-id / get_account_public / send_generic_public_transaction + // calls the AMM path needs, so keep a thin LogosModules over the same + // LogosAPI as the wallet provider. + std::unique_ptr m_logos; + std::unique_ptr m_wallet; + std::unique_ptr m_walletController; }; #endif // AMM_UI_BACKEND_H diff --git a/apps/amm/src/AmmUiBackend.rep b/apps/amm/src/AmmUiBackend.rep index 4827e077..f389b9dc 100644 --- a/apps/amm/src/AmmUiBackend.rep +++ b/apps/amm/src/AmmUiBackend.rep @@ -37,13 +37,6 @@ class AmmUiBackend // Basecamp, does not close the wallet other apps share. SLOT(void disconnectWallet()) - // Settings. Rewrites the wallet config's sequencer_addr and re-opens the - // wallet so the new network takes effect immediately. - SLOT(bool changeSequencerAddr(QString url)) - - // Misc - SLOT(void copyToClipboard(QString text)) - // AMM // Derives the AMM pool's PDAs (config/pool/vaults/current-tick) from the // deployed AMM program binary (see AMM_PROGRAM_BIN — a RISC Zero diff --git a/apps/shared/wallet/CMakeLists.txt b/apps/shared/wallet/CMakeLists.txt new file mode 100644 index 00000000..37f16630 --- /dev/null +++ b/apps/shared/wallet/CMakeLists.txt @@ -0,0 +1,168 @@ +cmake_minimum_required(VERSION 3.21) + +if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + project(LogosWallet LANGUAGES CXX) + include(CTest) +endif() + +option(LOGOS_WALLET_BUILD_QML "Build the Logos.Wallet QML module" ON) +option(LOGOS_WALLET_BUILD_ACCESS "Build the generated-SDK wallet adapter" ON) +set(LOGOS_WALLET_GENERATED_DIR "" CACHE PATH "Path to generated Logos SDK sources") + +if(LOGOS_WALLET_BUILD_ACCESS + AND NOT EXISTS "${LOGOS_WALLET_GENERATED_DIR}/logos_sdk.h" + AND NOT EXISTS "${LOGOS_WALLET_GENERATED_DIR}/include/logos_sdk.h") + message(FATAL_ERROR + "logos_wallet_access requires logos_sdk.h; set LOGOS_WALLET_GENERATED_DIR" + ) +endif() + +find_package(Qt6 6.8 REQUIRED COMPONENTS Core) +if(LOGOS_WALLET_BUILD_ACCESS OR BUILD_TESTING) + find_package(Qt6 6.8 REQUIRED COMPONENTS Network) +endif() +set(CMAKE_AUTOMOC ON) + +if(LOGOS_WALLET_BUILD_ACCESS) + add_library(logos_wallet_access STATIC + src/WalletProvider.h + src/WalletProvider.cpp + src/LogosWalletProvider.h + src/LogosWalletProvider.cpp + src/WalletAccountModel.h + src/WalletAccountModel.cpp + src/WalletController.h + src/WalletController.cpp + ) + set_target_properties(logos_wallet_access PROPERTIES + AUTOMOC ON + POSITION_INDEPENDENT_CODE ON + ) + target_compile_features(logos_wallet_access PUBLIC cxx_std_17) + target_include_directories(logos_wallet_access + PUBLIC + "$" + PRIVATE + "${LOGOS_WALLET_GENERATED_DIR}" + "${LOGOS_WALLET_GENERATED_DIR}/include" + ) + target_link_libraries(logos_wallet_access + PUBLIC Qt6::Core + PRIVATE Qt6::Network + ) +endif() + +if(LOGOS_WALLET_BUILD_QML) + find_package(Qt6 6.8 REQUIRED COMPONENTS Qml Quick QuickControls2) + qt_policy(SET QTP0004 NEW) + + set(wallet_qml_output_dir "${CMAKE_CURRENT_BINARY_DIR}/qml/Logos/Wallet") + set(wallet_internal_qml + qml/internal/WalletIconButton.qml + qml/internal/CopyButton.qml + qml/internal/AccountDelegate.qml + qml/internal/CreateAccountDialog.qml + qml/internal/CreateWalletDialog.qml + qml/internal/WalletMessageDialog.qml + ) + set(wallet_public_qml + qml/WalletControl.qml + qml/TransactionConfirmationDialog.qml + qml/SubmittedTransaction.qml + ) + set(wallet_icons + qml/internal/icons/account.svg + qml/internal/icons/back.svg + qml/internal/icons/checkmark.svg + qml/internal/icons/copy.svg + qml/internal/icons/power.svg + ) + foreach(qml_file IN LISTS wallet_public_qml wallet_internal_qml) + get_filename_component(qml_name "${qml_file}" NAME) + set_source_files_properties("${qml_file}" PROPERTIES QT_RESOURCE_ALIAS "${qml_name}") + endforeach() + foreach(icon IN LISTS wallet_icons) + get_filename_component(icon_name "${icon}" NAME) + set_source_files_properties("${icon}" PROPERTIES QT_RESOURCE_ALIAS "icons/${icon_name}") + endforeach() + set_source_files_properties(${wallet_internal_qml} PROPERTIES QT_QML_INTERNAL_TYPE TRUE) + + qt_add_library(logos_wallet_qml SHARED) + qt_add_qml_module(logos_wallet_qml + URI Logos.Wallet + VERSION 1.0 + RESOURCE_PREFIX /qt/qml + OUTPUT_DIRECTORY "${wallet_qml_output_dir}" + TYPEINFO plugins.qmltypes + QML_FILES + ${wallet_public_qml} + ${wallet_internal_qml} + RESOURCES + ${wallet_icons} + ) + target_link_libraries(logos_wallet_qml PRIVATE + Qt6::Core + Qt6::Qml + Qt6::Quick + Qt6::QuickControls2 + ) + set_target_properties(logos_wallet_qml logos_wallet_qmlplugin PROPERTIES + LIBRARY_OUTPUT_DIRECTORY "${wallet_qml_output_dir}" + RUNTIME_OUTPUT_DIRECTORY "${wallet_qml_output_dir}" + BUILD_WITH_INSTALL_RPATH ON + INSTALL_RPATH "$ORIGIN" + ) + + set(wallet_qml_install_dir "lib/qml/Logos/Wallet") + install(TARGETS logos_wallet_qml logos_wallet_qmlplugin + LIBRARY DESTINATION "${wallet_qml_install_dir}" + RUNTIME DESTINATION "${wallet_qml_install_dir}" + ) + install(FILES + "${wallet_qml_output_dir}/qmldir" + "${wallet_qml_output_dir}/plugins.qmltypes" + DESTINATION "${wallet_qml_install_dir}" + OPTIONAL + ) +endif() + +if(BUILD_TESTING) + find_package(Qt6 6.8 REQUIRED COMPONENTS Test) + + add_executable(logos_wallet_access_test + tests/cpp/LogosWalletProviderTest.cpp + src/WalletProvider.cpp + src/LogosWalletProvider.cpp + src/WalletAccountModel.cpp + src/WalletAccountModel.h + src/WalletController.cpp + src/WalletController.h + ) + set_target_properties(logos_wallet_access_test PROPERTIES AUTOMOC ON) + target_compile_features(logos_wallet_access_test PRIVATE cxx_std_17) + target_include_directories(logos_wallet_access_test PRIVATE + tests/cpp/fixtures + tests/support + src + ) + target_link_libraries(logos_wallet_access_test PRIVATE + Qt6::Core + Qt6::Network + Qt6::Test + ) + add_test(NAME logos_wallet_access COMMAND logos_wallet_access_test) + + if(LOGOS_WALLET_BUILD_QML) + find_package(Qt6 6.8 REQUIRED COMPONENTS QuickTest) + add_executable(logos_wallet_qml_test tests/qml/main.cpp) + target_compile_definitions(logos_wallet_qml_test PRIVATE + QUICK_TEST_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/tests/qml" + ) + target_link_libraries(logos_wallet_qml_test PRIVATE Qt6::QuickTest) + add_dependencies(logos_wallet_qml_test logos_wallet_qmlplugin) + add_test(NAME logos_wallet_qml COMMAND logos_wallet_qml_test) + set_tests_properties(logos_wallet_qml PROPERTIES ENVIRONMENT + "QT_QPA_PLATFORM=offscreen;QT_QUICK_BACKEND=software;QML2_IMPORT_PATH=${CMAKE_CURRENT_BINARY_DIR}/qml;QML_IMPORT_PATH=${CMAKE_CURRENT_BINARY_DIR}/qml" + ) + endif() +endif() diff --git a/apps/shared/wallet/qml/SubmittedTransaction.qml b/apps/shared/wallet/qml/SubmittedTransaction.qml new file mode 100644 index 00000000..00cc3ba8 --- /dev/null +++ b/apps/shared/wallet/qml/SubmittedTransaction.qml @@ -0,0 +1,86 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +Item { + id: root + + property string title: qsTr("Transaction submitted") + property string transactionId: "" + readonly property bool base58Shape: root.transactionId.length > 0 + && /^[1-9A-HJ-NP-Za-km-z]+$/.test(root.transactionId) + + implicitWidth: 420 + implicitHeight: resultCard.implicitHeight + + TextEdit { + id: clipboardProxy + visible: false + } + + function copyTransactionId() { + if (!root.transactionId) + return + clipboardProxy.text = root.transactionId + clipboardProxy.selectAll() + clipboardProxy.copy() + clipboardProxy.deselect() + clipboardProxy.text = "" + } + + Rectangle { + id: resultCard + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: resultContent.implicitHeight + 32 + color: "#18181b" + border.color: "#3f3f46" + border.width: 1 + radius: 8 + + ColumnLayout { + id: resultContent + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + Label { + Layout.fillWidth: true + text: root.title + color: "#f4f4f5" + font.bold: true + font.pixelSize: 17 + wrapMode: Text.WordWrap + } + + Label { + Layout.fillWidth: true + text: qsTr("Transaction ID") + color: "#a1a1aa" + font.pixelSize: 12 + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Label { + id: transactionLabel + objectName: "submittedTransactionId" + Layout.fillWidth: true + text: root.transactionId + color: "#f4f4f5" + font.family: "monospace" + wrapMode: Text.WrapAnywhere + Accessible.name: qsTr("Transaction ID %1").arg(root.transactionId) + } + + CopyButton { + objectName: "copySubmittedTransactionButton" + enabled: root.transactionId.length > 0 + onCopyRequested: root.copyTransactionId() + } + } + } + } +} diff --git a/apps/shared/wallet/qml/TransactionConfirmationDialog.qml b/apps/shared/wallet/qml/TransactionConfirmationDialog.qml new file mode 100644 index 00000000..141a8db8 --- /dev/null +++ b/apps/shared/wallet/qml/TransactionConfirmationDialog.qml @@ -0,0 +1,169 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +Popup { + id: root + + property string title: qsTr("Confirm transaction") + property string cancelText: qsTr("Cancel") + property string confirmText: qsTr("Confirm") + property bool busy: false + property var snapshot: ({}) + property Component summary: null + property bool confirmationPending: false + + signal canceled + signal confirmed(var snapshot) + + modal: true + dim: true + padding: 20 + width: Math.max(0, Math.min(420, parent ? parent.width - 32 : 420)) + height: Math.max(0, Math.min(implicitHeight, parent ? parent.height - 32 : implicitHeight)) + x: parent ? Math.max(0, Math.round((parent.width - width) / 2)) : 0 + y: parent ? Math.max(0, Math.round((parent.height - height) / 2)) : 0 + closePolicy: root.busy ? Popup.NoAutoClose : Popup.CloseOnEscape | Popup.CloseOnPressOutside + focus: true + + function cloneSnapshot(value) { + if (value === undefined || value === null) + return ({}) + try { + return JSON.parse(JSON.stringify(value)) + } catch (_error) { + return value + } + } + + function openWithSnapshot(nextSnapshot) { + root.snapshot = root.cloneSnapshot(nextSnapshot) + root.confirmationPending = false + root.open() + cancelButton.forceActiveFocus() + } + + function cancel() { + if (root.busy) + return + root.confirmationPending = false + root.close() + root.canceled() + } + + function confirm() { + if (root.busy) + return + root.confirmationPending = true + root.confirmed(root.snapshot) + if (!root.busy) { + root.confirmationPending = false + root.close() + } + } + + onBusyChanged: { + if (!root.busy && root.confirmationPending) { + root.confirmationPending = false + root.close() + } + } + + onSnapshotChanged: { + if (summaryLoader.item && summaryLoader.item.hasOwnProperty("snapshot")) + summaryLoader.item.snapshot = root.snapshot + } + + Overlay.modal: Rectangle { color: "#99000000" } + + background: Rectangle { + color: "#18181b" + border.color: "#3f3f46" + border.width: 1 + radius: 8 + } + + contentItem: ColumnLayout { + spacing: 16 + + Label { + Layout.fillWidth: true + text: root.title + color: "#f4f4f5" + font.bold: true + font.pixelSize: 17 + wrapMode: Text.WordWrap + } + + ScrollView { + id: summaryScroll + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumHeight: 0 + Layout.preferredHeight: summaryLoader.implicitHeight + clip: true + contentWidth: availableWidth + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + Loader { + id: summaryLoader + objectName: "transactionSummaryLoader" + width: summaryScroll.availableWidth + sourceComponent: root.summary + onLoaded: { + if (item && item.hasOwnProperty("snapshot")) + item.snapshot = root.snapshot + } + } + } + + BusyIndicator { + Layout.alignment: Qt.AlignHCenter + visible: root.busy + running: root.busy + Accessible.name: qsTr("Submitting transaction") + } + + RowLayout { + Layout.fillWidth: true + spacing: 10 + + Button { + id: cancelButton + objectName: "transactionCancelButton" + Layout.fillWidth: true + implicitHeight: 44 + text: root.cancelText + enabled: !root.busy + Accessible.name: text + onClicked: root.cancel() + } + + Button { + id: confirmButton + objectName: "transactionConfirmButton" + Layout.fillWidth: true + implicitHeight: 44 + text: root.busy ? qsTr("Submitting...") : root.confirmText + enabled: !root.busy + Accessible.name: text + onClicked: root.confirm() + + background: Rectangle { + color: confirmButton.enabled + ? confirmButton.pressed ? "#d95c1e" : "#f26a21" + : "#52525b" + radius: 6 + } + + contentItem: Label { + text: confirmButton.text + color: "#ffffff" + font.bold: true + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + } + } +} diff --git a/apps/shared/wallet/qml/WalletControl.qml b/apps/shared/wallet/qml/WalletControl.qml new file mode 100644 index 00000000..28d32a13 --- /dev/null +++ b/apps/shared/wallet/qml/WalletControl.qml @@ -0,0 +1,503 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +Item { + id: root + + property var wallet: null + property var accountModel: null + property var watchCall: null + property bool compact: false + property real viewportWidth: width + property int selectedIndex: 0 + property bool busy: false + + readonly property bool connected: root.wallet !== null && root.wallet.isWalletOpen + readonly property bool compactLayout: root.compact || root.viewportWidth < 680 + readonly property string selectedAddress: root.accountAt(root.selectedIndex, "address") + readonly property string selectedName: root.accountAt(root.selectedIndex, "name") + readonly property string selectedBalance: root.accountAt(root.selectedIndex, "balance") + readonly property bool selectedIsPublic: root.accountAt(root.selectedIndex, "isPublic") === true + + implicitWidth: root.connected ? connectedButton.implicitWidth : connectButton.implicitWidth + implicitHeight: 40 + + Instantiator { + id: accounts + model: root.accountModel + delegate: QtObject { + required property string address + required property string name + required property string balance + required property bool isPublic + } + onCountChanged: root.clampSelection() + } + + function accountAt(index, field) { + const entry = index >= 0 && index < accounts.count ? accounts.objectAt(index) : null + return entry ? entry[field] : (field === "isPublic" ? false : "") + } + + function clampSelection() { + if (accounts.count === 0) { + root.selectedIndex = 0 + } else { + root.selectedIndex = Math.max(0, Math.min(root.selectedIndex, accounts.count - 1)) + } + } + + function shortAddress(address) { + return address && address.length > 13 + ? address.substring(0, 6) + "..." + address.substring(address.length - 4) + : address || "" + } + + function watchResult(result, success, failure) { + if (root.watchCall) { + root.watchCall(result, success, failure) + } else { + success(result) + } + } + + function showError(message) { + messageDialog.message = message + messageDialog.open() + } + + function openWallet() { + if (!root.wallet || root.busy) + return + root.busy = true + try { + root.watchResult(root.wallet.openExisting(), function(ok) { + root.busy = false + if (!ok) + root.showError(qsTr("Wallet could not be opened.")) + }, function(error) { + root.busy = false + root.showError(qsTr("Wallet could not be opened: %1").arg(error)) + }) + } catch (error) { + root.busy = false + root.showError(qsTr("Wallet could not be opened: %1").arg(error)) + } + } + + TextEdit { + id: clipboardProxy + visible: false + } + + function copyToClipboard(text) { + if (!text) + return + clipboardProxy.text = text + clipboardProxy.selectAll() + clipboardProxy.copy() + clipboardProxy.deselect() + clipboardProxy.text = "" + } + + Connections { + target: root.accountModel + ignoreUnknownSignals: true + function onModelReset() { root.clampSelection() } + function onRowsInserted() { root.clampSelection() } + function onRowsRemoved() { root.clampSelection() } + } + + onConnectedChanged: { + if (!root.connected) { + root.selectedIndex = 0 + walletMenu.close() + } + } + + onViewportWidthChanged: { + if (walletMenu.opened) + Qt.callLater(walletMenu.updateAnchor) + } + + Button { + id: connectButton + objectName: "walletConnectButton" + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + visible: !root.connected + enabled: root.wallet !== null && !root.busy + implicitHeight: 40 + implicitWidth: root.compactLayout ? 40 : 108 + text: root.compactLayout ? "" : root.busy ? qsTr("Connecting...") : qsTr("Connect") + display: root.compactLayout ? AbstractButton.IconOnly : AbstractButton.TextBesideIcon + icon.source: Qt.resolvedUrl("icons/account.svg") + icon.color: "#ffffff" + icon.width: 18 + icon.height: 18 + Accessible.name: qsTr("Connect wallet") + ToolTip.text: Accessible.name + ToolTip.visible: hovered && root.compactLayout + + background: Rectangle { + color: connectButton.pressed ? "#d95c1e" : "#f26a21" + radius: 6 + } + + contentItem: RowLayout { + spacing: 6 + Image { + visible: root.compactLayout + source: connectButton.icon.source + sourceSize.width: 18 + sourceSize.height: 18 + } + Label { + Layout.fillWidth: true + visible: !root.compactLayout + text: connectButton.text + color: "#ffffff" + font.bold: true + horizontalAlignment: Text.AlignHCenter + } + } + + onClicked: { + if (root.wallet && root.wallet.walletExists) + root.openWallet() + else + createWalletDialog.open() + } + } + + Button { + id: connectedButton + objectName: "walletAccountButton" + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + visible: root.connected + enabled: !root.busy + implicitHeight: 40 + implicitWidth: root.compactLayout ? 44 : Math.max(140, accountButtonLabel.implicitWidth + 58) + Accessible.name: qsTr("Wallet account %1").arg(root.selectedAddress) + + background: Rectangle { + color: connectedButton.pressed ? "#3f3f46" : "#27272a" + border.width: walletMenu.opened || connectedButton.activeFocus ? 1 : 0 + border.color: "#f26a21" + radius: 6 + } + + contentItem: RowLayout { + spacing: 8 + + Rectangle { + Layout.preferredWidth: 8 + Layout.preferredHeight: 8 + radius: 4 + color: "#22c55e" + } + + Label { + id: accountButtonLabel + Layout.fillWidth: true + visible: !root.compactLayout + text: root.shortAddress(root.selectedAddress) || qsTr("Connected") + color: "#f4f4f5" + horizontalAlignment: Text.AlignHCenter + } + + Label { + visible: !root.compactLayout + text: walletMenu.opened ? "\u25b4" : "\u25be" + color: "#a1a1aa" + } + } + + onClicked: { + if (walletMenu.opened || Date.now() - walletMenu.lastClosedMs < 200) + walletMenu.close() + else + walletMenu.open() + } + } + + Popup { + id: walletMenu + objectName: "walletMenu" + property real lastClosedMs: 0 + property point anchorPosition: Qt.point(0, 0) + readonly property var viewport: Overlay.overlay + readonly property real spaceAbove: Math.max(0, anchorPosition.y - 20) + readonly property real spaceBelow: viewport + ? Math.max(0, viewport.height - anchorPosition.y - connectedButton.height - 20) + : implicitHeight + readonly property bool opensAbove: spaceBelow < implicitHeight && spaceAbove > spaceBelow + readonly property real availableMenuHeight: opensAbove ? spaceAbove : spaceBelow + parent: connectedButton + x: viewport + ? Math.max(12 - anchorPosition.x, + Math.min(connectedButton.width - width, + viewport.width - width - 12 - anchorPosition.x)) + : connectedButton.width - width + y: opensAbove + ? -height - 8 + : connectedButton.height + 8 + width: Math.min(360, Math.max(0, Math.min(root.viewportWidth, + viewport ? viewport.width : root.viewportWidth) - 24)) + height: Math.min(implicitHeight, availableMenuHeight) + margins: 12 + padding: 12 + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + function updateAnchor() { + if (viewport) + anchorPosition = connectedButton.mapToItem(viewport, 0, 0) + } + + onAboutToShow: updateAnchor() + + onClosed: { + walletMenu.lastClosedMs = Date.now() + if (walletStack.depth > 1) + walletStack.pop(null, StackView.Immediate) + } + + Connections { + target: walletMenu.opened ? walletMenu.viewport : null + + function onWidthChanged() { Qt.callLater(walletMenu.updateAnchor) } + function onHeightChanged() { Qt.callLater(walletMenu.updateAnchor) } + } + + background: Rectangle { + color: "#18181b" + border.color: "#3f3f46" + border.width: 1 + radius: 8 + } + + contentItem: StackView { + id: walletStack + width: walletMenu.availableWidth + height: walletMenu.availableHeight + implicitWidth: walletMenu.availableWidth + implicitHeight: currentItem ? currentItem.implicitHeight : 0 + initialItem: walletOverview + } + + Component { + id: walletOverview + + ColumnLayout { + spacing: 12 + + RowLayout { + Layout.fillWidth: true + + Item { Layout.fillWidth: true } + + WalletIconButton { + objectName: "walletAccountsButton" + iconSource: Qt.resolvedUrl("icons/account.svg") + accessibleName: qsTr("Accounts") + onClicked: walletStack.push(accountList) + } + + WalletIconButton { + objectName: "walletDisconnectButton" + iconSource: Qt.resolvedUrl("icons/power.svg") + accessibleName: qsTr("Disconnect") + onClicked: { + walletMenu.close() + if (root.wallet) + root.wallet.disconnectWallet() + } + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: accountCard.implicitHeight + 24 + color: "#27272a" + radius: 6 + + ColumnLayout { + id: accountCard + anchors.fill: parent + anchors.margins: 12 + spacing: 8 + + RowLayout { + Layout.fillWidth: true + + Label { + text: root.selectedName || qsTr("Account") + color: "#f4f4f5" + font.bold: true + } + + Label { + text: root.selectedIsPublic ? qsTr("Public") : qsTr("Private") + color: "#a1a1aa" + font.pixelSize: 11 + } + + Item { Layout.fillWidth: true } + + Label { + text: root.selectedBalance || "-" + color: "#f4f4f5" + font.bold: true + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 4 + + Label { + Layout.fillWidth: true + text: root.selectedAddress + color: "#a1a1aa" + font.family: "monospace" + font.pixelSize: 11 + elide: Text.ElideMiddle + } + + CopyButton { + visible: root.selectedAddress.length > 0 + onCopyRequested: root.copyToClipboard(root.selectedAddress) + } + } + } + } + } + } + + Component { + id: accountList + + ColumnLayout { + spacing: 12 + + RowLayout { + Layout.fillWidth: true + + WalletIconButton { + iconSource: Qt.resolvedUrl("icons/back.svg") + accessibleName: qsTr("Back") + onClicked: walletStack.pop() + } + + Label { + Layout.fillWidth: true + text: qsTr("Accounts") + color: "#f4f4f5" + font.bold: true + } + } + + ListView { + id: accountListView + objectName: "walletAccountList" + Layout.fillWidth: true + Layout.fillHeight: true + Layout.minimumHeight: 0 + Layout.preferredHeight: Math.min(contentHeight, 260) + clip: true + spacing: 6 + model: root.accountModel + ScrollIndicator.vertical: ScrollIndicator { } + + delegate: AccountDelegate { + width: ListView.view.width + highlighted: index === root.selectedIndex + onClicked: { + root.selectedIndex = index + walletStack.pop() + } + onCopyRequested: function(text) { root.copyToClipboard(text) } + } + } + + Button { + objectName: "walletAddAccountButton" + Layout.fillWidth: true + text: qsTr("Add account") + enabled: !root.busy + onClicked: createAccountDialog.open() + } + } + } + } + + CreateWalletDialog { + id: createWalletDialog + objectName: "createWalletDialog" + walletHome: root.wallet ? root.wallet.walletHome || "" : "" + busy: root.busy + + onCreateRequested: function(password) { + if (!root.wallet || root.busy) + return + root.busy = true + try { + root.watchResult(root.wallet.createNewDefault(password), function(mnemonic) { + root.busy = false + if (mnemonic && mnemonic.length > 0) + createWalletDialog.mnemonic = mnemonic + else + createWalletDialog.errorText = qsTr("Wallet could not be created.") + }, function(error) { + root.busy = false + createWalletDialog.errorText = qsTr("Wallet could not be created: %1").arg(error) + }) + } catch (error) { + root.busy = false + createWalletDialog.errorText = qsTr("Wallet could not be created: %1").arg(error) + } + } + + onCopyRequested: function(text) { root.copyToClipboard(text) } + } + + CreateAccountDialog { + id: createAccountDialog + objectName: "createAccountDialog" + busy: root.busy + + onCreateRequested: function(isPublic) { + if (!root.wallet || root.busy) + return + root.busy = true + try { + const request = isPublic + ? root.wallet.createAccountPublic() + : root.wallet.createAccountPrivate() + root.watchResult(request, function(accountId) { + root.busy = false + if (accountId && accountId.length > 0) { + createAccountDialog.close() + } else { + root.showError(qsTr("Account could not be created.")) + } + }, function(error) { + root.busy = false + root.showError(qsTr("Account could not be created: %1").arg(error)) + }) + } catch (error) { + root.busy = false + root.showError(qsTr("Account could not be created: %1").arg(error)) + } + } + } + + WalletMessageDialog { + id: messageDialog + objectName: "walletMessageDialog" + } +} diff --git a/apps/shared/wallet/qml/internal/AccountDelegate.qml b/apps/shared/wallet/qml/internal/AccountDelegate.qml new file mode 100644 index 00000000..b0ff529f --- /dev/null +++ b/apps/shared/wallet/qml/internal/AccountDelegate.qml @@ -0,0 +1,77 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +ItemDelegate { + id: root + + required property int index + required property string name + required property string address + required property string balance + required property bool isPublic + + signal copyRequested(string text) + + leftPadding: 12 + rightPadding: 8 + topPadding: 10 + bottomPadding: 10 + + Accessible.name: qsTr("%1, balance %2").arg(root.name).arg(root.balance || "0") + + background: Rectangle { + color: root.highlighted || root.hovered ? "#27272a" : "#18181b" + radius: 6 + border.width: root.activeFocus ? 1 : 0 + border.color: "#f26a21" + } + + contentItem: ColumnLayout { + spacing: 6 + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Label { + text: root.name + color: "#f4f4f5" + font.bold: true + } + + Label { + text: root.isPublic ? qsTr("Public") : qsTr("Private") + color: "#a1a1aa" + font.pixelSize: 11 + } + + Item { Layout.fillWidth: true } + + Label { + text: root.balance.length > 0 ? root.balance : "-" + color: "#f4f4f5" + font.bold: true + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 4 + + Label { + Layout.fillWidth: true + text: root.address + color: "#a1a1aa" + font.family: "monospace" + font.pixelSize: 11 + elide: Text.ElideMiddle + } + + CopyButton { + visible: root.address.length > 0 + onCopyRequested: root.copyRequested(root.address) + } + } + } +} diff --git a/apps/shared/wallet/qml/internal/CopyButton.qml b/apps/shared/wallet/qml/internal/CopyButton.qml new file mode 100644 index 00000000..1964da3c --- /dev/null +++ b/apps/shared/wallet/qml/internal/CopyButton.qml @@ -0,0 +1,26 @@ +import QtQuick + +WalletIconButton { + id: root + + signal copyRequested + + property bool copied: false + + accessibleName: root.copied ? qsTr("Copied") : qsTr("Copy") + iconSource: root.copied + ? Qt.resolvedUrl("icons/checkmark.svg") + : Qt.resolvedUrl("icons/copy.svg") + + Timer { + id: resetTimer + interval: 1500 + onTriggered: root.copied = false + } + + onClicked: { + root.copyRequested() + root.copied = true + resetTimer.restart() + } +} diff --git a/apps/shared/wallet/qml/internal/CreateAccountDialog.qml b/apps/shared/wallet/qml/internal/CreateAccountDialog.qml new file mode 100644 index 00000000..51d5f31f --- /dev/null +++ b/apps/shared/wallet/qml/internal/CreateAccountDialog.qml @@ -0,0 +1,94 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +Popup { + id: root + + property bool busy: false + + signal createRequested(bool isPublic) + + modal: true + dim: true + parent: Overlay.overlay + width: Math.max(0, Math.min(380, parent ? parent.width - 32 : 380)) + x: parent ? Math.max(0, Math.round((parent.width - width) / 2)) : 0 + y: parent ? Math.max(0, Math.round((parent.height - height) / 2)) : 0 + padding: 20 + closePolicy: root.busy ? Popup.NoAutoClose : Popup.CloseOnEscape | Popup.CloseOnPressOutside + + onOpened: privateSwitch.checked = false + + background: Rectangle { + color: "#18181b" + border.color: "#3f3f46" + border.width: 1 + radius: 8 + } + + contentItem: ColumnLayout { + spacing: 16 + + Label { + Layout.fillWidth: true + text: qsTr("Create account") + color: "#f4f4f5" + font.bold: true + font.pixelSize: 17 + } + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Label { + text: privateSwitch.checked ? qsTr("Private") : qsTr("Public") + color: "#f4f4f5" + font.bold: true + } + + Label { + Layout.fillWidth: true + text: privateSwitch.checked + ? qsTr("Private balance and activity") + : qsTr("Public balance and activity") + color: "#a1a1aa" + wrapMode: Text.WordWrap + } + } + + Switch { + id: privateSwitch + objectName: "privateAccountSwitch" + Accessible.name: qsTr("Create private account") + enabled: !root.busy + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 10 + + Button { + Layout.fillWidth: true + text: qsTr("Cancel") + enabled: !root.busy + onClicked: root.close() + } + + Button { + id: createButton + objectName: "createAccountButton" + Layout.fillWidth: true + text: root.busy ? qsTr("Creating...") : qsTr("Create") + enabled: !root.busy + onClicked: root.createRequested(!privateSwitch.checked) + } + } + } +} diff --git a/apps/shared/wallet/qml/internal/CreateWalletDialog.qml b/apps/shared/wallet/qml/internal/CreateWalletDialog.qml new file mode 100644 index 00000000..ac422c1f --- /dev/null +++ b/apps/shared/wallet/qml/internal/CreateWalletDialog.qml @@ -0,0 +1,190 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +Popup { + id: root + + property string walletHome: "" + property string mnemonic: "" + property string errorText: "" + property bool busy: false + + signal createRequested(string password) + signal copyRequested(string text) + + modal: true + dim: true + parent: Overlay.overlay + width: Math.max(0, Math.min(420, parent ? parent.width - 32 : 420)) + x: parent ? Math.max(0, Math.round((parent.width - width) / 2)) : 0 + y: parent ? Math.max(0, Math.round((parent.height - height) / 2)) : 0 + padding: 20 + closePolicy: root.busy || root.mnemonic.length > 0 + ? Popup.NoAutoClose + : Popup.CloseOnEscape | Popup.CloseOnPressOutside + + onOpened: { + passwordField.text = "" + confirmField.text = "" + acknowledgement.checked = false + root.errorText = "" + root.mnemonic = "" + passwordField.forceActiveFocus() + } + + background: Rectangle { + color: "#18181b" + border.color: "#3f3f46" + border.width: 1 + radius: 8 + } + + contentItem: ColumnLayout { + spacing: 16 + + ColumnLayout { + Layout.fillWidth: true + spacing: 14 + visible: root.mnemonic.length === 0 + + Label { + Layout.fillWidth: true + text: qsTr("Create wallet") + color: "#f4f4f5" + font.bold: true + font.pixelSize: 17 + } + + Label { + Layout.fillWidth: true + text: root.walletHome.length > 0 + ? qsTr("Wallet will be stored at %1").arg(root.walletHome) + : qsTr("Wallet will use default storage") + color: "#a1a1aa" + wrapMode: Text.WordWrap + } + + TextField { + id: passwordField + objectName: "walletPasswordField" + Layout.fillWidth: true + placeholderText: qsTr("Password") + echoMode: TextInput.Password + enabled: !root.busy + Keys.onReturnPressed: createButton.tryCreate() + } + + TextField { + id: confirmField + objectName: "walletConfirmPasswordField" + Layout.fillWidth: true + placeholderText: qsTr("Confirm password") + echoMode: TextInput.Password + enabled: !root.busy + Keys.onReturnPressed: createButton.tryCreate() + } + + Label { + Layout.fillWidth: true + visible: root.errorText.length > 0 + text: root.errorText + color: "#f87171" + wrapMode: Text.WordWrap + } + + RowLayout { + Layout.fillWidth: true + spacing: 10 + + Button { + Layout.fillWidth: true + text: qsTr("Cancel") + enabled: !root.busy + onClicked: root.close() + } + + Button { + id: createButton + objectName: "createWalletButton" + Layout.fillWidth: true + text: root.busy ? qsTr("Creating...") : qsTr("Create wallet") + enabled: !root.busy + + function tryCreate() { + if (passwordField.text.length === 0) { + root.errorText = qsTr("Password cannot be empty.") + } else if (passwordField.text !== confirmField.text) { + root.errorText = qsTr("Passwords do not match.") + } else { + root.errorText = "" + root.createRequested(passwordField.text) + } + } + + onClicked: tryCreate() + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 14 + visible: root.mnemonic.length > 0 + + Label { + Layout.fillWidth: true + text: qsTr("Back up recovery phrase") + color: "#f4f4f5" + font.bold: true + font.pixelSize: 17 + } + + Label { + Layout.fillWidth: true + text: qsTr("Write these words down in order. Anyone with this phrase can control this wallet.") + color: "#d4d4d8" + wrapMode: Text.WordWrap + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: phraseLabel.implicitHeight + 24 + color: "#27272a" + radius: 6 + + Label { + id: phraseLabel + objectName: "walletMnemonic" + anchors.fill: parent + anchors.margins: 12 + text: root.mnemonic + color: "#f4f4f5" + font.bold: true + wrapMode: Text.WordWrap + } + } + + Button { + Layout.fillWidth: true + text: qsTr("Copy recovery phrase") + onClicked: root.copyRequested(root.mnemonic) + } + + CheckBox { + id: acknowledgement + objectName: "walletBackupAcknowledgement" + Layout.fillWidth: true + text: qsTr("I have safely backed up my recovery phrase") + } + + Button { + objectName: "walletContinueButton" + Layout.fillWidth: true + text: qsTr("Continue") + enabled: acknowledgement.checked + onClicked: root.close() + } + } + } +} diff --git a/apps/shared/wallet/qml/internal/WalletIconButton.qml b/apps/shared/wallet/qml/internal/WalletIconButton.qml new file mode 100644 index 00000000..159d5e10 --- /dev/null +++ b/apps/shared/wallet/qml/internal/WalletIconButton.qml @@ -0,0 +1,31 @@ +import QtQuick +import QtQuick.Controls.Basic + +Button { + id: root + + property url iconSource + property string accessibleName: "" + property color iconColor: "#d4d4d8" + + implicitWidth: 36 + implicitHeight: 36 + display: AbstractButton.IconOnly + hoverEnabled: true + + Accessible.name: root.accessibleName + ToolTip.text: root.accessibleName + ToolTip.visible: root.hovered && root.accessibleName.length > 0 + + icon.source: root.iconSource + icon.width: 18 + icon.height: 18 + icon.color: root.iconColor + + background: Rectangle { + color: root.pressed ? "#3f3f46" : root.hovered || root.activeFocus ? "#27272a" : "transparent" + radius: 6 + border.width: root.activeFocus ? 1 : 0 + border.color: "#f26a21" + } +} diff --git a/apps/shared/wallet/qml/internal/WalletMessageDialog.qml b/apps/shared/wallet/qml/internal/WalletMessageDialog.qml new file mode 100644 index 00000000..7f7a3467 --- /dev/null +++ b/apps/shared/wallet/qml/internal/WalletMessageDialog.qml @@ -0,0 +1,52 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +Popup { + id: root + + property string title: qsTr("Wallet error") + property string message: "" + + modal: true + dim: true + parent: Overlay.overlay + width: Math.max(0, Math.min(380, parent ? parent.width - 32 : 380)) + x: parent ? Math.max(0, Math.round((parent.width - width) / 2)) : 0 + y: parent ? Math.max(0, Math.round((parent.height - height) / 2)) : 0 + padding: 20 + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + background: Rectangle { + color: "#18181b" + border.color: "#3f3f46" + border.width: 1 + radius: 8 + } + + contentItem: ColumnLayout { + spacing: 16 + + Label { + Layout.fillWidth: true + text: root.title + color: "#f4f4f5" + font.bold: true + font.pixelSize: 17 + wrapMode: Text.WordWrap + } + + Label { + Layout.fillWidth: true + text: root.message + color: "#d4d4d8" + wrapMode: Text.WordWrap + } + + Button { + Layout.alignment: Qt.AlignRight + text: qsTr("Close") + onClicked: root.close() + } + } +} diff --git a/apps/amm/qml/components/wallet/icons/account.svg b/apps/shared/wallet/qml/internal/icons/account.svg similarity index 100% rename from apps/amm/qml/components/wallet/icons/account.svg rename to apps/shared/wallet/qml/internal/icons/account.svg diff --git a/apps/amm/qml/components/wallet/icons/back.svg b/apps/shared/wallet/qml/internal/icons/back.svg similarity index 100% rename from apps/amm/qml/components/wallet/icons/back.svg rename to apps/shared/wallet/qml/internal/icons/back.svg diff --git a/apps/amm/qml/components/wallet/icons/checkmark.svg b/apps/shared/wallet/qml/internal/icons/checkmark.svg similarity index 100% rename from apps/amm/qml/components/wallet/icons/checkmark.svg rename to apps/shared/wallet/qml/internal/icons/checkmark.svg diff --git a/apps/amm/qml/components/wallet/icons/copy.svg b/apps/shared/wallet/qml/internal/icons/copy.svg similarity index 100% rename from apps/amm/qml/components/wallet/icons/copy.svg rename to apps/shared/wallet/qml/internal/icons/copy.svg diff --git a/apps/amm/qml/components/wallet/icons/power.svg b/apps/shared/wallet/qml/internal/icons/power.svg similarity index 100% rename from apps/amm/qml/components/wallet/icons/power.svg rename to apps/shared/wallet/qml/internal/icons/power.svg diff --git a/apps/shared/wallet/src/LogosWalletProvider.cpp b/apps/shared/wallet/src/LogosWalletProvider.cpp new file mode 100644 index 00000000..3e569525 --- /dev/null +++ b/apps/shared/wallet/src/LogosWalletProvider.cpp @@ -0,0 +1,382 @@ +#include "LogosWalletProvider.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "logos_sdk.h" + +namespace { +constexpr int WALLET_FFI_SUCCESS = 0; + +bool isHex(const QString& value, qsizetype size, bool lowercaseOnly = true) +{ + if (value.size() != size) + return false; + + for (const QChar character : value) { + const bool digit = character >= QLatin1Char('0') + && character <= QLatin1Char('9'); + const bool lowercase = character >= QLatin1Char('a') + && character <= QLatin1Char('f'); + const bool uppercase = character >= QLatin1Char('A') + && character <= QLatin1Char('F'); + if (!digit && !lowercase && (!uppercase || lowercaseOnly)) + return false; + } + return true; +} + +QString littleEndianU128ToDecimal(const QString& value) +{ + if (!isHex(value, 32)) + return {}; + + QString decimal = QStringLiteral("0"); + for (int byteIndex = 15; byteIndex >= 0; --byteIndex) { + bool parsed = false; + int carry = value.mid(byteIndex * 2, 2).toInt(&parsed, 16); + if (!parsed) + return {}; + + for (qsizetype digit = decimal.size(); digit-- > 0;) { + const int next = (decimal.at(digit).unicode() - QLatin1Char('0').unicode()) + * 256 + carry; + decimal[digit] = QLatin1Char(static_cast('0' + next % 10)); + carry = next / 10; + } + while (carry > 0) { + decimal.prepend(QLatin1Char(static_cast('0' + carry % 10))); + carry /= 10; + } + } + + return decimal; +} + +WalletSession failedSession(WalletFailure failure) +{ + WalletSession session; + session.failure = failure; + session.snapshot.failure = failure; + return session; +} + +WalletCreation failedCreation(WalletFailure failure) +{ + WalletCreation creation; + creation.failure = failure; + creation.snapshot.failure = failure; + return creation; +} +} + +struct LogosWalletProvider::Impl { + explicit Impl(LogosAPI* api) + : ownedLogos(std::make_unique(api)), logos(ownedLogos.get()) + { + } + + explicit Impl(LogosModules* value) + : logos(value) + { + } + + std::unique_ptr ownedLogos; + LogosModules* logos = nullptr; +}; + +LogosWalletProvider::LogosWalletProvider(LogosAPI* api) + : m_impl(std::make_unique(api)) +{ +} + +LogosWalletProvider::LogosWalletProvider(LogosModules* logos) + : m_impl(std::make_unique(logos)) +{ +} + +LogosWalletProvider::~LogosWalletProvider() +{ + if (m_connected) + save(); +} + +WalletSession LogosWalletProvider::connect(const WalletPaths& paths) +{ + clearSnapshot(); + if (!m_impl->logos) + return failedSession(WalletFailure::WalletUnavailable); + + WalletSession session; + if (sharedWalletIsOpen()) { + session.adopted = true; + } else { + if (!QFileInfo::exists(paths.storage)) + return failedSession(WalletFailure::WalletMissing); + if (m_impl->logos->logos_execution_zone.open(paths.config, paths.storage) + != WALLET_FFI_SUCCESS) { + return failedSession(WalletFailure::OpenFailed); + } + } + + m_connected = true; + session.snapshot = snapshot(true); + session.failure = session.snapshot.failure; + return session; +} + +WalletCreation LogosWalletProvider::createWallet(const WalletPaths& paths, + const QString& password) +{ + clearSnapshot(); + if (!m_impl->logos) + return failedCreation(WalletFailure::WalletUnavailable); + + const QFileInfo configInfo(paths.config); + const QFileInfo storageInfo(paths.storage); + if (!QDir().mkpath(configInfo.absolutePath()) + || !QDir().mkpath(storageInfo.absolutePath())) { + return failedCreation(WalletFailure::CreateFailed); + } + + WalletCreation creation; + creation.mnemonic = m_impl->logos->logos_execution_zone.create_new( + paths.config, paths.storage, password); + if (creation.mnemonic.isEmpty()) + return failedCreation(WalletFailure::CreateFailed); + + m_connected = true; + if (!save()) { + creation.failure = WalletFailure::SaveFailed; + creation.snapshot.failure = creation.failure; + return creation; + } + + creation.snapshot = snapshot(true); + creation.failure = creation.snapshot.failure; + return creation; +} + +WalletSnapshot LogosWalletProvider::snapshot(bool forceRefresh) +{ + if (m_snapshotReady && !forceRefresh) + return m_snapshot; + if (!m_connected) { + WalletSnapshot result; + result.failure = WalletFailure::WalletUnavailable; + return result; + } + + WalletSnapshot result = loadSnapshot(); + if (result.ok()) { + m_snapshot = result; + m_snapshotReady = true; + } + return result; +} + +void LogosWalletProvider::clearSnapshot() +{ + m_snapshot = {}; + m_snapshotReady = false; +} + +WalletAccountCreation LogosWalletProvider::createAccount(bool isPublic) +{ + WalletAccountCreation creation; + if (!m_connected || !m_impl->logos) { + creation.failure = WalletFailure::WalletUnavailable; + return creation; + } + + creation.accountId = isPublic + ? m_impl->logos->logos_execution_zone.create_account_public() + : m_impl->logos->logos_execution_zone.create_account_private(); + if (!isHex(creation.accountId, 64)) { + creation.failure = WalletFailure::CreateFailed; + return creation; + } + if (!save()) { + creation.failure = WalletFailure::SaveFailed; + return creation; + } + + if (isPublic) + creation.publicAccount = readPublicAccount(creation.accountId); + + clearSnapshot(); + creation.snapshot = snapshot(true); + return creation; +} + +WalletAccountRead LogosWalletProvider::readPublicAccount(const QString& accountId) const +{ + WalletAccountRead read; + read.accountId = accountId; + if (!m_impl->logos || !isHex(accountId, 64)) + return read; + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson( + m_impl->logos->logos_execution_zone.get_account_public(accountId).toUtf8(), + &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + return read; + + const QJsonObject account = document.object(); + const QString owner = account.value(QStringLiteral("program_owner")).toString(); + const QString balance = account.value(QStringLiteral("balance")).toString(); + const QString nonce = account.value(QStringLiteral("nonce")).toString(); + const QString data = account.value(QStringLiteral("data")).toString(); + if (!isHex(owner, 64) + || !isHex(balance, 32) + || !isHex(nonce, 32) + || data.size() % 2 != 0 + || !isHex(data, data.size())) { + return read; + } + + read.status = QStringLiteral("ok"); + read.programOwner = owner; + read.balanceHex = balance; + read.nonceHex = nonce; + read.dataHex = data; + return read; +} + +WalletSubmission LogosWalletProvider::submitPublicTransaction( + const WalletTransaction& transaction) +{ + WalletSubmission submission; + if (!m_connected || !m_impl->logos) { + submission.failure = WalletFailure::WalletUnavailable; + return submission; + } + if (!isHex(transaction.programId, 64) + || transaction.accountIds.size() != transaction.signingRequirements.size()) { + submission.failure = WalletFailure::InvalidRequest; + return submission; + } + for (const QString& accountId : transaction.accountIds) { + if (!isHex(accountId, 64)) { + submission.failure = WalletFailure::InvalidRequest; + return submission; + } + } + + QVariantList signingRequirements; + signingRequirements.reserve(transaction.signingRequirements.size()); + for (bool required : transaction.signingRequirements) + signingRequirements.append(required); + + QVariantList instruction; + instruction.reserve(transaction.instruction.size()); + for (quint32 word : transaction.instruction) + instruction.append(word); + + const QString response = + m_impl->logos->logos_execution_zone.send_generic_public_transaction( + transaction.accountIds, + signingRequirements, + QVariant::fromValue(instruction), + transaction.programId); + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(response.toUtf8(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + submission.failure = WalletFailure::SubmissionFailed; + return submission; + } + + const QJsonObject result = document.object(); + const QJsonValue success = result.value(QStringLiteral("success")); + const QJsonValue error = result.value(QStringLiteral("error")); + const QString hash = result.value(QStringLiteral("tx_hash")).toString(); + const bool emptyError = error.isUndefined() + || error.isNull() + || (error.isString() && error.toString().isEmpty()); + if (!success.isBool() + || !success.toBool() + || !emptyError + || !isHex(hash, 64, false)) { + submission.failure = WalletFailure::SubmissionFailed; + return submission; + } + + submission.nativeHash = hash.toLower(); + return submission; +} + +void LogosWalletProvider::disconnect() +{ + if (m_connected) + save(); + clearSnapshot(); + m_connected = false; +} + +bool LogosWalletProvider::sharedWalletIsOpen() const +{ + if (!m_impl->logos) + return false; + if (!m_impl->logos->logos_execution_zone.get_sequencer_addr().isEmpty()) + return true; + return !m_impl->logos->logos_execution_zone.list_accounts().isEmpty(); +} + +WalletSnapshot LogosWalletProvider::loadSnapshot() +{ + WalletSnapshot result; + result.currentBlockHeight = static_cast( + qMax(0, m_impl->logos->logos_execution_zone.get_current_block_height())); + if (result.currentBlockHeight > 0 + && m_impl->logos->logos_execution_zone.sync_to_block(result.currentBlockHeight) + != WALLET_FFI_SUCCESS) { + result.failure = WalletFailure::ReadFailed; + return result; + } + result.lastSyncedBlock = static_cast( + qMax(0, m_impl->logos->logos_execution_zone.get_last_synced_block())); + result.sequencerAddress = m_impl->logos->logos_execution_zone.get_sequencer_addr(); + + const QVariantList entries = m_impl->logos->logos_execution_zone.list_accounts(); + result.accounts.reserve(entries.size()); + result.publicAccountReads.reserve(entries.size()); + for (const QVariant& value : entries) { + const QVariantMap entry = value.toMap(); + const QString address = entry.value(QStringLiteral("account_id")).toString(); + if (entry.isEmpty() || !isHex(address, 64)) { + result.failure = WalletFailure::ReadFailed; + return result; + } + + WalletAccount account; + account.address = address; + account.isPublic = entry.value(QStringLiteral("is_public"), true).toBool(); + if (account.isPublic) { + const WalletAccountRead read = readPublicAccount(address); + result.publicAccountReads.append(read); + account.balance = read.ok() + ? littleEndianU128ToDecimal(read.balanceHex) + : m_impl->logos->logos_execution_zone.get_balance(address, true); + } else { + account.balance = m_impl->logos->logos_execution_zone.get_balance(address, false); + } + result.accounts.append(account); + } + + if (!save()) + result.failure = WalletFailure::SaveFailed; + return result; +} + +bool LogosWalletProvider::save() const +{ + return m_impl->logos + && m_impl->logos->logos_execution_zone.save() == WALLET_FFI_SUCCESS; +} diff --git a/apps/shared/wallet/src/LogosWalletProvider.h b/apps/shared/wallet/src/LogosWalletProvider.h new file mode 100644 index 00000000..d0462906 --- /dev/null +++ b/apps/shared/wallet/src/LogosWalletProvider.h @@ -0,0 +1,37 @@ +#pragma once + +#include + +#include "WalletProvider.h" + +class LogosAPI; +struct LogosModules; + +class LogosWalletProvider final : public WalletProvider { +public: + explicit LogosWalletProvider(LogosAPI* api); + explicit LogosWalletProvider(LogosModules* logos); + ~LogosWalletProvider() override; + + WalletSession connect(const WalletPaths& paths) override; + WalletCreation createWallet(const WalletPaths& paths, + const QString& password) override; + WalletSnapshot snapshot(bool forceRefresh = false) override; + void clearSnapshot() override; + WalletAccountCreation createAccount(bool isPublic) override; + WalletAccountRead readPublicAccount(const QString& accountId) const override; + WalletSubmission submitPublicTransaction( + const WalletTransaction& transaction) override; + void disconnect() override; + +private: + bool sharedWalletIsOpen() const; + WalletSnapshot loadSnapshot(); + bool save() const; + + struct Impl; + std::unique_ptr m_impl; + WalletSnapshot m_snapshot; + bool m_snapshotReady = false; + bool m_connected = false; +}; diff --git a/apps/shared/wallet/src/WalletAccountModel.cpp b/apps/shared/wallet/src/WalletAccountModel.cpp new file mode 100644 index 00000000..06e94bfd --- /dev/null +++ b/apps/shared/wallet/src/WalletAccountModel.cpp @@ -0,0 +1,61 @@ +#include "WalletAccountModel.h" + +WalletAccountModel::WalletAccountModel(QObject* parent) + : QAbstractListModel(parent) +{ +} + +int WalletAccountModel::rowCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : m_accounts.size(); +} + +QVariant WalletAccountModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_accounts.size()) + return {}; + + const Entry& account = m_accounts.at(index.row()); + switch (role) { + case NameRole: + return account.name; + case AddressRole: + return account.address; + case BalanceRole: + return account.balance; + case IsPublicRole: + return account.isPublic; + default: + return {}; + } +} + +QHash WalletAccountModel::roleNames() const +{ + return { + { NameRole, "name" }, + { AddressRole, "address" }, + { BalanceRole, "balance" }, + { IsPublicRole, "isPublic" }, + }; +} + +void WalletAccountModel::replaceAccounts(const QVector& accounts) +{ + beginResetModel(); + const qsizetype oldCount = m_accounts.size(); + m_accounts.clear(); + m_accounts.reserve(accounts.size()); + for (qsizetype index = 0; index < accounts.size(); ++index) { + const WalletAccount& account = accounts.at(index); + m_accounts.append({ + QStringLiteral("Account %1").arg(index + 1), + account.address, + account.balance, + account.isPublic, + }); + } + endResetModel(); + if (oldCount != m_accounts.size()) + emit countChanged(); +} diff --git a/apps/shared/wallet/src/WalletAccountModel.h b/apps/shared/wallet/src/WalletAccountModel.h new file mode 100644 index 00000000..c00b0d65 --- /dev/null +++ b/apps/shared/wallet/src/WalletAccountModel.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include + +#include "WalletProvider.h" + +class WalletAccountModel final : public QAbstractListModel { + Q_OBJECT + Q_PROPERTY(int count READ count NOTIFY countChanged) + +public: + enum Role { + NameRole = Qt::UserRole + 1, + AddressRole, + BalanceRole, + IsPublicRole, + }; + Q_ENUM(Role) + + explicit WalletAccountModel(QObject* parent = nullptr); + + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + void replaceAccounts(const QVector& accounts); + int count() const { return m_accounts.size(); } + +signals: + void countChanged(); + +private: + struct Entry { + QString name; + QString address; + QString balance; + bool isPublic = true; + }; + + QVector m_accounts; +}; diff --git a/apps/shared/wallet/src/WalletController.cpp b/apps/shared/wallet/src/WalletController.cpp new file mode 100644 index 00000000..5cffdfd5 --- /dev/null +++ b/apps/shared/wallet/src/WalletController.cpp @@ -0,0 +1,238 @@ +#include "WalletController.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "WalletAccountModel.h" + +namespace { +const char SETTINGS_ORG[] = "Logos"; +const char DISCONNECTED_KEY[] = "disconnected"; +const char WALLET_HOME_ENV[] = "LEE_WALLET_HOME_DIR"; + +QString toLocalPath(const QString& path) +{ + if (path.startsWith(QStringLiteral("file://")) || path.contains(QLatin1Char('/'))) + return QUrl::fromUserInput(path).toLocalFile(); + return path; +} +} + +WalletController::WalletController(WalletProvider& wallet, + QString settingsApplication, + QObject* parent) + : QObject(parent), + m_wallet(wallet), + m_settingsApplication(std::move(settingsApplication)), + m_accountModel(new WalletAccountModel(this)), + m_network(new QNetworkAccessManager(this)), + m_reachabilityTimer(new QTimer(this)) +{ + m_state.walletHome = defaultWalletHome(); + m_state.walletExists = QFileInfo::exists(defaultStoragePath()); + + m_reachabilityTimer->setInterval(10000); + connect(m_reachabilityTimer, &QTimer::timeout, + this, &WalletController::checkReachability); +} + +WalletController::~WalletController() = default; + +QString WalletController::defaultWalletHome() +{ + const QByteArray override = qgetenv(WALLET_HOME_ENV); + if (!override.isEmpty()) + return QString::fromLocal8Bit(override); + return QDir::homePath() + QStringLiteral("/.lee/wallet"); +} + +QString WalletController::defaultConfigPath() const +{ + return m_state.walletHome + QStringLiteral("/wallet_config.json"); +} + +QString WalletController::defaultStoragePath() const +{ + return m_state.walletHome + QStringLiteral("/storage.json"); +} + +void WalletController::start() +{ + if (m_started) + return; + m_started = true; + m_reachabilityTimer->start(); + QTimer::singleShot(0, this, &WalletController::openOnStartup); +} + +void WalletController::openOnStartup() +{ + if (QSettings(SETTINGS_ORG, m_settingsApplication) + .value(DISCONNECTED_KEY, false).toBool()) { + return; + } + + const QString config = defaultConfigPath(); + const QString storage = defaultStoragePath(); + const WalletSession session = m_wallet.connect({ config, storage }); + if (session.failure == WalletFailure::WalletMissing) + return; + if (!session.ok()) { + qWarning() << "WalletController: wallet connection failed" + << walletFailureCode(session.failure); + return; + } + + m_state.configPath = config; + m_state.storagePath = storage; + m_state.walletExists = QFileInfo::exists(storage) || session.adopted; + m_state.isWalletOpen = true; + applySnapshot(session.snapshot); +} + +QString WalletController::createDefaultWallet(const QString& password) +{ + return createWallet(defaultConfigPath(), defaultStoragePath(), password); +} + +QString WalletController::createWallet(const QString& configPath, + const QString& storagePath, + const QString& password) +{ + const QString config = toLocalPath(configPath); + const QString storage = toLocalPath(storagePath); + const WalletCreation creation = m_wallet.createWallet( + { config, storage }, password); + if (creation.mnemonic.isEmpty()) { + qWarning() << "WalletController: wallet creation failed" + << walletFailureCode(creation.failure); + return {}; + } + + m_state.configPath = config; + m_state.storagePath = storage; + m_state.walletExists = true; + QSettings(SETTINGS_ORG, m_settingsApplication).setValue(DISCONNECTED_KEY, false); + if (!creation.ok()) { + qWarning() << "WalletController: wallet creation failed" + << walletFailureCode(creation.failure); + emit stateChanged(); + return creation.mnemonic; + } + + m_state.isWalletOpen = true; + applySnapshot(creation.snapshot); + return creation.mnemonic; +} + +bool WalletController::open() +{ + const QString config = m_state.configPath.isEmpty() + ? defaultConfigPath() : m_state.configPath; + const QString storage = m_state.storagePath.isEmpty() + ? defaultStoragePath() : m_state.storagePath; + const WalletSession session = m_wallet.connect({ config, storage }); + if (!session.ok()) { + qWarning() << "WalletController: wallet open failed" + << walletFailureCode(session.failure); + return false; + } + + m_state.configPath = config; + m_state.storagePath = storage; + m_state.walletExists = true; + m_state.isWalletOpen = true; + QSettings(SETTINGS_ORG, m_settingsApplication).setValue(DISCONNECTED_KEY, false); + applySnapshot(session.snapshot); + return true; +} + +void WalletController::disconnect() +{ + m_wallet.disconnect(); + m_state.isWalletOpen = false; + m_accountModel->replaceAccounts({}); + QSettings(SETTINGS_ORG, m_settingsApplication).setValue(DISCONNECTED_KEY, true); + emit stateChanged(); +} + +QString WalletController::createAccount(bool isPublic) +{ + const WalletAccountCreation creation = m_wallet.createAccount(isPublic); + if (!creation.ok()) { + qWarning() << "WalletController: account creation failed" + << walletFailureCode(creation.failure); + return {}; + } + if (creation.snapshot.ok()) { + applySnapshot(creation.snapshot); + } else { + qWarning() << "WalletController: account refresh failed" + << walletFailureCode(creation.snapshot.failure); + } + return creation.accountId; +} + +void WalletController::refresh() +{ + const WalletSnapshot next = m_wallet.snapshot(true); + if (next.ok()) { + applySnapshot(next); + } else { + qWarning() << "WalletController: wallet refresh failed" + << walletFailureCode(next.failure); + } +} + +QString WalletController::balance(const QString& accountId, bool isPublic) +{ + const WalletSnapshot current = m_wallet.snapshot(); + for (const WalletAccount& account : current.accounts) { + if (account.address == accountId && account.isPublic == isPublic) + return account.balance; + } + return {}; +} + +void WalletController::applySnapshot(const WalletSnapshot& snapshot) +{ + m_accountModel->replaceAccounts(snapshot.accounts); + m_state.lastSyncedBlock = static_cast(snapshot.lastSyncedBlock); + m_state.currentBlockHeight = static_cast(snapshot.currentBlockHeight); + m_state.sequencerAddress = snapshot.sequencerAddress; + emit stateChanged(); + checkReachability(); +} + +void WalletController::checkReachability() +{ + if (!m_state.isWalletOpen || m_state.sequencerAddress.isEmpty()) + return; + + QNetworkRequest request{QUrl(m_state.sequencerAddress)}; + request.setTransferTimeout(4000); + QNetworkReply* reply = m_network->get(request); + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + if (!m_state.isWalletOpen) { + reply->deleteLater(); + return; + } + const bool receivedHttp = + reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).isValid(); + const bool reachable = receivedHttp || reply->error() == QNetworkReply::NoError; + if (m_state.sequencerReachable != reachable) { + m_state.sequencerReachable = reachable; + emit stateChanged(); + } + reply->deleteLater(); + }); +} diff --git a/apps/shared/wallet/src/WalletController.h b/apps/shared/wallet/src/WalletController.h new file mode 100644 index 00000000..c27f9cef --- /dev/null +++ b/apps/shared/wallet/src/WalletController.h @@ -0,0 +1,67 @@ +#pragma once + +#include +#include + +#include "WalletProvider.h" + +class QNetworkAccessManager; +class QTimer; +class WalletAccountModel; + +struct WalletUiState { + bool isWalletOpen = false; + bool walletExists = false; + QString configPath; + QString storagePath; + QString walletHome; + int lastSyncedBlock = 0; + int currentBlockHeight = 0; + QString sequencerAddress; + bool sequencerReachable = true; +}; + +class WalletController final : public QObject { + Q_OBJECT + +public: + // The provider must outlive the controller. + explicit WalletController(WalletProvider& wallet, + QString settingsApplication, + QObject* parent = nullptr); + ~WalletController() override; + + WalletAccountModel* accountModel() const { return m_accountModel; } + const WalletUiState& state() const { return m_state; } + + void start(); + QString createAccount(bool isPublic); + void refresh(); + QString balance(const QString& accountId, bool isPublic); + QString createDefaultWallet(const QString& password); + QString createWallet(const QString& configPath, + const QString& storagePath, + const QString& password); + bool open(); + void disconnect(); + +signals: + void stateChanged(); + +private: + static QString defaultWalletHome(); + QString defaultConfigPath() const; + QString defaultStoragePath() const; + + void openOnStartup(); + void applySnapshot(const WalletSnapshot& snapshot); + void checkReachability(); + + WalletProvider& m_wallet; + QString m_settingsApplication; + WalletUiState m_state; + WalletAccountModel* m_accountModel; + QNetworkAccessManager* m_network; + QTimer* m_reachabilityTimer; + bool m_started = false; +}; diff --git a/apps/shared/wallet/src/WalletProvider.cpp b/apps/shared/wallet/src/WalletProvider.cpp new file mode 100644 index 00000000..9cb259d4 --- /dev/null +++ b/apps/shared/wallet/src/WalletProvider.cpp @@ -0,0 +1,26 @@ +#include "WalletProvider.h" + +QString walletFailureCode(WalletFailure failure) +{ + switch (failure) { + case WalletFailure::None: + return {}; + case WalletFailure::WalletMissing: + return QStringLiteral("wallet_missing"); + case WalletFailure::WalletUnavailable: + return QStringLiteral("wallet_unavailable"); + case WalletFailure::OpenFailed: + return QStringLiteral("open_failed"); + case WalletFailure::CreateFailed: + return QStringLiteral("create_failed"); + case WalletFailure::SaveFailed: + return QStringLiteral("save_failed"); + case WalletFailure::ReadFailed: + return QStringLiteral("read_failed"); + case WalletFailure::InvalidRequest: + return QStringLiteral("invalid_request"); + case WalletFailure::SubmissionFailed: + return QStringLiteral("submission_failed"); + } + return QStringLiteral("wallet_unavailable"); +} diff --git a/apps/shared/wallet/src/WalletProvider.h b/apps/shared/wallet/src/WalletProvider.h new file mode 100644 index 00000000..a7814caa --- /dev/null +++ b/apps/shared/wallet/src/WalletProvider.h @@ -0,0 +1,107 @@ +#pragma once + +#include +#include +#include + +enum class WalletFailure { + None, + WalletMissing, + WalletUnavailable, + OpenFailed, + CreateFailed, + SaveFailed, + ReadFailed, + InvalidRequest, + SubmissionFailed, +}; + +QString walletFailureCode(WalletFailure failure); + +struct WalletPaths { + QString config; + QString storage; +}; + +struct WalletAccountRead { + QString accountId; + QString status = QStringLiteral("read_failed"); + QString programOwner; + QString balanceHex; + QString nonceHex; + QString dataHex; + + bool ok() const { return status == QStringLiteral("ok"); } +}; + +struct WalletAccount { + QString address; + QString balance; + bool isPublic = true; +}; + +struct WalletSnapshot { + WalletFailure failure = WalletFailure::None; + QVector accounts; + QVector publicAccountReads; + quint64 lastSyncedBlock = 0; + quint64 currentBlockHeight = 0; + QString sequencerAddress; + + bool ok() const { return failure == WalletFailure::None; } +}; + +struct WalletSession { + WalletFailure failure = WalletFailure::None; + WalletSnapshot snapshot; + bool adopted = false; + + bool ok() const { return failure == WalletFailure::None; } +}; + +struct WalletCreation { + WalletFailure failure = WalletFailure::None; + QString mnemonic; + WalletSnapshot snapshot; + + bool ok() const { return failure == WalletFailure::None; } +}; + +struct WalletAccountCreation { + WalletFailure failure = WalletFailure::None; + QString accountId; + WalletAccountRead publicAccount; + WalletSnapshot snapshot; + + bool ok() const { return failure == WalletFailure::None; } +}; + +struct WalletTransaction { + QString programId; + QStringList accountIds; + QVector signingRequirements; + QVector instruction; +}; + +struct WalletSubmission { + WalletFailure failure = WalletFailure::None; + QString nativeHash; + + bool accepted() const { return failure == WalletFailure::None && !nativeHash.isEmpty(); } +}; + +class WalletProvider { +public: + virtual ~WalletProvider() = default; + + virtual WalletSession connect(const WalletPaths& paths) = 0; + virtual WalletCreation createWallet(const WalletPaths& paths, + const QString& password) = 0; + virtual WalletSnapshot snapshot(bool forceRefresh = false) = 0; + virtual void clearSnapshot() = 0; + virtual WalletAccountCreation createAccount(bool isPublic) = 0; + virtual WalletAccountRead readPublicAccount(const QString& accountId) const = 0; + virtual WalletSubmission submitPublicTransaction( + const WalletTransaction& transaction) = 0; + virtual void disconnect() = 0; +}; diff --git a/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp new file mode 100644 index 00000000..9701d6b5 --- /dev/null +++ b/apps/shared/wallet/tests/cpp/LogosWalletProviderTest.cpp @@ -0,0 +1,452 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FakeWalletProvider.h" +#include "LogosWalletProvider.h" +#include "WalletAccountModel.h" +#include "WalletController.h" +#include "logos_sdk.h" + +namespace { +const QString ACCOUNT_A(64, QLatin1Char('a')); +const QString ACCOUNT_B(64, QLatin1Char('b')); +const QString PROGRAM_ID(64, QLatin1Char('c')); + +QString publicAccountJson(const QString& owner = PROGRAM_ID, + const QString& balance = QStringLiteral("01000000000000000000000000000000"), + const QString& nonce = QString(32, QLatin1Char('0')), + const QString& data = QStringLiteral("00ff")) +{ + return QString::fromUtf8(QJsonDocument(QJsonObject { + { QStringLiteral("program_owner"), owner }, + { QStringLiteral("balance"), balance }, + { QStringLiteral("nonce"), nonce }, + { QStringLiteral("data"), data }, + }).toJson(QJsonDocument::Compact)); +} + +QVariantMap accountEntry(const QString& id, bool isPublic) +{ + return { + { QStringLiteral("account_id"), id }, + { QStringLiteral("is_public"), isPublic }, + }; +} +} + +class LogosWalletProviderTest : public QObject { + Q_OBJECT + +private slots: + void adoptsOpenWalletAndCachesSnapshots(); + void opensConfiguredWalletWhenNoSharedSessionExists(); + void createsAndPersistsWallet(); + void validatesCompletePublicAccountPayloads(); + void fallsBackToBalanceWhenPublicReadFails(); + void createsAndPersistsAccounts(); + void preservesCreatedAccountWhenPublicReadFails(); + void preservesCreatedAccountWhenSnapshotRefreshFails(); + void dispatchesExactGenericTransaction(); + void rejectsInvalidSubmissionResponses(); + void exposesStableAccountModelRoles(); + void fakeProviderImplementsConsumerContract(); + void controllerOwnsUiWalletFlow(); + void controllerStopsReachabilityChecksAfterDisconnect(); +}; + +void LogosWalletProviderTest::adoptsOpenWalletAndCachesSnapshots() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + modules.logos_execution_zone.currentBlockHeight = 12; + modules.logos_execution_zone.lastSyncedBlock = 11; + modules.logos_execution_zone.accounts = { + accountEntry(ACCOUNT_A, true), + accountEntry(ACCOUNT_B, false), + }; + modules.logos_execution_zone.publicAccounts.insert( + ACCOUNT_A, publicAccountJson()); + modules.logos_execution_zone.balances.insert(ACCOUNT_B, QStringLiteral("42")); + + LogosWalletProvider provider(&modules); + const WalletSession session = provider.connect({ QStringLiteral("unused"), QStringLiteral("unused") }); + + QVERIFY(session.ok()); + QVERIFY(session.adopted); + QCOMPARE(session.snapshot.accounts.size(), 2); + QCOMPARE(session.snapshot.accounts.at(0).balance, QStringLiteral("1")); + QCOMPARE(session.snapshot.accounts.at(1).balance, QStringLiteral("42")); + QCOMPARE(session.snapshot.publicAccountReads.size(), 1); + QCOMPARE(session.snapshot.currentBlockHeight, quint64(12)); + QCOMPARE(session.snapshot.lastSyncedBlock, quint64(11)); + + const int listCalls = modules.logos_execution_zone.listCalls; + const int readCalls = modules.logos_execution_zone.publicReadCalls; + QVERIFY(provider.snapshot().ok()); + QCOMPARE(modules.logos_execution_zone.listCalls, listCalls); + QCOMPARE(modules.logos_execution_zone.publicReadCalls, readCalls); + + QVERIFY(provider.snapshot(true).ok()); + QVERIFY(modules.logos_execution_zone.listCalls > listCalls); + QVERIFY(modules.logos_execution_zone.publicReadCalls > readCalls); + + modules.logos_execution_zone.publicAccounts[ACCOUNT_A] = publicAccountJson( + PROGRAM_ID, QString(32, QLatin1Char('f'))); + QCOMPARE(provider.snapshot(true).accounts.at(0).balance, + QStringLiteral("340282366920938463463374607431768211455")); + + provider.clearSnapshot(); + const int afterRefresh = modules.logos_execution_zone.listCalls; + QVERIFY(provider.snapshot().ok()); + QVERIFY(modules.logos_execution_zone.listCalls > afterRefresh); + + provider.disconnect(); + QCOMPARE(provider.snapshot().failure, WalletFailure::WalletUnavailable); +} + +void LogosWalletProviderTest::opensConfiguredWalletWhenNoSharedSessionExists() +{ + QTemporaryDir directory; + QVERIFY(directory.isValid()); + const QString storage = directory.filePath(QStringLiteral("storage.json")); + QFile file(storage); + QVERIFY(file.open(QIODevice::WriteOnly)); + file.close(); + + LogosModules modules; + LogosWalletProvider provider(&modules); + const WalletSession session = provider.connect({ + directory.filePath(QStringLiteral("wallet.json")), + storage, + }); + + QVERIFY(session.ok()); + QVERIFY(!session.adopted); + QCOMPARE(modules.logos_execution_zone.openCalls, 1); + QCOMPARE(modules.logos_execution_zone.openedStorage, storage); + + LogosModules missingModules; + LogosWalletProvider missingProvider(&missingModules); + QCOMPARE(missingProvider.connect({ QStringLiteral("config"), QStringLiteral("missing") }).failure, + WalletFailure::WalletMissing); +} + +void LogosWalletProviderTest::createsAndPersistsWallet() +{ + QTemporaryDir directory; + QVERIFY(directory.isValid()); + + LogosModules modules; + LogosWalletProvider provider(&modules); + const WalletPaths paths { + directory.filePath(QStringLiteral("config/wallet.json")), + directory.filePath(QStringLiteral("state/storage.json")), + }; + const WalletCreation creation = provider.createWallet(paths, QStringLiteral("secret")); + + QVERIFY(creation.ok()); + QCOMPARE(creation.mnemonic, modules.logos_execution_zone.mnemonic); + QCOMPARE(modules.logos_execution_zone.createdConfig, paths.config); + QCOMPARE(modules.logos_execution_zone.createdStorage, paths.storage); + QCOMPARE(modules.logos_execution_zone.createdPassword, QStringLiteral("secret")); + QVERIFY(modules.logos_execution_zone.saveCalls >= 1); + + LogosModules rejectedModules; + rejectedModules.logos_execution_zone.mnemonic.clear(); + LogosWalletProvider rejectedProvider(&rejectedModules); + QCOMPARE(rejectedProvider.createWallet(paths, QStringLiteral("secret")).failure, + WalletFailure::CreateFailed); + + LogosModules unsavedModules; + unsavedModules.logos_execution_zone.saveResult = 1; + LogosWalletProvider unsavedProvider(&unsavedModules); + const WalletCreation unsaved = unsavedProvider.createWallet(paths, QStringLiteral("secret")); + QCOMPARE(unsaved.failure, WalletFailure::SaveFailed); + QCOMPARE(unsaved.mnemonic, unsavedModules.logos_execution_zone.mnemonic); +} + +void LogosWalletProviderTest::validatesCompletePublicAccountPayloads() +{ + LogosModules modules; + modules.logos_execution_zone.publicAccounts.insert(ACCOUNT_A, publicAccountJson()); + LogosWalletProvider provider(&modules); + + const WalletAccountRead valid = provider.readPublicAccount(ACCOUNT_A); + QVERIFY(valid.ok()); + QCOMPARE(valid.accountId, ACCOUNT_A); + QCOMPARE(valid.programOwner, PROGRAM_ID); + QCOMPARE(valid.balanceHex, QStringLiteral("01000000000000000000000000000000")); + QCOMPARE(valid.dataHex, QStringLiteral("00ff")); + + modules.logos_execution_zone.publicAccounts[ACCOUNT_A] = publicAccountJson(PROGRAM_ID.toUpper()); + QVERIFY(!provider.readPublicAccount(ACCOUNT_A).ok()); + modules.logos_execution_zone.publicAccounts[ACCOUNT_A] = publicAccountJson( + PROGRAM_ID, QStringLiteral("01")); + QVERIFY(!provider.readPublicAccount(ACCOUNT_A).ok()); + modules.logos_execution_zone.publicAccounts[ACCOUNT_A] = publicAccountJson( + PROGRAM_ID, QStringLiteral("01000000000000000000000000000000"), + QString(32, QLatin1Char('0')), QStringLiteral("abc")); + QVERIFY(!provider.readPublicAccount(ACCOUNT_A).ok()); + modules.logos_execution_zone.publicAccounts[ACCOUNT_A] = QStringLiteral("[]"); + QVERIFY(!provider.readPublicAccount(ACCOUNT_A).ok()); + QVERIFY(!provider.readPublicAccount(QStringLiteral("invalid")).ok()); +} + +void LogosWalletProviderTest::fallsBackToBalanceWhenPublicReadFails() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + modules.logos_execution_zone.accounts = { accountEntry(ACCOUNT_A, true) }; + modules.logos_execution_zone.balances.insert(ACCOUNT_A, QStringLiteral("42")); + + LogosWalletProvider provider(&modules); + const WalletSession session = provider.connect({}); + + QVERIFY(session.ok()); + QCOMPARE(session.snapshot.accounts.size(), 1); + QCOMPARE(session.snapshot.accounts.at(0).balance, QStringLiteral("42")); + QCOMPARE(session.snapshot.publicAccountReads.size(), 1); + QVERIFY(!session.snapshot.publicAccountReads.at(0).ok()); +} + +void LogosWalletProviderTest::createsAndPersistsAccounts() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + modules.logos_execution_zone.publicAccountId = ACCOUNT_A; + modules.logos_execution_zone.accounts = { accountEntry(ACCOUNT_A, true) }; + modules.logos_execution_zone.publicAccounts.insert(ACCOUNT_A, publicAccountJson()); + LogosWalletProvider provider(&modules); + QVERIFY(provider.connect({}).ok()); + + const int savesBeforeCreate = modules.logos_execution_zone.saveCalls; + const WalletAccountCreation creation = provider.createAccount(true); + QVERIFY(creation.ok()); + QCOMPARE(creation.accountId, ACCOUNT_A); + QVERIFY(creation.publicAccount.ok()); + QCOMPARE(creation.snapshot.accounts.size(), 1); + QVERIFY(modules.logos_execution_zone.saveCalls > savesBeforeCreate); + + modules.logos_execution_zone.saveResult = 1; + QCOMPARE(provider.createAccount(true).failure, WalletFailure::SaveFailed); +} + +void LogosWalletProviderTest::preservesCreatedAccountWhenPublicReadFails() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + modules.logos_execution_zone.publicAccountId = ACCOUNT_A; + modules.logos_execution_zone.accounts = { accountEntry(ACCOUNT_A, true) }; + modules.logos_execution_zone.balances.insert(ACCOUNT_A, QStringLiteral("7")); + LogosWalletProvider provider(&modules); + QVERIFY(provider.connect({}).ok()); + + const WalletAccountCreation creation = provider.createAccount(true); + + QVERIFY(creation.ok()); + QCOMPARE(creation.accountId, ACCOUNT_A); + QVERIFY(!creation.publicAccount.ok()); + QCOMPARE(creation.snapshot.accounts.size(), 1); + QCOMPARE(creation.snapshot.accounts.at(0).balance, QStringLiteral("7")); +} + +void LogosWalletProviderTest::preservesCreatedAccountWhenSnapshotRefreshFails() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + modules.logos_execution_zone.publicAccountId = ACCOUNT_A; + modules.logos_execution_zone.publicAccounts.insert(ACCOUNT_A, publicAccountJson()); + LogosWalletProvider provider(&modules); + QVERIFY(provider.connect({}).ok()); + + modules.logos_execution_zone.currentBlockHeight = 1; + modules.logos_execution_zone.syncResult = 1; + const WalletAccountCreation creation = provider.createAccount(true); + + QVERIFY(creation.ok()); + QCOMPARE(creation.accountId, ACCOUNT_A); + QCOMPARE(creation.snapshot.failure, WalletFailure::ReadFailed); +} + +void LogosWalletProviderTest::dispatchesExactGenericTransaction() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + modules.logos_execution_zone.transactionResponse = QStringLiteral( + R"({"success":true,"tx_hash":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"})"); + LogosWalletProvider provider(&modules); + QVERIFY(provider.connect({}).ok()); + + WalletTransaction transaction; + transaction.programId = PROGRAM_ID; + transaction.accountIds = { ACCOUNT_A, ACCOUNT_B }; + transaction.signingRequirements = { true, false }; + transaction.instruction = { 7, 0, 4294967295U }; + + const WalletSubmission submission = provider.submitPublicTransaction(transaction); + QVERIFY(submission.accepted()); + QCOMPARE(submission.nativeHash, QString(64, QLatin1Char('a'))); + QCOMPARE(modules.logos_execution_zone.submitCalls, 1); + QCOMPARE(modules.logos_execution_zone.submittedProgramId, PROGRAM_ID); + QCOMPARE(modules.logos_execution_zone.submittedAccountIds, transaction.accountIds); + QCOMPARE(modules.logos_execution_zone.submittedSigningRequirements, + QVariantList({ true, false })); + QCOMPARE(modules.logos_execution_zone.submittedInstruction.toList(), + QVariantList({ 7U, 0U, 4294967295U })); +} + +void LogosWalletProviderTest::rejectsInvalidSubmissionResponses() +{ + LogosModules modules; + modules.logos_execution_zone.sequencerAddress = QStringLiteral("http://sequencer"); + LogosWalletProvider provider(&modules); + QVERIFY(provider.connect({}).ok()); + + WalletTransaction transaction { + PROGRAM_ID, + { ACCOUNT_A }, + { true }, + { 1 }, + }; + + const QStringList invalidResponses { + QStringLiteral("not-json"), + QStringLiteral(R"({"success":false,"tx_hash":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"})"), + QStringLiteral(R"({"success":true,"error":"rejected","tx_hash":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"})"), + QStringLiteral(R"({"success":true,"tx_hash":"short"})"), + }; + for (const QString& response : invalidResponses) { + modules.logos_execution_zone.transactionResponse = response; + QCOMPARE(provider.submitPublicTransaction(transaction).failure, + WalletFailure::SubmissionFailed); + } + + transaction.signingRequirements.clear(); + QCOMPARE(provider.submitPublicTransaction(transaction).failure, + WalletFailure::InvalidRequest); +} + +void LogosWalletProviderTest::exposesStableAccountModelRoles() +{ + WalletAccountModel model; + QSignalSpy countChanged(&model, &WalletAccountModel::countChanged); + model.replaceAccounts({ + { ACCOUNT_A, QStringLiteral("10"), true }, + { ACCOUNT_B, QStringLiteral("20"), false }, + }); + + QCOMPARE(model.count(), 2); + QCOMPARE(countChanged.count(), 1); + QCOMPARE(model.roleNames().value(WalletAccountModel::NameRole), QByteArray("name")); + QCOMPARE(model.data(model.index(0), WalletAccountModel::NameRole).toString(), + QStringLiteral("Account 1")); + QCOMPARE(model.data(model.index(1), WalletAccountModel::AddressRole).toString(), ACCOUNT_B); + QCOMPARE(model.data(model.index(1), WalletAccountModel::BalanceRole).toString(), + QStringLiteral("20")); + QVERIFY(!model.data(model.index(1), WalletAccountModel::IsPublicRole).toBool()); +} + +void LogosWalletProviderTest::fakeProviderImplementsConsumerContract() +{ + FakeWalletProvider provider; + provider.snapshotResult.accounts = { { ACCOUNT_A, QStringLiteral("5"), true } }; + provider.submissionResult.nativeHash = QString(64, QLatin1Char('d')); + + QCOMPARE(provider.snapshot(true).accounts.size(), 1); + QVERIFY(provider.lastForceRefresh); + QCOMPARE(provider.readPublicAccount(ACCOUNT_B).accountId, ACCOUNT_B); + QCOMPARE(provider.readCalls, 1); + WalletTransaction transaction { PROGRAM_ID, { ACCOUNT_A }, { true }, { 9 } }; + QVERIFY(provider.submitPublicTransaction(transaction).accepted()); + QCOMPARE(provider.lastTransaction.instruction, transaction.instruction); + provider.disconnect(); + QCOMPARE(provider.disconnectCalls, 1); +} + +void LogosWalletProviderTest::controllerOwnsUiWalletFlow() +{ + const QString settingsApplication = QStringLiteral("WalletControllerTest"); + QSettings settings(QStringLiteral("Logos"), settingsApplication); + settings.clear(); + + FakeWalletProvider provider; + provider.connectResult.snapshot.accounts = { + { ACCOUNT_A, QStringLiteral("5"), true }, + }; + provider.connectResult.snapshot.lastSyncedBlock = 7; + provider.connectResult.snapshot.currentBlockHeight = 8; + + WalletController controller(provider, settingsApplication); + QSignalSpy stateChanged(&controller, &WalletController::stateChanged); + + QVERIFY(controller.open()); + QCOMPARE(provider.connectCalls, 1); + QVERIFY(controller.state().isWalletOpen); + QVERIFY(controller.state().walletExists); + QCOMPARE(controller.state().lastSyncedBlock, 7); + QCOMPARE(controller.state().currentBlockHeight, 8); + QCOMPARE(controller.accountModel()->count(), 1); + + provider.snapshotResult.accounts = { + { ACCOUNT_A, QStringLiteral("9"), true }, + }; + controller.refresh(); + QVERIFY(provider.lastForceRefresh); + QCOMPARE(controller.balance(ACCOUNT_A, true), QStringLiteral("9")); + + provider.createAccountResult.accountId = ACCOUNT_B; + provider.createAccountResult.snapshot.accounts = { + { ACCOUNT_A, QStringLiteral("9"), true }, + { ACCOUNT_B, QStringLiteral("3"), false }, + }; + QCOMPARE(controller.createAccount(false), ACCOUNT_B); + QVERIFY(!provider.lastAccountWasPublic); + QCOMPARE(controller.accountModel()->count(), 2); + + controller.disconnect(); + QCOMPARE(provider.disconnectCalls, 1); + QVERIFY(!controller.state().isWalletOpen); + QCOMPARE(controller.accountModel()->count(), 0); + QVERIFY(stateChanged.count() >= 4); + + settings.clear(); +} + +void LogosWalletProviderTest::controllerStopsReachabilityChecksAfterDisconnect() +{ + const QString settingsApplication = QStringLiteral("WalletReachabilityTest"); + QSettings settings(QStringLiteral("Logos"), settingsApplication); + settings.clear(); + + FakeWalletProvider provider; + provider.connectResult.snapshot.sequencerAddress = QStringLiteral("http://127.0.0.1:1"); + WalletController controller(provider, settingsApplication); + auto* network = controller.findChild(); + QVERIFY(network); + QSignalSpy finished(network, &QNetworkAccessManager::finished); + + QVERIFY(controller.open()); + QTRY_VERIFY_WITH_TIMEOUT(!finished.isEmpty(), 1000); + controller.disconnect(); + finished.clear(); + + auto* timer = controller.findChild(); + QVERIFY(timer); + timer->setInterval(1); + controller.start(); + QTest::qWait(50); + QCOMPARE(finished.count(), 0); + + settings.clear(); +} + +QTEST_GUILESS_MAIN(LogosWalletProviderTest) + +#include "LogosWalletProviderTest.moc" diff --git a/apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h b/apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h new file mode 100644 index 00000000..c1927f43 --- /dev/null +++ b/apps/shared/wallet/tests/cpp/fixtures/logos_sdk.h @@ -0,0 +1,118 @@ +#pragma once + +#include +#include +#include +#include +#include + +class LogosAPI; + +class FakeExecutionZone { +public: + int openResult = 0; + int saveResult = 0; + int syncResult = 0; + int lastSyncedBlock = 0; + int currentBlockHeight = 0; + QString sequencerAddress; + QString mnemonic = QStringLiteral("one two three"); + QString publicAccountId; + QString privateAccountId; + QString transactionResponse; + QVariantList accounts; + QHash publicAccounts; + QHash balances; + + int openCalls = 0; + int saveCalls = 0; + int syncCalls = 0; + int listCalls = 0; + int publicReadCalls = 0; + int submitCalls = 0; + QString openedConfig; + QString openedStorage; + QString createdConfig; + QString createdStorage; + QString createdPassword; + QStringList submittedAccountIds; + QVariantList submittedSigningRequirements; + QVariant submittedInstruction; + QString submittedProgramId; + + int open(const QString& config, const QString& storage) + { + ++openCalls; + openedConfig = config; + openedStorage = storage; + return openResult; + } + + QString create_new(const QString& config, + const QString& storage, + const QString& password) + { + createdConfig = config; + createdStorage = storage; + createdPassword = password; + return mnemonic; + } + + int save() + { + ++saveCalls; + return saveResult; + } + + QString create_account_public() { return publicAccountId; } + QString create_account_private() { return privateAccountId; } + + int get_last_synced_block() const { return lastSyncedBlock; } + int get_current_block_height() const { return currentBlockHeight; } + + int sync_to_block(quint64) + { + ++syncCalls; + return syncResult; + } + + QString get_sequencer_addr() const { return sequencerAddress; } + + QVariantList list_accounts() + { + ++listCalls; + return accounts; + } + + QString get_account_public(const QString& accountId) + { + ++publicReadCalls; + return publicAccounts.value(accountId); + } + + QString get_balance(const QString& accountId, bool) const + { + return balances.value(accountId); + } + + QString send_generic_public_transaction( + const QStringList& accountIds, + const QVariantList& signingRequirements, + const QVariant& instruction, + const QString& programId) + { + ++submitCalls; + submittedAccountIds = accountIds; + submittedSigningRequirements = signingRequirements; + submittedInstruction = instruction; + submittedProgramId = programId; + return transactionResponse; + } +}; + +struct LogosModules { + LogosModules() = default; + explicit LogosModules(LogosAPI*) { } + + FakeExecutionZone logos_execution_zone; +}; diff --git a/apps/shared/wallet/tests/qml/main.cpp b/apps/shared/wallet/tests/qml/main.cpp new file mode 100644 index 00000000..42c698d3 --- /dev/null +++ b/apps/shared/wallet/tests/qml/main.cpp @@ -0,0 +1,3 @@ +#include + +QUICK_TEST_MAIN(logos_wallet_qml) diff --git a/apps/shared/wallet/tests/qml/tst_SubmittedTransaction.qml b/apps/shared/wallet/tests/qml/tst_SubmittedTransaction.qml new file mode 100644 index 00000000..44e57c41 --- /dev/null +++ b/apps/shared/wallet/tests/qml/tst_SubmittedTransaction.qml @@ -0,0 +1,43 @@ +import QtQuick +import QtTest +import Logos.Wallet as Wallet + +Item { + id: root + width: 360 + height: 400 + + Component { + id: resultComponent + + Wallet.SubmittedTransaction { + width: 280 + } + } + + TestCase { + name: "SubmittedTransaction" + when: windowShown + + function test_presentsBase58AndCopyFeedback() { + const result = createTemporaryObject(resultComponent, root, { + transactionId: "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + }) + verify(result, "Submitted result exists") + verify(result.base58Shape) + + const label = findChild(result, "submittedTransactionId") + verify(label, "Transaction label exists") + compare(label.text, result.transactionId) + verify(label.implicitHeight > 0) + + const copyButton = findChild(result, "copySubmittedTransactionButton") + verify(copyButton, "Copy button exists") + mouseClick(copyButton) + verify(copyButton.copied) + + result.transactionId = "0OIl" + verify(!result.base58Shape) + } + } +} diff --git a/apps/shared/wallet/tests/qml/tst_TransactionConfirmationDialog.qml b/apps/shared/wallet/tests/qml/tst_TransactionConfirmationDialog.qml new file mode 100644 index 00000000..898a2450 --- /dev/null +++ b/apps/shared/wallet/tests/qml/tst_TransactionConfirmationDialog.qml @@ -0,0 +1,125 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtTest +import Logos.Wallet as Wallet + +Item { + id: root + width: 800 + height: 600 + + Component { + id: summaryComponent + + Item { + property var snapshot: ({}) + implicitHeight: summaryText.implicitHeight + + Label { + id: summaryText + objectName: "customSummaryText" + text: parent.snapshot.amount || "" + } + } + } + + Component { + id: dialogComponent + + Wallet.TransactionConfirmationDialog { + summary: summaryComponent + } + } + + Component { + id: viewportComponent + + Item { + width: 360 + height: 240 + } + } + + Component { + id: tallSummaryComponent + + Item { + property var snapshot: ({}) + implicitHeight: 360 + } + } + + Component { + id: constrainedDialogComponent + + Wallet.TransactionConfirmationDialog { + summary: tallSummaryComponent + } + } + + TestCase { + name: "TransactionConfirmationDialog" + when: windowShown + + function test_capturesSnapshotAndLoadsProgramSummary() { + const dialog = createTemporaryObject(dialogComponent, root) + verify(dialog, "Dialog exists") + const source = { amount: "10" } + dialog.openWithSnapshot(source) + tryCompare(dialog, "opened", true) + source.amount = "20" + compare(dialog.snapshot.amount, "10") + + const summary = findChild(dialog, "customSummaryText") + verify(summary, "Custom summary exists") + compare(summary.text, "10") + + confirmedSpy.target = dialog + confirmedSpy.signalName = "confirmed" + confirmedSpy.clear() + mouseClick(findChild(dialog, "transactionConfirmButton")) + compare(confirmedSpy.count, 1) + compare(confirmedSpy.signalArguments[0][0].amount, "10") + tryCompare(dialog, "opened", false) + } + + function test_busyStateCannotBeDismissed() { + const dialog = createTemporaryObject(dialogComponent, root) + verify(dialog, "Dialog exists") + dialog.openWithSnapshot({ amount: "5" }) + dialog.busy = true + const cancelButton = findChild(dialog, "transactionCancelButton") + const confirmButton = findChild(dialog, "transactionConfirmButton") + verify(!cancelButton.enabled) + verify(!confirmButton.enabled) + keyClick(Qt.Key_Escape) + verify(dialog.opened) + dialog.cancel() + verify(dialog.opened) + dialog.busy = false + dialog.cancel() + tryCompare(dialog, "opened", false) + } + + function test_keepsActionsInsideShortViewport() { + const viewport = createTemporaryObject(viewportComponent, root) + verify(viewport, "Short viewport exists") + const dialog = createTemporaryObject(constrainedDialogComponent, viewport) + verify(dialog, "Dialog exists") + + dialog.openWithSnapshot({ amount: "5" }) + tryCompare(dialog, "opened", true) + verify(dialog.height <= viewport.height - 32) + verify(dialog.y >= 0) + verify(dialog.y + dialog.height <= viewport.height) + + const confirmButton = findChild(dialog, "transactionConfirmButton") + verify(confirmButton, "Confirm button exists") + verify(confirmButton.visible) + } + } + + SignalSpy { id: confirmedSpy } +} diff --git a/apps/shared/wallet/tests/qml/tst_WalletControl.qml b/apps/shared/wallet/tests/qml/tst_WalletControl.qml new file mode 100644 index 00000000..157a0bcd --- /dev/null +++ b/apps/shared/wallet/tests/qml/tst_WalletControl.qml @@ -0,0 +1,339 @@ +import QtQuick +import QtTest +import Logos.Wallet as Wallet + +Item { + id: root + width: 800 + height: 600 + + Component { + id: backendComponent + + QtObject { + property bool isWalletOpen: false + property bool walletExists: true + property string walletHome: "/wallet" + property int openCalls: 0 + property int createCalls: 0 + property int publicAccountCalls: 0 + property int privateAccountCalls: 0 + property int disconnectCalls: 0 + + function openExisting() { + openCalls++ + isWalletOpen = true + return true + } + + function createNewDefault(_password) { + createCalls++ + isWalletOpen = true + return "alpha beta gamma" + } + + function createAccountPublic() { + publicAccountCalls++ + return "a".repeat(64) + } + + function createAccountPrivate() { + privateAccountCalls++ + return "b".repeat(64) + } + + function disconnectWallet() { + disconnectCalls++ + isWalletOpen = false + } + } + } + + Component { + id: modelComponent + ListModel { } + } + + Component { + id: controlComponent + Wallet.WalletControl { + width: 320 + height: implicitHeight + viewportWidth: 800 + } + } + + Component { + id: compactWindowComponent + + Window { + width: 360 + height: 240 + visible: true + + property alias control: walletControl + + Wallet.WalletControl { + id: walletControl + x: 20 + y: 8 + width: 320 + height: implicitHeight + viewportWidth: parent.width + } + } + } + + Component { + id: compactDialogWindowComponent + + Window { + width: 360 + height: 600 + visible: true + + property alias control: walletControl + + Wallet.WalletControl { + id: walletControl + x: parent.width - width - 12 + y: 8 + width: 40 + height: implicitHeight + viewportWidth: parent.width + } + } + } + + TestCase { + name: "WalletControl" + when: windowShown + + function createControl(walletProperties, accounts) { + const backend = createTemporaryObject(backendComponent, root, walletProperties || {}) + verify(backend, "Backend exists") + const model = createTemporaryObject(modelComponent, root) + verify(model, "Account model exists") + for (const account of accounts || []) + model.append(account) + const control = createTemporaryObject(controlComponent, root, { + wallet: backend, + accountModel: model + }) + verify(control, "Wallet control exists") + return { backend, model, control } + } + + function test_opensExistingWallet() { + const fixture = createControl({ walletExists: true }, []) + const connectButton = findChild(fixture.control, "walletConnectButton") + verify(connectButton, "Connect button exists") + mouseClick(connectButton) + compare(fixture.backend.openCalls, 1) + tryCompare(fixture.control, "connected", true) + } + + function test_requiresSeedBackupAcknowledgement() { + const fixture = createControl({ walletExists: false }, []) + mouseClick(findChild(fixture.control, "walletConnectButton")) + + const dialog = findChild(fixture.control, "createWalletDialog") + tryCompare(dialog, "opened", true) + const password = findChild(dialog, "walletPasswordField") + const confirmation = findChild(dialog, "walletConfirmPasswordField") + const createButton = findChild(dialog, "createWalletButton") + verify(password && confirmation && createButton, "Wallet fields exist") + + password.text = "secret" + confirmation.text = "different" + mouseClick(createButton) + compare(fixture.backend.createCalls, 0) + verify(dialog.errorText.length > 0) + + confirmation.text = "secret" + mouseClick(createButton) + compare(fixture.backend.createCalls, 1) + compare(dialog.mnemonic, "alpha beta gamma") + + const acknowledgement = findChild(dialog, "walletBackupAcknowledgement") + const continueButton = findChild(dialog, "walletContinueButton") + verify(acknowledgement && continueButton, "Backup controls exist") + verify(!continueButton.enabled) + mouseClick(acknowledgement) + verify(continueButton.enabled) + mouseClick(continueButton) + tryCompare(dialog, "opened", false) + } + + function test_clampsSelectionAndDisconnectsLocally() { + const fixture = createControl({ isWalletOpen: true }, [ + { name: "One", address: "a".repeat(64), balance: "10", isPublic: true }, + { name: "Two", address: "b".repeat(64), balance: "20", isPublic: false } + ]) + fixture.control.selectedIndex = 1 + compare(fixture.control.selectedAddress, "b".repeat(64)) + fixture.model.clear() + tryCompare(fixture.control, "selectedIndex", 0) + compare(fixture.control.selectedAddress, "") + + fixture.model.append({ + name: "One", address: "a".repeat(64), balance: "10", isPublic: true + }) + mouseClick(findChild(fixture.control, "walletAccountButton")) + const disconnectButton = findChild(fixture.control, "walletDisconnectButton") + tryVerify(function() { return disconnectButton.visible }) + mouseClick(disconnectButton) + compare(fixture.backend.disconnectCalls, 1) + tryCompare(fixture.control, "connected", false) + } + + function test_connectedButtonClosesOpenMenu() { + const fixture = createControl({ isWalletOpen: true }, [ + { name: "One", address: "a".repeat(64), balance: "10", isPublic: true } + ]) + const accountButton = findChild(fixture.control, "walletAccountButton") + const menu = findChild(fixture.control, "walletMenu") + + mouseClick(accountButton) + tryCompare(menu, "opened", true) + mouseClick(accountButton) + tryCompare(menu, "opened", false) + } + + function test_openMenuTracksControlMovement() { + const fixture = createControl({ isWalletOpen: true }, [ + { name: "One", address: "a".repeat(64), balance: "10", isPublic: true } + ]) + fixture.control.x = 20 + fixture.control.y = 20 + const accountButton = findChild(fixture.control, "walletAccountButton") + const menu = findChild(fixture.control, "walletMenu") + + mouseClick(accountButton) + tryCompare(menu, "opened", true) + const initialMenu = menu.contentItem.mapToItem(root, 0, 0) + const initialButton = accountButton.mapToItem(root, 0, 0) + + fixture.control.x += 300 + fixture.control.y += 30 + wait(0) + + const movedMenu = menu.contentItem.mapToItem(root, 0, 0) + const movedButton = accountButton.mapToItem(root, 0, 0) + compare(movedMenu.x - movedButton.x, initialMenu.x - initialButton.x) + compare(movedMenu.y - movedButton.y, initialMenu.y - initialButton.y) + } + + function test_selectsAccount() { + const fixture = createControl({ isWalletOpen: true }, [ + { name: "One", address: "a".repeat(64), balance: "10", isPublic: true }, + { name: "Two", address: "b".repeat(64), balance: "20", isPublic: false } + ]) + mouseClick(findChild(fixture.control, "walletAccountButton")) + const accountsButton = findChild(fixture.control, "walletAccountsButton") + tryVerify(function() { return accountsButton.visible }) + mouseClick(accountsButton) + + const accountList = findChild(fixture.control, "walletAccountList") + tryCompare(accountList, "count", 2) + tryVerify(function() { return accountList.itemAtIndex(1) !== null }) + const secondAccount = accountList.itemAtIndex(1) + secondAccount.clicked() + tryCompare(fixture.control, "selectedIndex", 1) + compare(fixture.control.selectedAddress, "b".repeat(64)) + } + + function test_createsAccount() { + const fixture = createControl({ isWalletOpen: true }, [ + { name: "One", address: "a".repeat(64), balance: "10", isPublic: true } + ]) + mouseClick(findChild(fixture.control, "walletAccountButton")) + const accountsButton = findChild(fixture.control, "walletAccountsButton") + tryVerify(function() { return accountsButton.visible }) + mouseClick(accountsButton) + + const addButton = findChild(fixture.control, "walletAddAccountButton") + tryVerify(function() { return addButton.visible }) + addButton.clicked() + const dialog = findChild(fixture.control, "createAccountDialog") + tryCompare(dialog, "opened", true) + findChild(dialog, "createAccountButton").clicked() + compare(fixture.backend.publicAccountCalls, 1) + tryCompare(dialog, "opened", false) + } + + function test_compactLayoutHasStableWidth() { + const fixture = createControl({ isWalletOpen: false }, []) + fixture.control.viewportWidth = 480 + verify(fixture.control.compactLayout) + compare(fixture.control.implicitWidth, 40) + fixture.control.viewportWidth = 900 + verify(!fixture.control.compactLayout) + compare(fixture.control.implicitWidth, 108) + } + + function test_walletDialogsUseWindowViewport() { + const window = createTemporaryObject(compactDialogWindowComponent, root) + verify(window, "Compact window exists") + waitForRendering(window.contentItem) + + const dialogNames = [ + "createWalletDialog", + "createAccountDialog", + "walletMessageDialog" + ] + for (const name of dialogNames) { + const dialog = findChild(window.control, name) + verify(dialog, name + " exists") + dialog.open() + tryCompare(dialog, "opened", true) + compare(dialog.width, window.width - 32) + verify(dialog.x >= 0) + verify(dialog.x + dialog.width <= window.width) + dialog.close() + tryCompare(dialog, "opened", false) + } + } + + function test_accountsRemainReachableInShortWindow() { + const backend = createTemporaryObject(backendComponent, root, { isWalletOpen: true }) + const model = createTemporaryObject(modelComponent, root) + verify(backend && model, "Wallet fixture exists") + for (let index = 0; index < 10; ++index) { + model.append({ + name: "Account " + index, + address: String(index).repeat(64), + balance: String(index), + isPublic: true + }) + } + + const window = createTemporaryObject(compactWindowComponent, root) + verify(window, "Short window exists") + window.control.wallet = backend + window.control.accountModel = model + waitForRendering(window.contentItem) + + mouseClick(findChild(window.control, "walletAccountButton")) + const menu = findChild(window.control, "walletMenu") + tryCompare(menu, "opened", true) + mouseClick(findChild(window.control, "walletAccountsButton")) + + const accountList = findChild(window.control, "walletAccountList") + const addButton = findChild(window.control, "walletAddAccountButton") + tryCompare(accountList, "count", 10) + verify(menu.y >= 12, "Menu top: " + menu.y) + verify(menu.y + menu.height <= window.height - 12, + "Menu bottom: " + (menu.y + menu.height) + + ", window: " + window.height) + verify(accountList.height > 0) + verify(accountList.contentHeight > accountList.height) + verify(addButton && addButton.visible, "Add account button is visible") + const addPosition = addButton.mapToItem(window.contentItem, 0, 0) + verify(addPosition.y >= menu.y) + verify(addPosition.y + addButton.height <= menu.y + menu.height, + "Add account bottom: " + (addPosition.y + addButton.height) + + ", menu bottom: " + (menu.y + menu.height)) + } + } +} diff --git a/apps/shared/wallet/tests/support/FakeWalletProvider.h b/apps/shared/wallet/tests/support/FakeWalletProvider.h new file mode 100644 index 00000000..d5dea0ad --- /dev/null +++ b/apps/shared/wallet/tests/support/FakeWalletProvider.h @@ -0,0 +1,75 @@ +#pragma once + +#include "WalletProvider.h" + +class FakeWalletProvider final : public WalletProvider { +public: + WalletSession connectResult; + WalletCreation createWalletResult; + WalletSnapshot snapshotResult; + WalletAccountCreation createAccountResult; + WalletAccountRead readResult; + WalletSubmission submissionResult; + + int connectCalls = 0; + int createWalletCalls = 0; + int snapshotCalls = 0; + int clearCalls = 0; + int createAccountCalls = 0; + mutable int readCalls = 0; + int submitCalls = 0; + int disconnectCalls = 0; + bool lastForceRefresh = false; + bool lastAccountWasPublic = false; + WalletPaths lastPaths; + WalletTransaction lastTransaction; + + WalletSession connect(const WalletPaths& paths) override + { + ++connectCalls; + lastPaths = paths; + return connectResult; + } + + WalletCreation createWallet(const WalletPaths& paths, + const QString&) override + { + ++createWalletCalls; + lastPaths = paths; + return createWalletResult; + } + + WalletSnapshot snapshot(bool forceRefresh) override + { + ++snapshotCalls; + lastForceRefresh = forceRefresh; + return snapshotResult; + } + + void clearSnapshot() override { ++clearCalls; } + + WalletAccountCreation createAccount(bool isPublic) override + { + ++createAccountCalls; + lastAccountWasPublic = isPublic; + return createAccountResult; + } + + WalletAccountRead readPublicAccount(const QString& accountId) const override + { + ++readCalls; + WalletAccountRead result = readResult; + result.accountId = accountId; + return result; + } + + WalletSubmission submitPublicTransaction( + const WalletTransaction& transaction) override + { + ++submitCalls; + lastTransaction = transaction; + return submissionResult; + } + + void disconnect() override { ++disconnectCalls; } +}; diff --git a/flake.nix b/flake.nix index a0a443c0..15fc2816 100644 --- a/flake.nix +++ b/flake.nix @@ -118,6 +118,27 @@ externalLibInputs = { amm_client_ffi = { input = self; packages.default = "amm_client_ffi"; }; }; + # The AMM UI links the shared C++ wallet access lib and bundles the + # Logos.Wallet QML module (apps/shared/wallet). apps/amm/flake.nix wires + # these via its `shared_wallet` input; when built from this root flake + # the source lives in-tree, so point CMake straight at it and stage the + # built QML module the same way. Keep in sync with apps/amm/flake.nix. + preConfigure = '' + cmakeFlagsArray+=("-DLOGOS_WALLET_SOURCE_DIR=${./apps/shared/wallet}") + ''; + postInstall = '' + test -f ${./apps/amm/qml}/Logos/Wallet/qmldir + + walletQmlDir="shared-wallet/qml/Logos/Wallet" + if [ ! -d "$walletQmlDir" ]; then + echo "Built Logos.Wallet QML module not found" + exit 1 + fi + walletQmlInstallDir="$out/lib/Logos/Wallet" + mkdir -p "$walletQmlInstallDir" + cp -r "$walletQmlDir/." "$walletQmlInstallDir/" + test -f "$walletQmlInstallDir/qmldir" + ''; }; # Expose the AMM QML UI as a NAMED app/package (`amm-ui`) rather than From 46f50a3f81751a8e1876b1aba956a3e4aa1b2826 Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:44:20 +0200 Subject: [PATCH 02/10] fix(apps/wallet): use @loader_path rpath for the QML plugin on macOS The Logos.Wallet QML plugin was installed with an ELF "$ORIGIN" rpath. macOS dyld does not expand "$ORIGIN", so loading the plugin failed with Cannot load library liblogos_wallet_qmlplugin.dylib: Library not loaded: @rpath/liblogos_wallet_qml.dylib even though liblogos_wallet_qml.dylib sits in the same directory. Select the rpath per platform: @loader_path on Apple, $ORIGIN elsewhere, so @rpath/liblogos_wallet_qml.dylib resolves to the sibling library. --- apps/shared/wallet/CMakeLists.txt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/shared/wallet/CMakeLists.txt b/apps/shared/wallet/CMakeLists.txt index 37f16630..896716b8 100644 --- a/apps/shared/wallet/CMakeLists.txt +++ b/apps/shared/wallet/CMakeLists.txt @@ -106,11 +106,18 @@ if(LOGOS_WALLET_BUILD_QML) Qt6::Quick Qt6::QuickControls2 ) + # Resolve the sibling liblogos_wallet_qml.dylib/.so next to the plugin. + # macOS dyld does not expand the ELF "$ORIGIN" token — it uses @loader_path. + if(APPLE) + set(wallet_qml_rpath "@loader_path") + else() + set(wallet_qml_rpath "$ORIGIN") + endif() set_target_properties(logos_wallet_qml logos_wallet_qmlplugin PROPERTIES LIBRARY_OUTPUT_DIRECTORY "${wallet_qml_output_dir}" RUNTIME_OUTPUT_DIRECTORY "${wallet_qml_output_dir}" BUILD_WITH_INSTALL_RPATH ON - INSTALL_RPATH "$ORIGIN" + INSTALL_RPATH "${wallet_qml_rpath}" ) set(wallet_qml_install_dir "lib/qml/Logos/Wallet") From 64b7e8a1973e3876c71b0538fd1708a6fb96eb77 Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:00:02 +0200 Subject: [PATCH 03/10] fix(apps/amm): ensure changing endpoint works --- apps/amm/qml/Main.qml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/amm/qml/Main.qml b/apps/amm/qml/Main.qml index 6e55e3e1..1703e33d 100644 --- a/apps/amm/qml/Main.qml +++ b/apps/amm/qml/Main.qml @@ -45,7 +45,7 @@ Item { height: show ? 32 : 0 visible: height > 0 clip: true - color: Theme.palette.warning + color: Theme.palette.error Behavior on height { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } } @@ -56,7 +56,7 @@ Item { elide: Text.ElideMiddle font.pixelSize: 12 font.weight: Font.Medium - color: Theme.palette.background + color: Theme.palette.text text: qsTr("Unable to connect to network") } } From b2753aa0c98c27577526541a607dd6f9fae2f9ef Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Fri, 26 Jun 2026 09:22:42 +0200 Subject: [PATCH 04/10] feat(apps/amm): add create-pool / new liquidity position flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Create Pool" flow to the AMM app that lets a user open a new liquidity position — seeding a pool's initial liquidity — from token selection and amount entry, through a confirmation dialog, to on-chain submission. - client crate (apps/amm/client): pure, testable protocol logic — account decoding, pair/position modelling, and quote/plan computation — exposed to the app over a C ABI (config/networks.json drives network selection). - C++ runtime + backend: AmmClient, ActiveNetwork, and NewPositionRuntime, wired into AmmUiBackend (new resolve/quote/submit slots). - QML flow: NewPositionForm, NewPositionFlow state, NewPositionConfirmation- Dialog, TokenSelectorModal, and reusable Amm* presentational components (theme, surfaces, buttons) + AmountMath.js. - tests: C++ (NewPositionRuntimeTest, ActiveNetworkTest) and QML (tst_NewPositionForm, tst_LiquidityPage, tst_TokenAmountInput, …). --- Cargo.lock | 41 + Cargo.toml | 1 + apps/amm/CMakeLists.txt | 45 +- apps/amm/README.md | 15 +- apps/amm/VALIDATION.md | 52 + apps/amm/client/Cargo.toml | 24 + apps/amm/client/include/amm_client.h | 20 + apps/amm/client/src/account.rs | 152 ++ apps/amm/client/src/api/accounts.rs | 226 +++ apps/amm/client/src/api/clock.rs | 12 + apps/amm/client/src/api/commitment.rs | 51 + apps/amm/client/src/api/config.rs | 25 + apps/amm/client/src/api/context.rs | 254 +++ apps/amm/client/src/api/funding.rs | 106 ++ apps/amm/client/src/api/holding.rs | 64 + apps/amm/client/src/api/mod.rs | 97 ++ apps/amm/client/src/api/pair.rs | 93 + apps/amm/client/src/api/plan.rs | 136 ++ apps/amm/client/src/api/position.rs | 229 +++ apps/amm/client/src/api/quote.rs | 726 ++++++++ apps/amm/client/src/api/quote_error.rs | 25 + apps/amm/client/src/api/request.rs | 116 ++ apps/amm/client/src/api/tests.rs | 703 ++++++++ apps/amm/client/src/ffi.rs | 140 ++ apps/amm/client/src/lib.rs | 12 + apps/amm/client/tests/public_api.rs | 13 + apps/amm/config/networks.json | 18 + apps/amm/flake.lock | 891 +++++----- apps/amm/flake.nix | 8 + apps/amm/metadata.json | 7 +- apps/amm/qml/Main.qml | 6 +- .../components/liquidity/AddLiquidityForm.qml | 222 --- .../components/liquidity/AmmActionCard.qml | 15 + .../components/liquidity/AmmPairSeparator.qml | 61 + .../components/liquidity/AmmPrimaryButton.qml | 43 + .../amm/qml/components/liquidity/AmmTheme.qml | 50 + .../liquidity/AmmTokenAccessory.qml | 46 + .../liquidity/AmmTokenAmountSurface.qml | 195 +++ .../liquidity/AmmTokenSelectButton.qml | 80 + .../qml/components/liquidity/AmountMath.js | 295 ++++ .../liquidity/LiquidityActionTabs.qml | 91 - .../LiquidityConfirmationSummary.qml | 104 +- .../components/liquidity/NewPositionForm.qml | 1542 +++++++++++++++++ .../liquidity/PoolPositionSummary.qml | 98 -- .../liquidity/RemoveLiquidityForm.qml | 492 ------ .../qml/components/liquidity/SummaryRow.qml | 5 +- .../components/liquidity/TokenAmountInput.qml | 247 ++- .../liquidity/TokenSelectorModal.qml | 489 ++++++ apps/amm/qml/pages/LiquidityPage.qml | 375 ++-- apps/amm/qml/state/DummyPoolState.qml | 205 --- apps/amm/qml/state/NewPositionFlow.qml | 375 ++++ apps/amm/src/ActiveNetwork.cpp | 141 ++ apps/amm/src/ActiveNetwork.h | 36 + apps/amm/src/AmmClient.cpp | 71 + apps/amm/src/AmmClient.h | 30 + apps/amm/src/AmmUiBackend.cpp | 193 ++- apps/amm/src/AmmUiBackend.h | 25 + apps/amm/src/AmmUiBackend.rep | 13 +- apps/amm/src/NewPositionRuntime.cpp | 390 +++++ apps/amm/src/NewPositionRuntime.h | 42 + apps/amm/tests/cpp/ActiveNetworkTest.cpp | 65 + apps/amm/tests/cpp/NewPositionRuntimeTest.cpp | 230 +++ .../qml/tst_LiquidityConfirmationDialog.qml | 29 + apps/amm/tests/qml/tst_LiquidityPage.qml | 335 ++++ apps/amm/tests/qml/tst_NewPositionForm.qml | 633 +++++++ apps/amm/tests/qml/tst_ResponsivePopups.qml | 47 + apps/amm/tests/qml/tst_TokenAmountInput.qml | 280 +++ flake.nix | 30 +- 68 files changed, 10046 insertions(+), 1882 deletions(-) create mode 100644 apps/amm/VALIDATION.md create mode 100644 apps/amm/client/Cargo.toml create mode 100644 apps/amm/client/include/amm_client.h create mode 100644 apps/amm/client/src/account.rs create mode 100644 apps/amm/client/src/api/accounts.rs create mode 100644 apps/amm/client/src/api/clock.rs create mode 100644 apps/amm/client/src/api/commitment.rs create mode 100644 apps/amm/client/src/api/config.rs create mode 100644 apps/amm/client/src/api/context.rs create mode 100644 apps/amm/client/src/api/funding.rs create mode 100644 apps/amm/client/src/api/holding.rs create mode 100644 apps/amm/client/src/api/mod.rs create mode 100644 apps/amm/client/src/api/pair.rs create mode 100644 apps/amm/client/src/api/plan.rs create mode 100644 apps/amm/client/src/api/position.rs create mode 100644 apps/amm/client/src/api/quote.rs create mode 100644 apps/amm/client/src/api/quote_error.rs create mode 100644 apps/amm/client/src/api/request.rs create mode 100644 apps/amm/client/src/api/tests.rs create mode 100644 apps/amm/client/src/ffi.rs create mode 100644 apps/amm/client/src/lib.rs create mode 100644 apps/amm/client/tests/public_api.rs create mode 100644 apps/amm/config/networks.json delete mode 100644 apps/amm/qml/components/liquidity/AddLiquidityForm.qml create mode 100644 apps/amm/qml/components/liquidity/AmmActionCard.qml create mode 100644 apps/amm/qml/components/liquidity/AmmPairSeparator.qml create mode 100644 apps/amm/qml/components/liquidity/AmmPrimaryButton.qml create mode 100644 apps/amm/qml/components/liquidity/AmmTheme.qml create mode 100644 apps/amm/qml/components/liquidity/AmmTokenAccessory.qml create mode 100644 apps/amm/qml/components/liquidity/AmmTokenAmountSurface.qml create mode 100644 apps/amm/qml/components/liquidity/AmmTokenSelectButton.qml create mode 100644 apps/amm/qml/components/liquidity/AmountMath.js delete mode 100644 apps/amm/qml/components/liquidity/LiquidityActionTabs.qml create mode 100644 apps/amm/qml/components/liquidity/NewPositionForm.qml delete mode 100644 apps/amm/qml/components/liquidity/PoolPositionSummary.qml delete mode 100644 apps/amm/qml/components/liquidity/RemoveLiquidityForm.qml create mode 100644 apps/amm/qml/components/liquidity/TokenSelectorModal.qml delete mode 100644 apps/amm/qml/state/DummyPoolState.qml create mode 100644 apps/amm/qml/state/NewPositionFlow.qml create mode 100644 apps/amm/src/ActiveNetwork.cpp create mode 100644 apps/amm/src/ActiveNetwork.h create mode 100644 apps/amm/src/AmmClient.cpp create mode 100644 apps/amm/src/AmmClient.h create mode 100644 apps/amm/src/NewPositionRuntime.cpp create mode 100644 apps/amm/src/NewPositionRuntime.h create mode 100644 apps/amm/tests/cpp/ActiveNetworkTest.cpp create mode 100644 apps/amm/tests/cpp/NewPositionRuntimeTest.cpp create mode 100644 apps/amm/tests/qml/tst_LiquidityConfirmationDialog.qml create mode 100644 apps/amm/tests/qml/tst_LiquidityPage.qml create mode 100644 apps/amm/tests/qml/tst_NewPositionForm.qml create mode 100644 apps/amm/tests/qml/tst_ResponsivePopups.qml create mode 100644 apps/amm/tests/qml/tst_TokenAmountInput.qml diff --git a/Cargo.lock b/Cargo.lock index 575725c5..5da900e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,6 +76,25 @@ dependencies = [ "risc0-zkvm", ] +[[package]] +name = "amm_client" +version = "0.1.0" +dependencies = [ + "alloy-primitives", + "amm_core", + "borsh", + "clock_core", + "hex", + "lee_core", + "pretty_assertions", + "risc0-zkvm", + "serde", + "serde_json", + "sha2", + "token_core", + "twap_oracle_core", +] + [[package]] name = "amm_client_ffi" version = "0.1.0" @@ -1309,6 +1328,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.9.0" @@ -2761,6 +2786,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "primitive-types" version = "0.12.2" @@ -4878,6 +4913,12 @@ dependencies = [ "hashlink", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index d38a8c44..5631a811 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "apps/amm/client", "programs/token/core", "programs/token", "programs/token/methods", diff --git a/apps/amm/CMakeLists.txt b/apps/amm/CMakeLists.txt index 8d66e29f..037867cb 100644 --- a/apps/amm/CMakeLists.txt +++ b/apps/amm/CMakeLists.txt @@ -4,6 +4,11 @@ project(AmmUiPlugin LANGUAGES CXX) find_package(Qt6 6.8 REQUIRED COMPONENTS Core Gui Network Qml Quick QuickControls2) qt_standard_project_setup(REQUIRES 6.8) +find_package(PkgConfig REQUIRED) +pkg_check_modules(BASE58 REQUIRED IMPORTED_TARGET libbase58) + +include(CTest) + if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT}) include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake) else() @@ -31,14 +36,50 @@ logos_module( src/AmmUiPlugin.cpp src/AmmUiBackend.h src/AmmUiBackend.cpp + src/ActiveNetwork.h + src/ActiveNetwork.cpp + src/AmmClient.h + src/AmmClient.cpp + src/NewPositionRuntime.h + src/NewPositionRuntime.cpp FIND_PACKAGES Qt6Gui Qt6Network LINK_LIBRARIES Qt6::Gui Qt6::Network - EXTERNAL_LIBS - amm_client_ffi + PkgConfig::BASE58 LINK_TARGETS logos_wallet_access + EXTERNAL_LIBS + amm_client_ffi + amm_client ) + +qt_add_resources(amm_ui_module_plugin amm_ui_config + PREFIX "/amm" + FILES + config/networks.json +) + +if(BUILD_TESTING) + add_executable(amm_active_network_test + tests/cpp/ActiveNetworkTest.cpp + src/ActiveNetwork.cpp + ) + target_include_directories(amm_active_network_test PRIVATE src) + target_link_libraries(amm_active_network_test PRIVATE Qt6::Core) + add_test(NAME amm_active_network COMMAND amm_active_network_test) + + add_executable(amm_new_position_runtime_test + tests/cpp/NewPositionRuntimeTest.cpp + src/NewPositionRuntime.cpp + ) + target_include_directories(amm_new_position_runtime_test PRIVATE src) + target_link_libraries(amm_new_position_runtime_test PRIVATE + Qt6::Core + PkgConfig::BASE58 + logos_wallet_access + ) + add_test(NAME amm_new_position_runtime COMMAND amm_new_position_runtime_test) +endif() diff --git a/apps/amm/README.md b/apps/amm/README.md index 9a8621c7..7219a3a5 100644 --- a/apps/amm/README.md +++ b/apps/amm/README.md @@ -27,10 +27,12 @@ Account/keystore sharing follows the runtime: startup the backend **adopts** the already-open wallet (see `openOrAdoptWallet()`), surfacing **shared** accounts across apps. -> Follow-up: the wallet FFI requires explicit `config_path`/`storage_path` even -> though the wallet crate already defines defaults (`~/.lee/wallet`, -> `from_path_or_initialize_default`). A `wallet_ffi_create_new_default()` / -> `_open_default()` upstream would let the app drop its path handling entirely. +> Follow-up: the app reconstructs the wallet paths itself because the +> `logos_execution_zone` module only exposes path-taking `create_new`/`open`. +> LEZ's wallet FFI now provides path-free variants (`wallet_ffi_create_new_default`, +> `wallet_ffi_open_default`, plus `wallet_ffi_default_config_path` / +> `_storage_path` / `wallet_ffi_wallet_exists_default`). Once the module surfaces +> those over QtRO, the app can drop its `defaultWalletHome/Config/Storage` logic. ## Setup @@ -204,6 +206,11 @@ TOKENS_CONFIG=$(pwd)/amm-tokens.json \ nix run .#amm-ui ``` +## Validation + +New Position validation commands and acceptance criteria live in +[VALIDATION.md](VALIDATION.md). + ## Updating Dependencies To update the pinned versions of dependencies in `flake.lock`: diff --git a/apps/amm/VALIDATION.md b/apps/amm/VALIDATION.md new file mode 100644 index 00000000..8663bfa5 --- /dev/null +++ b/apps/amm/VALIDATION.md @@ -0,0 +1,52 @@ +# AMM UI Validation + +Validation for the New Position flow covers the shipped Rust client, QML +syntax, and the complete module build. + +## Commands + +Run from the repository root: + +```bash +cargo +1.94.0 test -p amm_client +cargo +1.94.0 clippy -p amm_client --all-targets -- -D warnings +logos_qml=$(nix build github:logos-co/logos-design-system/6176f0d7a5dfeb64a7f0f98e7ca2bf71a4804772 --no-link --print-out-paths) +amm_qml=$(nix build ./apps/amm#packages.x86_64-linux.default --no-link --print-out-paths) +qt_qml=$(nix-store --query --requisites "$amm_qml" | rg -m1 -- '-qtdeclarative-[0-9]') +qt_svg=$(nix-store --query --requisites "$amm_qml" | rg -m1 -- '-qtsvg-[0-9]') +export QT_QPA_PLATFORM=offscreen QT_QUICK_BACKEND=software QT_PLUGIN_PATH="$qt_svg/lib/qt-6/plugins" +amm_import="$amm_qml/lib/qml" +test -f "$amm_import/Logos/Wallet/qmldir" +"$qt_qml/bin/qmltestrunner" -import "$qt_qml/lib/qt-6/qml" -import "$logos_qml/lib" -import "$amm_import" -input apps/shared/wallet/tests/qml +"$qt_qml/bin/qmltestrunner" -import "$qt_qml/lib/qt-6/qml" -import "$logos_qml/lib" -import "$amm_import" -input apps/amm/tests/qml +"$qt_qml/bin/qmllint" -I "$qt_qml/lib/qt-6/qml" -I "$logos_qml/lib" -I "$amm_qml/lib" -I "$amm_import" apps/amm/qml/pages/LiquidityPage.qml apps/amm/qml/components/liquidity/NewPositionForm.qml apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml +``` + +When shared AMM program types or instruction signatures change, also run the +affected Rust checks: + +```bash +RISC0_DEV_MODE=1 cargo +1.94.0 test -p amm_program +cargo +1.94.0 build -p idl-gen --release +./target/release/idl-gen programs/amm/methods/guest/src/bin/amm.rs > /tmp/amm-idl.json +diff artifacts/amm-idl.json /tmp/amm-idl.json +``` + +Live wallet/sequencer validation uses the configured Network and an External +Wallet Provider. Open the AMM UI, select two TokenProgram-scoped fungible token +definitions, preview an active or missing Pool, submit, and verify the returned +transaction ID is displayed. + +## Acceptance Checklist + +- Context exposes Wallet-Scoped Holdings and configured token definitions. +- Unsupported fee tiers are disabled and explain why. +- Active pool fee tier is fixed to the stored pool fee. +- Missing pool flow accepts an editable `X Token A = Y Token B` ratio and + scales either deposit from the minimum that mints more than + `MINIMUM_LIQUIDITY`. +- Active Pool flow keeps the reserve ratio and previews expected LP output. +- `new_definition` and `add_liquidity` account lists match the committed AMM IDL + order. +- Submit re-quotes and surfaces `quote_changed` when the request no longer + matches the preview hash. diff --git a/apps/amm/client/Cargo.toml b/apps/amm/client/Cargo.toml new file mode 100644 index 00000000..eb5014cd --- /dev/null +++ b/apps/amm/client/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "amm_client" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +alloy-primitives = { version = "1", default-features = false } +amm_core = { workspace = true } +borsh = { workspace = true } +clock_core = { git = "https://github.com/logos-blockchain/logos-execution-zone.git", tag = "v0.2.0" } +hex = "0.4" +nssa_core = { workspace = true } +risc0-zkvm = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = "0.10" +token_core = { workspace = true } +twap_oracle_core = { workspace = true } + +[dev-dependencies] +pretty_assertions = "1" diff --git a/apps/amm/client/include/amm_client.h b/apps/amm/client/include/amm_client.h new file mode 100644 index 00000000..83c56a6d --- /dev/null +++ b/apps/amm/client/include/amm_client.h @@ -0,0 +1,20 @@ +#ifndef AMM_CLIENT_H +#define AMM_CLIENT_H + +#ifdef __cplusplus +extern "C" { +#endif + +char *amm_config_id(const char *request_json); +char *amm_token_ids(const char *request_json); +char *amm_pair_ids(const char *request_json); +char *amm_context(const char *request_json); +char *amm_quote(const char *request_json); +char *amm_plan(const char *request_json); +void amm_free(char *value); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/apps/amm/client/src/account.rs b/apps/amm/client/src/account.rs new file mode 100644 index 00000000..2a8d0203 --- /dev/null +++ b/apps/amm/client/src/account.rs @@ -0,0 +1,152 @@ +use std::str::FromStr; + +use nssa_core::{ + account::{Account, AccountId, Data, Nonce}, + program::ProgramId, +}; +use serde::Deserialize; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AccountRead { + pub id: String, + pub status: String, + #[serde(default)] + pub account: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +pub struct WalletAccount { + pub program_owner: String, + pub balance: String, + pub nonce: String, + pub data: String, +} + +pub(crate) fn parse_hex_32(value: &str, label: &str) -> Result<[u8; 32], String> { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(format!( + "{label} must be 64 lowercase hexadecimal characters" + )); + } + + let mut bytes = [0_u8; 32]; + hex::decode_to_slice(value, &mut bytes).map_err(|error| format!("invalid {label}: {error}"))?; + Ok(bytes) +} + +pub(crate) fn parse_program_id(value: &str) -> Result { + let bytes = parse_hex_32(value, "program id")?; + let mut program_id = [0_u32; 8]; + for (word, chunk) in program_id.iter_mut().zip(bytes.chunks_exact(4)) { + let chunk: [u8; 4] = chunk + .try_into() + .map_err(|_| String::from("program id word has invalid length"))?; + *word = u32::from_le_bytes(chunk); + } + Ok(program_id) +} + +#[cfg(test)] +pub(crate) fn program_id_hex(program_id: ProgramId) -> String { + let bytes = program_id + .iter() + .flat_map(|word| word.to_le_bytes()) + .collect::>(); + hex::encode(bytes) +} + +pub(crate) fn program_id_base58(program_id: ProgramId) -> String { + AccountId::new(program_id_bytes(program_id)).to_string() +} + +pub(crate) fn program_id_bytes(program_id: ProgramId) -> [u8; 32] { + let mut bytes = [0_u8; 32]; + for (chunk, word) in bytes.chunks_exact_mut(4).zip(program_id) { + chunk.copy_from_slice(&word.to_le_bytes()); + } + bytes +} + +pub(crate) fn parse_base58_id(value: &str, label: &str) -> Result { + AccountId::from_str(value).map_err(|error| format!("invalid {label}: {error}")) +} + +pub(crate) fn account_id_from_hex(value: &str, label: &str) -> Result { + Ok(AccountId::new(parse_hex_32(value, label)?)) +} + +pub(crate) fn account_id_hex(account_id: AccountId) -> String { + hex::encode(account_id.into_value()) +} + +fn parse_le_u128(value: &str, label: &str) -> Result { + if value.len() != 32 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(format!( + "{label} must be 32 lowercase hexadecimal characters" + )); + } + let mut bytes = [0_u8; 16]; + hex::decode_to_slice(value, &mut bytes).map_err(|error| format!("invalid {label}: {error}"))?; + Ok(u128::from_le_bytes(bytes)) +} + +pub(crate) fn decode_account(read: &AccountRead) -> Result<(AccountId, Account), String> { + if read.status != "ok" { + return Err(String::from("account read failed")); + } + let account_id = account_id_from_hex(&read.id, "account id")?; + let source = read + .account + .as_ref() + .ok_or_else(|| String::from("successful account read has no account"))?; + let program_owner = parse_program_id(&source.program_owner)?; + let balance = parse_le_u128(&source.balance, "account balance")?; + let nonce = parse_le_u128(&source.nonce, "account nonce")?; + if source.data.len() % 2 != 0 + || !source + .data + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(String::from( + "account data must be lowercase even-length hexadecimal", + )); + } + let data = + hex::decode(&source.data).map_err(|error| format!("invalid account data: {error}"))?; + let data = + Data::try_from(data).map_err(|error| format!("account data is too large: {error}"))?; + + Ok(( + account_id, + Account { + program_owner, + balance, + data, + nonce: Nonce(nonce), + }, + )) +} + +#[cfg(test)] +pub(crate) fn account_read(id: AccountId, account: &Account) -> AccountRead { + AccountRead { + id: account_id_hex(id), + status: String::from("ok"), + account: Some(WalletAccount { + program_owner: program_id_hex(account.program_owner), + balance: hex::encode(account.balance.to_le_bytes()), + nonce: hex::encode(account.nonce.0.to_le_bytes()), + data: hex::encode(account.data.as_ref()), + }), + } +} diff --git a/apps/amm/client/src/api/accounts.rs b/apps/amm/client/src/api/accounts.rs new file mode 100644 index 00000000..a1c3c881 --- /dev/null +++ b/apps/amm/client/src/api/accounts.rs @@ -0,0 +1,226 @@ +use amm_core::PoolDefinition; +use nssa_core::program::ProgramId; + +use super::{ + funding::{append_holding_source, sources_from_reads}, + pair::PairIds, + position::{AccountPlan, AccountPlanHoldings, AccountPlanRow}, + QuoteRequest, +}; + +pub(super) fn missing_account_plan( + input: &QuoteRequest, + pair: PairIds, + amm_program: ProgramId, + holdings: AccountPlanHoldings<'_>, +) -> Result { + let mut sources = sources_from_reads(&[ + ("config", &input.snapshot.config), + ("token_a", &input.snapshot.token_a), + ("token_b", &input.snapshot.token_b), + ("pool", &input.snapshot.pool), + ("vault_a", &input.snapshot.vault_a), + ("vault_b", &input.snapshot.vault_b), + ("lp_definition", &input.snapshot.lp_definition), + ("lp_lock_holding", &input.snapshot.lp_lock_holding), + ("current_tick", &input.snapshot.current_tick), + ])?; + append_holding_source(&mut sources, "holding_a", holdings.token_a); + append_holding_source(&mut sources, "holding_b", holdings.token_b); + Ok(AccountPlan { + rows: vec![ + AccountPlanRow::new( + "config", + Some(pair.config), + Some(amm_program), + "read", + false, + false, + ), + AccountPlanRow::new( + "pool", + Some(pair.pool), + Some(amm_program), + "create", + false, + true, + ), + AccountPlanRow::new( + "vault_a", + Some(pair.vault_a), + Some(pair.token_program), + "create", + false, + true, + ), + AccountPlanRow::new( + "vault_b", + Some(pair.vault_b), + Some(pair.token_program), + "create", + false, + true, + ), + AccountPlanRow::new( + "lp_definition", + Some(pair.lp_definition), + Some(pair.token_program), + "create", + false, + true, + ), + AccountPlanRow::new( + "lp_lock_holding", + Some(pair.lp_lock_holding), + Some(pair.token_program), + "create", + false, + true, + ), + AccountPlanRow::new( + "user_holding_a", + holdings.token_a.map(|value| value.id), + Some(pair.token_program), + "update", + true, + false, + ), + AccountPlanRow::new( + "user_holding_b", + holdings.token_b.map(|value| value.id), + Some(pair.token_program), + "update", + true, + false, + ), + AccountPlanRow::new( + "user_holding_lp", + None, + Some(pair.token_program), + "create", + true, + true, + ), + AccountPlanRow::new( + "current_tick", + Some(pair.current_tick), + Some(pair.twap_program), + "create", + false, + true, + ), + AccountPlanRow::new("clock", Some(pair.clock), None, "read", false, false), + ], + sources, + }) +} + +pub(super) fn active_account_plan( + input: &QuoteRequest, + pair: PairIds, + amm_program: ProgramId, + pool: &PoolDefinition, + stored_reversed: bool, + holdings: AccountPlanHoldings<'_>, +) -> Result { + let (stored_holding_a, stored_holding_b) = if stored_reversed { + (holdings.token_b, holdings.token_a) + } else { + (holdings.token_a, holdings.token_b) + }; + let mut sources = sources_from_reads(&[ + ("config", &input.snapshot.config), + ("token_a", &input.snapshot.token_a), + ("token_b", &input.snapshot.token_b), + ("pool", &input.snapshot.pool), + ("vault_a", &input.snapshot.vault_a), + ("vault_b", &input.snapshot.vault_b), + ("lp_definition", &input.snapshot.lp_definition), + ("current_tick", &input.snapshot.current_tick), + ])?; + append_holding_source(&mut sources, "holding_a", holdings.token_a); + append_holding_source(&mut sources, "holding_b", holdings.token_b); + append_holding_source(&mut sources, "holding_lp", holdings.lp); + Ok(AccountPlan { + rows: vec![ + AccountPlanRow::new( + "config", + Some(pair.config), + Some(amm_program), + "read", + false, + false, + ), + AccountPlanRow::new( + "pool", + Some(pair.pool), + Some(amm_program), + "update", + false, + false, + ), + AccountPlanRow::new( + "vault_a", + Some(pool.vault_a_id), + Some(pair.token_program), + "update", + false, + false, + ), + AccountPlanRow::new( + "vault_b", + Some(pool.vault_b_id), + Some(pair.token_program), + "update", + false, + false, + ), + AccountPlanRow::new( + "lp_definition", + Some(pair.lp_definition), + Some(pair.token_program), + "update", + false, + false, + ), + AccountPlanRow::new( + "user_holding_a", + stored_holding_a.map(|value| value.id), + Some(pair.token_program), + "update", + true, + false, + ), + AccountPlanRow::new( + "user_holding_b", + stored_holding_b.map(|value| value.id), + Some(pair.token_program), + "update", + true, + false, + ), + AccountPlanRow::new( + "user_holding_lp", + holdings.lp.map(|value| value.id), + Some(pair.token_program), + if holdings.lp.is_some() { + "update" + } else { + "create" + }, + holdings.lp.is_none(), + holdings.lp.is_none(), + ), + AccountPlanRow::new( + "current_tick", + Some(pair.current_tick), + Some(pair.twap_program), + "update", + false, + false, + ), + AccountPlanRow::new("clock", Some(pair.clock), None, "read", false, false), + ], + sources, + }) +} diff --git a/apps/amm/client/src/api/clock.rs b/apps/amm/client/src/api/clock.rs new file mode 100644 index 00000000..358750db --- /dev/null +++ b/apps/amm/client/src/api/clock.rs @@ -0,0 +1,12 @@ +use borsh::from_slice; +use clock_core::ClockAccountData; +use nssa_core::account::AccountId; + +use crate::account::{decode_account, AccountRead}; + +pub(super) fn decode_clock(read: &AccountRead) -> Result<(AccountId, ClockAccountData), String> { + let (id, account) = decode_account(read)?; + let clock = from_slice(account.data.as_ref()) + .map_err(|error| format!("invalid clock account: {error}"))?; + Ok((id, clock)) +} diff --git a/apps/amm/client/src/api/commitment.rs b/apps/amm/client/src/api/commitment.rs new file mode 100644 index 00000000..4a6dc9fa --- /dev/null +++ b/apps/amm/client/src/api/commitment.rs @@ -0,0 +1,51 @@ +use borsh::BorshSerialize; + +#[derive(BorshSerialize)] +pub(super) enum RequestCommitment { + Missing { + amount_a: u128, + amount_b: u128, + }, + Active { + max_a: u128, + max_b: u128, + slippage_bps: u32, + }, +} + +#[derive(BorshSerialize)] +pub(super) struct SourceCommitment { + pub(super) role: String, + pub(super) commitment: [u8; 32], +} + +#[derive(BorshSerialize)] +pub(super) struct FundingCommitment { + pub(super) token_id: [u8; 32], + pub(super) holding_id: Option<[u8; 32]>, + pub(super) available: u128, + pub(super) requested: u128, +} + +#[derive(BorshSerialize)] +pub(super) struct QuoteCommitment { + pub(super) schema: String, + pub(super) network_id: String, + pub(super) network_fingerprint: String, + pub(super) amm_program_id: [u8; 32], + pub(super) token_a_id: [u8; 32], + pub(super) token_b_id: [u8; 32], + pub(super) fee_bps: u32, + pub(super) pool_status: u8, + pub(super) request: RequestCommitment, + pub(super) max_a: u128, + pub(super) max_b: u128, + pub(super) actual_a: u128, + pub(super) actual_b: u128, + pub(super) expected_lp: u128, + pub(super) lp_guard: u128, + pub(super) requires_fresh_lp: bool, + pub(super) sources: Vec, + pub(super) funding: Vec, + pub(super) warnings: Vec, +} diff --git a/apps/amm/client/src/api/config.rs b/apps/amm/client/src/api/config.rs new file mode 100644 index 00000000..563537df --- /dev/null +++ b/apps/amm/client/src/api/config.rs @@ -0,0 +1,25 @@ +use amm_core::{compute_config_pda, AmmConfig}; +use nssa_core::{account::Account, program::ProgramId}; +use serde_json::{json, Value}; + +use super::ConfigIdRequest; +use crate::account::{account_id_hex, decode_account, parse_program_id, AccountRead}; + +pub(super) fn config_id(request: ConfigIdRequest) -> Result { + let amm_program = parse_program_id(&request.amm_program_id)?; + Ok(json!({ + "status": "ok", + "configId": account_id_hex(compute_config_pda(amm_program)), + })) +} + +pub(super) fn load_config(amm_program: ProgramId, read: &AccountRead) -> Result { + let (id, account) = decode_account(read)?; + if id != compute_config_pda(amm_program) + || account.program_owner != amm_program + || account == Account::default() + { + return Err(String::from("AMM config is unavailable")); + } + AmmConfig::try_from(&account.data).map_err(|_| String::from("AMM config is invalid")) +} diff --git a/apps/amm/client/src/api/context.rs b/apps/amm/client/src/api/context.rs new file mode 100644 index 00000000..601bdc44 --- /dev/null +++ b/apps/amm/client/src/api/context.rs @@ -0,0 +1,254 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use amm_core::{ + FEE_TIER_BPS_1, FEE_TIER_BPS_100, FEE_TIER_BPS_30, FEE_TIER_BPS_5, MINIMUM_LIQUIDITY, +}; +use nssa_core::{account::AccountId, program::ProgramId}; +use serde_json::{json, Value}; +use token_core::TokenDefinition; + +use super::{ + config::load_config, + holding::{select_holding, wallet_holdings, SelectedHolding}, + quote_error::issue, + ContextRequest, TokenIdsRequest, SCHEMA, +}; +use crate::account::{ + account_id_from_hex, account_id_hex, decode_account, parse_base58_id, parse_program_id, + program_id_base58, AccountRead, +}; + +pub(super) fn token_ids(request: TokenIdsRequest) -> Result { + let amm_program = parse_program_id(&request.amm_program_id)?; + let Ok(config) = load_config(amm_program, &request.config) else { + return Ok(manifest_error("config_unavailable")); + }; + + let holdings = wallet_holdings(&request.wallet_accounts, config.token_program_id); + let mut token_ids = BTreeSet::new(); + for id in &request.configured_token_ids { + if let Ok(id) = account_id_from_hex(id, "configured token id") { + token_ids.insert(id); + } + } + for id in request + .recent_token_ids + .iter() + .chain(&request.resolved_token_ids) + { + if let Ok(id) = parse_base58_id(id, "token id") { + token_ids.insert(id); + } + } + token_ids.extend(holdings.into_iter().map(|holding| holding.definition_id)); + + Ok(json!({ + "status": "ok", + "tokenIds": token_ids.into_iter().map(account_id_hex).collect::>(), + })) +} + +fn manifest_error(code: &str) -> Value { + json!({ "status": "error", "code": code, "tokenIds": [] }) +} + +pub(super) fn context(request: ContextRequest) -> Result { + let amm_program = parse_program_id(&request.amm_program_id)?; + let Ok(config) = load_config(amm_program, &request.config) else { + return Ok(context_error(&request, "config_unavailable")); + }; + + let holdings = wallet_holdings(&request.wallet_accounts, config.token_program_id); + let source_map = token_sources(&request, &holdings); + let mut rows = Vec::new(); + let mut warnings = Vec::new(); + + for (token_id, sources) in source_map { + let read = request + .token_definitions + .iter() + .find(|read| account_id_from_hex(&read.id, "token definition id") == Ok(token_id)); + let (name, total_supply, metadata_id) = + match fungible_definition(read, token_id, config.token_program_id) { + Ok(definition) => definition, + Err(error) => { + rows.push(unavailable_token_row(token_id, sources, error.code)); + if error.warn { + warnings.push(issue( + error.code, + "Token definition could not be read.", + &[], + json!({ "tokenId": token_id.to_string() }), + )); + } + continue; + } + }; + + let selected = select_holding(&holdings, token_id); + let mut row = json!({ + "definitionId": token_id.to_string(), + "name": name, + "metadataId": metadata_id.map(|id| id.to_string()), + "totalSupplyRaw": total_supply.to_string(), + "ownerProgramId": program_id_base58(config.token_program_id), + "public": true, + "fungible": true, + "selectable": true, + "status": "available", + "code": "available", + "sources": sources, + }); + if let Some(selected) = selected { + row["holdingId"] = json!(selected.id.to_string()); + row["balanceRaw"] = json!(selected.balance.to_string()); + } + rows.push(row); + } + + rows.sort_by(|left, right| { + let left_holding = left.get("holdingId").is_some(); + let right_holding = right.get("holdingId").is_some(); + right_holding.cmp(&left_holding).then_with(|| { + left["definitionId"] + .as_str() + .cmp(&right["definitionId"].as_str()) + }) + }); + + Ok(json!({ + "schema": SCHEMA, + "status": if request.wallet_available { "ready" } else { "no_wallet" }, + "networkId": request.network_id, + "networkFingerprint": request.network_fingerprint, + "walletAvailable": request.wallet_available, + "minimumLiquidityRaw": MINIMUM_LIQUIDITY.to_string(), + "programIds": { + "amm": program_id_base58(amm_program), + "token": program_id_base58(config.token_program_id), + "twapOracle": program_id_base58(config.twap_oracle_program_id), + }, + "tokens": rows, + "feeTiers": fee_tiers(), + "warnings": warnings, + })) +} + +fn context_error(request: &ContextRequest, code: &str) -> Value { + json!({ + "schema": SCHEMA, + "status": "error", + "code": code, + "networkId": request.network_id, + "networkFingerprint": request.network_fingerprint, + "walletAvailable": request.wallet_available, + "tokens": [], + "feeTiers": fee_tiers(), + "warnings": [], + }) +} + +fn fee_tiers() -> Value { + json!([ + { "feeBps": FEE_TIER_BPS_1, "label": "0.01%", "enabled": true }, + { "feeBps": FEE_TIER_BPS_5, "label": "0.05%", "enabled": true }, + { "feeBps": FEE_TIER_BPS_30, "label": "0.30%", "enabled": true }, + { "feeBps": FEE_TIER_BPS_100, "label": "1.00%", "enabled": true }, + ]) +} + +fn token_sources( + request: &ContextRequest, + holdings: &[SelectedHolding], +) -> BTreeMap> { + let mut sources: BTreeMap> = BTreeMap::new(); + for id in &request.configured_token_ids { + if let Ok(id) = account_id_from_hex(id, "configured token id") { + sources + .entry(id) + .or_default() + .insert(String::from("config")); + } + } + for (ids, source) in [ + (&request.recent_token_ids, "recent"), + (&request.resolved_token_ids, "resolved"), + ] { + for id in ids { + if let Ok(id) = parse_base58_id(id, "token id") { + sources.entry(id).or_default().insert(String::from(source)); + } + } + } + for holding in holdings { + sources + .entry(holding.definition_id) + .or_default() + .insert(String::from("holding")); + } + sources + .into_iter() + .map(|(id, values)| (id, values.into_iter().collect())) + .collect() +} + +fn unavailable_token_row(token_id: AccountId, sources: Vec, code: &str) -> Value { + json!({ + "definitionId": token_id.to_string(), + "name": "", + "metadataId": Value::Null, + "totalSupplyRaw": "0", + "selectable": false, + "status": code, + "code": code, + "sources": sources, + }) +} + +pub(super) struct DefinitionError { + pub(super) code: &'static str, + pub(super) warn: bool, +} + +pub(super) fn fungible_definition( + read: Option<&AccountRead>, + token_id: AccountId, + token_program: ProgramId, +) -> Result<(String, u128, Option), DefinitionError> { + let Some(read) = read else { + return Err(DefinitionError { + code: "token_definition_unreadable", + warn: true, + }); + }; + let Ok((id, account)) = decode_account(read) else { + return Err(DefinitionError { + code: "token_definition_unreadable", + warn: true, + }); + }; + if id != token_id { + return Err(DefinitionError { + code: "token_definition_unreadable", + warn: false, + }); + } + if account.program_owner != token_program { + return Err(DefinitionError { + code: "token_program_mismatch", + warn: false, + }); + } + match TokenDefinition::try_from(&account.data) { + Ok(TokenDefinition::Fungible { + name, + total_supply, + metadata_id, + .. + }) => Ok((name, total_supply, metadata_id)), + _ => Err(DefinitionError { + code: "token_not_fungible", + warn: false, + }), + } +} diff --git a/apps/amm/client/src/api/funding.rs b/apps/amm/client/src/api/funding.rs new file mode 100644 index 00000000..b328c4c4 --- /dev/null +++ b/apps/amm/client/src/api/funding.rs @@ -0,0 +1,106 @@ +use nssa_core::Commitment; +use serde_json::{json, Value}; +use sha2::{Digest as _, Sha256}; + +use super::{ + commitment::{FundingCommitment, QuoteCommitment, SourceCommitment}, + holding::SelectedHolding, + pair::PairIds, + quote_error::issue, +}; +use crate::account::{decode_account, AccountRead}; + +pub(super) fn funding_issues( + wallet_available: bool, + pair: PairIds, + holding_a: &Option, + requested_a: u128, + holding_b: &Option, + requested_b: u128, + fields: [&str; 2], +) -> Vec { + if !wallet_available { + return vec![issue( + "no_wallet", + "Connect a wallet to submit.", + &[], + json!({}), + )]; + } + let mut errors = Vec::new(); + for (token_id, holding, requested, field) in [ + (pair.token_a, holding_a, requested_a, fields[0]), + (pair.token_b, holding_b, requested_b, fields[1]), + ] { + let available = holding.as_ref().map_or(0, |value| value.balance); + if available < requested { + errors.push(issue( + "amount_exceeds_balance", + "Amount exceeds the selected wallet holding balance.", + &[field], + json!({ + "requestedRaw": requested.to_string(), + "availableRaw": available.to_string(), + "holdingFound": holding.is_some(), + "tokenId": token_id.to_string(), + }), + )); + } + } + errors +} + +pub(super) fn funding_commitments( + pair: PairIds, + holding_a: &Option, + requested_a: u128, + holding_b: &Option, + requested_b: u128, +) -> Vec { + [ + (pair.token_a, holding_a, requested_a), + (pair.token_b, holding_b, requested_b), + ] + .into_iter() + .map(|(token_id, holding, requested)| FundingCommitment { + token_id: token_id.into_value(), + holding_id: holding.as_ref().map(|value| value.id.into_value()), + available: holding.as_ref().map_or(0, |value| value.balance), + requested, + }) + .collect() +} + +pub(super) fn sources_from_reads( + reads: &[(&str, &AccountRead)], +) -> Result, String> { + reads + .iter() + .map(|(role, read)| { + let (id, account) = decode_account(read)?; + Ok(SourceCommitment { + role: String::from(*role), + commitment: Commitment::new(&id, &account).to_byte_array(), + }) + }) + .collect() +} + +pub(super) fn append_holding_source( + sources: &mut Vec, + role: &str, + holding: Option<&SelectedHolding>, +) { + if let Some(holding) = holding { + sources.push(SourceCommitment { + role: String::from(role), + commitment: Commitment::new(&holding.id, &holding.account).to_byte_array(), + }); + } +} + +pub(super) fn hash_quote(commitment: &QuoteCommitment) -> Result { + let bytes = borsh::to_vec(commitment) + .map_err(|error| format!("quote commitment serialization failed: {error}"))?; + Ok(format!("sha256:{}", hex::encode(Sha256::digest(bytes)))) +} diff --git a/apps/amm/client/src/api/holding.rs b/apps/amm/client/src/api/holding.rs new file mode 100644 index 00000000..84810406 --- /dev/null +++ b/apps/amm/client/src/api/holding.rs @@ -0,0 +1,64 @@ +use nssa_core::{ + account::{Account, AccountId}, + program::ProgramId, +}; +use token_core::TokenHolding; + +use crate::account::{decode_account, AccountRead}; + +#[derive(Clone)] +pub(super) struct SelectedHolding { + pub(super) id: AccountId, + pub(super) definition_id: AccountId, + pub(super) balance: u128, + pub(super) account: Account, +} + +pub(super) fn wallet_holdings( + reads: &[AccountRead], + token_program: ProgramId, +) -> Vec { + reads + .iter() + .filter_map(|read| decode_fungible_holding(read, token_program).ok()) + .collect() +} + +pub(super) fn decode_fungible_holding( + read: &AccountRead, + token_program: ProgramId, +) -> Result { + let (id, account) = decode_account(read)?; + if account.program_owner != token_program { + return Err(String::from("holding owner mismatch")); + } + let TokenHolding::Fungible { + definition_id, + balance, + } = TokenHolding::try_from(&account.data) + .map_err(|_| String::from("invalid fungible holding"))? + else { + return Err(String::from("invalid fungible holding")); + }; + Ok(SelectedHolding { + id, + definition_id, + balance, + account, + }) +} + +pub(super) fn select_holding( + holdings: &[SelectedHolding], + definition_id: AccountId, +) -> Option { + holdings + .iter() + .filter(|holding| holding.definition_id == definition_id) + .max_by(|left, right| { + left.balance + .cmp(&right.balance) + .then_with(|| right.id.cmp(&left.id)) + }) + .cloned() +} diff --git a/apps/amm/client/src/api/mod.rs b/apps/amm/client/src/api/mod.rs new file mode 100644 index 00000000..fdae2431 --- /dev/null +++ b/apps/amm/client/src/api/mod.rs @@ -0,0 +1,97 @@ +//! Transport-independent AMM client operations. + +mod accounts; +mod clock; +mod commitment; +mod config; +mod context; +mod funding; +mod holding; +mod pair; +mod plan; +mod position; +mod quote; +mod quote_error; +mod request; + +#[cfg(test)] +mod tests; + +use std::{error::Error, fmt}; + +pub use request::{ + ConfigIdRequest, ContextRequest, PairIdsRequest, PairSnapshot, PlanRequest, PositionRequest, + QuoteRequest, TokenIdsRequest, +}; +use serde_json::Value; + +pub use crate::account::{AccountRead, WalletAccount}; + +/// Schema identifier expected by position quote and plan requests. +pub const NEW_POSITION_SCHEMA: &str = "new-position.v1"; + +pub(crate) const SCHEMA: &str = NEW_POSITION_SCHEMA; + +/// JSON response shared by direct Rust callers and transport adapters. +pub type AmmResponse = Value; + +/// Result returned by AMM client operations. +pub type AmmResult = Result; + +/// Failure produced before an AMM response can be constructed. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AmmApiError { + message: String, +} + +impl AmmApiError { + /// Returns the stable human-readable failure detail. + #[must_use] + pub fn message(&self) -> &str { + &self.message + } +} + +impl fmt::Display for AmmApiError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl Error for AmmApiError {} + +impl From for AmmApiError { + fn from(message: String) -> Self { + Self { message } + } +} + +/// Derives the AMM configuration account ID. +pub fn config_id(request: ConfigIdRequest) -> AmmResult { + config::config_id(request).map_err(Into::into) +} + +/// Discovers token definition IDs available to the active wallet and app. +pub fn token_ids(request: TokenIdsRequest) -> AmmResult { + context::token_ids(request).map_err(Into::into) +} + +/// Derives canonical accounts for one token pair. +pub fn pair_ids(request: PairIdsRequest) -> AmmResult { + pair::pair_ids(request).map_err(Into::into) +} + +/// Builds network, token, holding, and fee-tier context. +pub fn context(request: ContextRequest) -> AmmResult { + context::context(request).map_err(Into::into) +} + +/// Evaluates a pool-creation or add-liquidity request. +pub fn quote(request: QuoteRequest) -> AmmResult { + quote::quote(request).map_err(Into::into) +} + +/// Materializes a previously quoted request into wallet submission arguments. +pub fn plan(request: PlanRequest) -> AmmResult { + plan::plan(request).map_err(Into::into) +} diff --git a/apps/amm/client/src/api/pair.rs b/apps/amm/client/src/api/pair.rs new file mode 100644 index 00000000..f6cb3c74 --- /dev/null +++ b/apps/amm/client/src/api/pair.rs @@ -0,0 +1,93 @@ +use amm_core::{ + compute_config_pda, compute_liquidity_token_pda, compute_lp_lock_holding_pda, compute_pool_pda, + compute_vault_pda, +}; +use clock_core::CLOCK_01_PROGRAM_ACCOUNT_ID; +use nssa_core::{account::AccountId, program::ProgramId}; +use serde_json::{json, Value}; +use twap_oracle_core::compute_current_tick_account_pda; + +use super::{config::load_config, PairIdsRequest}; +use crate::account::{account_id_hex, parse_base58_id, parse_program_id, AccountRead}; + +#[derive(Clone, Copy)] +pub(super) struct PairIds { + pub(super) token_a: AccountId, + pub(super) token_b: AccountId, + pub(super) config: AccountId, + pub(super) pool: AccountId, + pub(super) vault_a: AccountId, + pub(super) vault_b: AccountId, + pub(super) lp_definition: AccountId, + pub(super) lp_lock_holding: AccountId, + pub(super) current_tick: AccountId, + pub(super) clock: AccountId, + pub(super) token_program: ProgramId, + pub(super) twap_program: ProgramId, +} + +pub(super) fn pair_ids(request: PairIdsRequest) -> Result { + let amm_program = parse_program_id(&request.amm_program_id)?; + let Ok(token_a) = parse_base58_id(&request.token_a_id, "token A id") else { + return Ok(json!({ "status": "error", "code": "invalid_token_id" })); + }; + let Ok(token_b) = parse_base58_id(&request.token_b_id, "token B id") else { + return Ok(json!({ "status": "error", "code": "invalid_token_id" })); + }; + if token_a == token_b { + return Ok(json!({ "status": "error", "code": "same_token_pair" })); + } + if !is_canonical_pair(token_a, token_b) { + return Ok(json!({ "status": "error", "code": "non_canonical_pair" })); + } + + let Ok(pair) = derive_pair(amm_program, token_a, token_b, &request.config) else { + return Ok(json!({ "status": "error", "code": "config_unavailable" })); + }; + Ok(pair_json(pair)) +} + +pub(super) fn is_canonical_pair(token_a: AccountId, token_b: AccountId) -> bool { + token_a.value() > token_b.value() +} + +pub(super) fn derive_pair( + amm_program: ProgramId, + token_a: AccountId, + token_b: AccountId, + config_read: &AccountRead, +) -> Result { + let config_id = compute_config_pda(amm_program); + let config = load_config(amm_program, config_read)?; + let pool = compute_pool_pda(amm_program, token_a, token_b); + Ok(PairIds { + token_a, + token_b, + config: config_id, + pool, + vault_a: compute_vault_pda(amm_program, pool, token_a), + vault_b: compute_vault_pda(amm_program, pool, token_b), + lp_definition: compute_liquidity_token_pda(amm_program, pool), + lp_lock_holding: compute_lp_lock_holding_pda(amm_program, pool), + current_tick: compute_current_tick_account_pda(config.twap_oracle_program_id, pool), + clock: CLOCK_01_PROGRAM_ACCOUNT_ID, + token_program: config.token_program_id, + twap_program: config.twap_oracle_program_id, + }) +} + +fn pair_json(pair: PairIds) -> Value { + json!({ + "status": "ok", + "tokenAId": account_id_hex(pair.token_a), + "tokenBId": account_id_hex(pair.token_b), + "configId": account_id_hex(pair.config), + "poolId": account_id_hex(pair.pool), + "vaultAId": account_id_hex(pair.vault_a), + "vaultBId": account_id_hex(pair.vault_b), + "lpDefinitionId": account_id_hex(pair.lp_definition), + "lpLockHoldingId": account_id_hex(pair.lp_lock_holding), + "currentTickId": account_id_hex(pair.current_tick), + "clockId": account_id_hex(pair.clock), + }) +} diff --git a/apps/amm/client/src/api/plan.rs b/apps/amm/client/src/api/plan.rs new file mode 100644 index 00000000..b87ce0bf --- /dev/null +++ b/apps/amm/client/src/api/plan.rs @@ -0,0 +1,136 @@ +use nssa_core::account::Account; +use serde_json::{json, Value}; + +use super::{ + clock::decode_clock, + position::{NewPositionPlan, QuoteBranch, QuoteComputation}, + quote::compute_quote, + PlanRequest, QuoteRequest, SCHEMA, +}; +use crate::account::{account_id_hex, decode_account, AccountRead}; + +const DEADLINE_WINDOW_MS: u64 = 1_200_000; + +pub(super) fn plan(input: PlanRequest) -> Result { + let quote_input = QuoteRequest { + network_id: input.network_id, + network_fingerprint: input.network_fingerprint, + amm_program_id: input.amm_program_id.clone(), + request: input.request, + snapshot: input.snapshot, + }; + let quote = compute_quote("e_input)?; + if quote.quote_hash() != Some(input.quote_hash.as_str()) { + return Ok(json!({ + "schema": SCHEMA, + "status": "error", + "code": "quote_changed", + "recoverable": true, + "quote": quote.into_value("e_input.request), + })); + } + let evaluated = match quote { + QuoteComputation::Evaluated(evaluated) => evaluated, + QuoteComputation::Failed(failure) => { + return Ok(json!({ + "schema": SCHEMA, + "status": "error", + "code": "quote_not_submittable", + "recoverable": true, + "quote": failure.into_value("e_input.request), + })) + } + }; + let Some(plan) = evaluated.plan else { + return Ok(json!({ + "schema": SCHEMA, + "status": "error", + "code": "quote_not_submittable", + "recoverable": true, + "quote": evaluated.value, + })); + }; + let fresh_lp = if plan.requires_fresh_lp() { + let Some(read) = input.fresh_lp.as_ref() else { + return Ok(json!({ + "schema": SCHEMA, + "status": "needs_fresh_lp", + "code": "fresh_lp_required", + })); + }; + let Ok((id, account)) = decode_account(read) else { + return Ok(plan_error("wallet_submission_failed")); + }; + if account != Account::default() || plan.accounts.contains(id) { + return Ok(plan_error("wallet_submission_failed")); + } + Some(id) + } else { + None + }; + let deadline = input + .now_ms + .checked_add(DEADLINE_WINDOW_MS) + .ok_or_else(|| String::from("transaction deadline overflow"))?; + let clock_timestamp = clock_timestamp("e_input.snapshot.clock)?; + if clock_timestamp >= deadline { + return Ok(plan_error("transaction_deadline_expired")); + } + let NewPositionPlan { accounts, branch } = plan; + let (account_ids, signing_requirements) = accounts.wallet_args(fresh_lp)?; + let instruction = match branch { + QuoteBranch::Missing { amount_a, amount_b } => { + let instruction = amm_core::Instruction::NewDefinition { + token_a_amount: amount_a, + token_b_amount: amount_b, + fees: u128::from(quote_input.request.fee_bps), + deadline, + }; + risc0_zkvm::serde::to_vec(&instruction) + .map_err(|error| format!("instruction serialization failed: {error}"))? + } + QuoteBranch::Active { + max_a, + max_b, + minimum_lp, + stored_reversed, + } => { + let (stored_max_a, stored_max_b) = if stored_reversed { + (max_b, max_a) + } else { + (max_a, max_b) + }; + let instruction = amm_core::Instruction::AddLiquidity { + min_amount_liquidity: minimum_lp, + max_amount_to_add_token_a: stored_max_a, + max_amount_to_add_token_b: stored_max_b, + deadline, + }; + risc0_zkvm::serde::to_vec(&instruction) + .map_err(|error| format!("instruction serialization failed: {error}"))? + } + }; + + Ok(json!({ + "schema": SCHEMA, + "status": "ready", + "programId": input.amm_program_id, + "accountIds": account_ids.into_iter().map(account_id_hex).collect::>(), + "signingRequirements": signing_requirements, + "instruction": instruction, + "deadlineMs": deadline.to_string(), + })) +} + +fn plan_error(code: &str) -> Value { + json!({ + "schema": SCHEMA, + "status": "error", + "code": code, + "recoverable": true, + }) +} + +fn clock_timestamp(read: &AccountRead) -> Result { + decode_clock(read).map(|(_, clock)| clock.timestamp) +} diff --git a/apps/amm/client/src/api/position.rs b/apps/amm/client/src/api/position.rs new file mode 100644 index 00000000..c173faa5 --- /dev/null +++ b/apps/amm/client/src/api/position.rs @@ -0,0 +1,229 @@ +use nssa_core::{account::AccountId, program::ProgramId}; +use serde_json::{json, Value}; + +use super::{ + commitment::SourceCommitment, holding::SelectedHolding, pair::PairIds, quote_error::issue, + PairSnapshot, PositionRequest, SCHEMA, +}; +use crate::account::{account_id_from_hex, program_id_base58}; + +#[derive(Clone)] +pub(super) enum QuoteBranch { + Missing { + amount_a: u128, + amount_b: u128, + }, + Active { + max_a: u128, + max_b: u128, + minimum_lp: u128, + stored_reversed: bool, + }, +} + +pub(super) struct EvaluatedQuote { + pub(super) value: Value, + pub(super) quote_hash: String, + pub(super) plan: Option, +} + +pub(super) enum QuoteComputation { + Failed(QuoteFailure), + Evaluated(EvaluatedQuote), +} + +pub(super) struct QuoteFailure { + pub(super) code: &'static str, + pub(super) fields: Vec<&'static str>, + pub(super) details: Value, +} + +pub(super) struct NewPositionPlan { + pub(super) accounts: AccountPlan, + pub(super) branch: QuoteBranch, +} + +pub(super) struct AccountPlan { + pub(super) rows: Vec, + pub(super) sources: Vec, +} + +pub(super) struct AccountPlanRow { + pub(super) role: &'static str, + pub(super) account_id: Option, + pub(super) expected_program: Option, + pub(super) action: &'static str, + pub(super) signer: bool, + pub(super) init: bool, +} + +pub(super) struct AccountPlanHoldings<'a> { + pub(super) token_a: Option<&'a SelectedHolding>, + pub(super) token_b: Option<&'a SelectedHolding>, + pub(super) lp: Option<&'a SelectedHolding>, +} + +impl QuoteComputation { + pub(super) fn into_value(self, request: &PositionRequest) -> Value { + match self { + Self::Failed(failure) => failure.into_value(request), + Self::Evaluated(EvaluatedQuote { value, .. }) => value, + } + } + + pub(super) fn quote_hash(&self) -> Option<&str> { + match self { + Self::Failed(_) => None, + Self::Evaluated(quote) => Some("e.quote_hash), + } + } +} + +impl QuoteFailure { + pub(super) fn into_value(self, request: &PositionRequest) -> Value { + json!({ + "schema": SCHEMA, + "status": "error", + "canSubmit": false, + "code": self.code, + "poolStatus": "unavailable_pool", + "tokenAId": request.token_a_id, + "tokenBId": request.token_b_id, + "accountPreview": [], + "errors": [issue( + self.code, + "Position quote is unavailable.", + &self.fields, + self.details, + )], + "warnings": [], + }) + } +} + +impl NewPositionPlan { + pub(super) fn new(accounts: AccountPlan, branch: QuoteBranch) -> Result { + accounts.validate_ready()?; + Ok(Self { accounts, branch }) + } + + pub(super) fn requires_fresh_lp(&self) -> bool { + self.accounts.requires_fresh_lp() + } +} + +impl AccountPlan { + pub(super) fn validate_snapshot_ids( + pair: &PairIds, + snapshot: &PairSnapshot, + ) -> Option<&'static str> { + let expected = [ + (&snapshot.config, pair.config, "config"), + (&snapshot.token_a, pair.token_a, "token_a"), + (&snapshot.token_b, pair.token_b, "token_b"), + (&snapshot.pool, pair.pool, "pool"), + (&snapshot.vault_a, pair.vault_a, "vault_a"), + (&snapshot.vault_b, pair.vault_b, "vault_b"), + (&snapshot.lp_definition, pair.lp_definition, "lp_definition"), + ( + &snapshot.lp_lock_holding, + pair.lp_lock_holding, + "lp_lock_holding", + ), + (&snapshot.current_tick, pair.current_tick, "current_tick"), + (&snapshot.clock, pair.clock, "clock"), + ]; + expected.into_iter().find_map(|(read, id, role)| { + match account_id_from_hex(&read.id, "account id") { + Ok(actual) if actual == id => None, + _ => Some(role), + } + }) + } + + pub(super) fn preview(&self) -> Vec { + self.rows + .iter() + .enumerate() + .map(|(order, row)| row.preview(order)) + .collect() + } + + pub(super) fn take_sources(&mut self) -> Vec { + std::mem::take(&mut self.sources) + } + + pub(super) fn requires_fresh_lp(&self) -> bool { + self.rows + .iter() + .any(|row| row.role == "user_holding_lp" && row.account_id.is_none()) + } + + pub(super) fn contains(&self, account_id: AccountId) -> bool { + self.rows + .iter() + .any(|row| row.account_id == Some(account_id)) + } + + pub(super) fn validate_ready(&self) -> Result<(), String> { + if self.rows.iter().any(|row| { + row.account_id.is_none() && !(row.role == "user_holding_lp" && row.signer && row.init) + }) { + return Err(String::from("submittable quote has an unresolved account")); + } + Ok(()) + } + + pub(super) fn wallet_args( + &self, + fresh_lp: Option, + ) -> Result<(Vec, Vec), String> { + let mut account_ids = Vec::with_capacity(self.rows.len()); + let mut signing_requirements = Vec::with_capacity(self.rows.len()); + for row in &self.rows { + let account_id = match row.account_id { + Some(account_id) => account_id, + None if row.role == "user_holding_lp" => { + fresh_lp.ok_or_else(|| String::from("transaction plan has no LP holding"))? + } + None => return Err(String::from("transaction plan has an unresolved account")), + }; + account_ids.push(account_id); + signing_requirements.push(row.signer); + } + Ok((account_ids, signing_requirements)) + } +} + +impl AccountPlanRow { + pub(super) fn new( + role: &'static str, + account_id: Option, + expected_program: Option, + action: &'static str, + signer: bool, + init: bool, + ) -> Self { + Self { + role, + account_id, + expected_program, + action, + signer, + init, + } + } + + fn preview(&self, order: usize) -> Value { + json!({ + "order": order, + "role": self.role, + "accountId": self.account_id.map(|id| id.to_string()), + "expectedProgramId": self.expected_program.map(program_id_base58), + "action": self.action, + "writable": self.action != "read", + "signer": self.signer, + "init": self.init, + }) + } +} diff --git a/apps/amm/client/src/api/quote.rs b/apps/amm/client/src/api/quote.rs new file mode 100644 index 00000000..9d4e3226 --- /dev/null +++ b/apps/amm/client/src/api/quote.rs @@ -0,0 +1,726 @@ +use alloy_primitives::U256; +use amm_core::{ + is_supported_fee_tier, isqrt_product, mul_div_floor, spot_price_q64_64, PoolDefinition, + FEE_BPS_DENOMINATOR, MINIMUM_LIQUIDITY, +}; +use nssa_core::{ + account::{Account, AccountId}, + program::ProgramId, +}; +use serde_json::{json, Value}; +use token_core::TokenDefinition; +use twap_oracle_core::CurrentTickAccount; + +use super::{ + accounts::{active_account_plan, missing_account_plan}, + clock::decode_clock, + commitment::{QuoteCommitment, RequestCommitment}, + context::fungible_definition, + funding::{funding_commitments, funding_issues, hash_quote}, + holding::{decode_fungible_holding, select_holding, wallet_holdings}, + pair::{derive_pair, is_canonical_pair, PairIds}, + position::{ + AccountPlan, AccountPlanHoldings, EvaluatedQuote, NewPositionPlan, QuoteBranch, + QuoteComputation, + }, + quote_error::{fatal_quote, issue}, + QuoteRequest, SCHEMA, +}; +use crate::account::{ + decode_account, parse_base58_id, parse_program_id, program_id_bytes, AccountRead, +}; + +const DEFAULT_SLIPPAGE_BPS: u32 = 50; +const MAX_SLIPPAGE_BPS: u32 = 5_000; +const HIGH_SLIPPAGE_BPS: u32 = 2_000; +pub(super) const Q64: u128 = 1_u128 << 64; + +pub(super) fn quote(request: QuoteRequest) -> Result { + Ok(compute_quote(&request)?.into_value(&request.request)) +} + +pub(super) fn compute_quote(input: &QuoteRequest) -> Result { + if input.request.schema != SCHEMA { + return Ok(fatal_quote( + "unsupported_schema", + &["schema"], + json!({ "received": input.request.schema }), + )); + } + let amm_program = parse_program_id(&input.amm_program_id)?; + let token_a = match parse_base58_id(&input.request.token_a_id, "token A id") { + Ok(id) => id, + Err(_) => return Ok(fatal_quote("invalid_token_id", &["tokenAId"], json!({}))), + }; + let token_b = match parse_base58_id(&input.request.token_b_id, "token B id") { + Ok(id) => id, + Err(_) => return Ok(fatal_quote("invalid_token_id", &["tokenBId"], json!({}))), + }; + if token_a == token_b { + return Ok(fatal_quote( + "same_token_pair", + &["tokenAId", "tokenBId"], + json!({}), + )); + } + if !is_canonical_pair(token_a, token_b) { + return Ok(fatal_quote( + "non_canonical_pair", + &["tokenAId", "tokenBId"], + json!({}), + )); + } + if !is_supported_fee_tier(u128::from(input.request.fee_bps)) { + return Ok(fatal_quote( + "invalid_fee_tier", + &["feeBps"], + json!({ "feeBps": input.request.fee_bps }), + )); + } + + let pair = match derive_pair(amm_program, token_a, token_b, &input.snapshot.config) { + Ok(pair) => pair, + Err(_) => return Ok(fatal_quote("config_unavailable", &[], json!({}))), + }; + if let Some(error) = AccountPlan::validate_snapshot_ids(&pair, &input.snapshot) { + return Ok(fatal_quote( + "account_read_failed", + &[], + json!({ "role": error }), + )); + } + for (read, token_id, field) in [ + (&input.snapshot.token_a, token_a, "tokenAId"), + (&input.snapshot.token_b, token_b, "tokenBId"), + ] { + if let Err(error) = fungible_definition(Some(read), token_id, pair.token_program) { + return Ok(fatal_quote( + error.code, + &[field], + json!({ "tokenId": token_id.to_string() }), + )); + } + } + + let (_, pool_account) = match decode_account(&input.snapshot.pool) { + Ok(value) => value, + Err(_) => { + return Ok(fatal_quote( + "account_read_failed", + &[], + json!({ "role": "pool", "accountId": pair.pool.to_string() }), + )) + } + }; + if pool_account == Account::default() { + compute_missing_quote(input, amm_program, pair) + } else { + compute_active_quote(input, amm_program, pair, pool_account) + } +} + +fn compute_missing_quote( + input: &QuoteRequest, + amm_program: ProgramId, + pair: PairIds, +) -> Result { + for (read, role) in [ + (&input.snapshot.vault_a, "vault_a"), + (&input.snapshot.vault_b, "vault_b"), + (&input.snapshot.lp_definition, "lp_definition"), + (&input.snapshot.lp_lock_holding, "lp_lock_holding"), + (&input.snapshot.current_tick, "current_tick"), + ] { + let Ok((_, account)) = decode_account(read) else { + return Ok(fatal_quote( + "account_read_failed", + &[], + json!({ "role": role }), + )); + }; + if account != Account::default() { + return Ok(fatal_quote( + "pool_unavailable", + &[], + json!({ "role": role }), + )); + } + } + if !valid_clock(&input.snapshot.clock, pair.clock) { + return Ok(fatal_quote( + "account_read_failed", + &[], + json!({ "role": "clock" }), + )); + } + + let requested_price = match raw_value(input.request.initial_price_real_raw.as_deref()) { + Ok(value) if value > 0 => value, + Ok(_) => { + return Ok(fatal_quote( + "amount_must_be_positive", + &["initialPriceRealRaw"], + json!({}), + )) + } + Err(code) => return Ok(fatal_quote(code, &["initialPriceRealRaw"], json!({}))), + }; + let (minimum_a, minimum_b) = minimum_opening_pair(requested_price)?; + let direct_amounts = + input.request.amount_a_raw.is_some() || input.request.amount_b_raw.is_some(); + let (amount_a, amount_b) = if direct_amounts { + let amount_a = match raw_value(input.request.amount_a_raw.as_deref()) { + Ok(value) if value > 0 => value, + Ok(_) => { + return Ok(fatal_quote( + "amount_must_be_positive", + &["amountARaw"], + json!({}), + )) + } + Err(code) => return Ok(fatal_quote(code, &["amountARaw"], json!({}))), + }; + let amount_b = match raw_value(input.request.amount_b_raw.as_deref()) { + Ok(value) if value > 0 => value, + Ok(_) => { + return Ok(fatal_quote( + "amount_must_be_positive", + &["amountBRaw"], + json!({}), + )) + } + Err(code) => return Ok(fatal_quote(code, &["amountBRaw"], json!({}))), + }; + if spot_price_q64_64(amount_a, amount_b) != requested_price { + return Ok(fatal_quote( + "deposit_ratio_mismatch", + &["amountARaw", "amountBRaw"], + json!({}), + )); + } + (amount_a, amount_b) + } else { + (minimum_a, minimum_b) + }; + let initial_lp = isqrt_product(amount_a, amount_b); + if initial_lp <= MINIMUM_LIQUIDITY { + return Ok(fatal_quote( + "amount_too_low", + &["amountARaw", "amountBRaw"], + json!({ "minimumLiquidityRaw": MINIMUM_LIQUIDITY.to_string() }), + )); + } + let expected_lp = initial_lp - MINIMUM_LIQUIDITY; + + let holdings = wallet_holdings(&input.snapshot.wallet_accounts, pair.token_program); + let holding_a = select_holding(&holdings, pair.token_a); + let holding_b = select_holding(&holdings, pair.token_b); + let funding = funding_issues( + input.snapshot.wallet_available, + pair, + &holding_a, + amount_a, + &holding_b, + amount_b, + ["amountARaw", "amountBRaw"], + ); + let can_submit = funding.is_empty(); + let mut account_plan = missing_account_plan( + input, + pair, + amm_program, + AccountPlanHoldings { + token_a: holding_a.as_ref(), + token_b: holding_b.as_ref(), + lp: None, + }, + )?; + let sources = account_plan.take_sources(); + let funding_commitment = funding_commitments(pair, &holding_a, amount_a, &holding_b, amount_b); + let commitment = QuoteCommitment { + schema: String::from(SCHEMA), + network_id: input.network_id.clone(), + network_fingerprint: input.network_fingerprint.clone(), + amm_program_id: program_id_bytes(amm_program), + token_a_id: pair.token_a.into_value(), + token_b_id: pair.token_b.into_value(), + fee_bps: input.request.fee_bps, + pool_status: 0, + request: RequestCommitment::Missing { amount_a, amount_b }, + max_a: amount_a, + max_b: amount_b, + actual_a: amount_a, + actual_b: amount_b, + expected_lp, + lp_guard: MINIMUM_LIQUIDITY, + requires_fresh_lp: true, + sources, + funding: funding_commitment, + warnings: Vec::new(), + }; + let quote_hash = hash_quote(&commitment)?; + let preview = account_plan.preview(); + let value = json!({ + "schema": SCHEMA, + "status": "ok", + "canSubmit": can_submit, + "code": if can_submit { "ready" } else { "funding_required" }, + "poolStatus": "missing_pool", + "instruction": "NewDefinition", + "quoteHash": quote_hash, + "feeBps": input.request.fee_bps, + "poolId": pair.pool.to_string(), + "tokenAId": pair.token_a.to_string(), + "tokenBId": pair.token_b.to_string(), + "maxAmountARaw": amount_a.to_string(), + "maxAmountBRaw": amount_b.to_string(), + "actualAmountARaw": amount_a.to_string(), + "actualAmountBRaw": amount_b.to_string(), + "expectedLpRaw": expected_lp.to_string(), + "lockedLpRaw": MINIMUM_LIQUIDITY.to_string(), + "initialPriceRealRaw": spot_price_q64_64(amount_a, amount_b).to_string(), + "minimumAmountARaw": minimum_a.to_string(), + "minimumAmountBRaw": minimum_b.to_string(), + "requiresFreshLp": true, + "accountPreview": preview, + "errors": funding, + "warnings": [], + }); + let plan = if can_submit { + Some(NewPositionPlan::new( + account_plan, + QuoteBranch::Missing { amount_a, amount_b }, + )?) + } else { + None + }; + Ok(QuoteComputation::Evaluated(EvaluatedQuote { + value, + quote_hash, + plan, + })) +} + +fn compute_active_quote( + input: &QuoteRequest, + amm_program: ProgramId, + pair: PairIds, + pool_account: Account, +) -> Result { + if pool_account.program_owner != amm_program { + return Ok(fatal_quote( + "pool_unavailable", + &[], + json!({ "reason": "owner_mismatch" }), + )); + } + let Ok(pool) = PoolDefinition::try_from(&pool_account.data) else { + return Ok(fatal_quote( + "pool_unavailable", + &[], + json!({ "reason": "invalid_pool_data" }), + )); + }; + let stored_reversed = if pool.definition_token_a_id == pair.token_a + && pool.definition_token_b_id == pair.token_b + { + false + } else if pool.definition_token_a_id == pair.token_b + && pool.definition_token_b_id == pair.token_a + { + true + } else { + return Ok(fatal_quote( + "pool_unavailable", + &[], + json!({ "reason": "pair_mismatch" }), + )); + }; + if pool.reserve_a == 0 || pool.reserve_b == 0 || pool.liquidity_pool_supply == 0 { + return Ok(fatal_quote("pool_inactive", &[], json!({}))); + } + if pool.fees != u128::from(input.request.fee_bps) { + return Ok(fatal_quote( + "fee_tier_mismatch", + &["feeBps"], + json!({ "poolFeeBps": pool.fees.to_string() }), + )); + } + if !is_supported_fee_tier(pool.fees) { + return Ok(fatal_quote( + "pool_unavailable", + &[], + json!({ "reason": "unsupported_pool_fee" }), + )); + } + + let max_a = match raw_value(input.request.max_amount_a_raw.as_deref()) { + Ok(value) if value > 0 => value, + Ok(_) => { + return Ok(fatal_quote( + "amount_must_be_positive", + &["maxAmountARaw"], + json!({}), + )) + } + Err(code) => return Ok(fatal_quote(code, &["maxAmountARaw"], json!({}))), + }; + let max_b = match raw_value(input.request.max_amount_b_raw.as_deref()) { + Ok(value) if value > 0 => value, + Ok(_) => { + return Ok(fatal_quote( + "amount_must_be_positive", + &["maxAmountBRaw"], + json!({}), + )) + } + Err(code) => return Ok(fatal_quote(code, &["maxAmountBRaw"], json!({}))), + }; + let slippage_bps = input.request.slippage_bps.unwrap_or(DEFAULT_SLIPPAGE_BPS); + if slippage_bps > MAX_SLIPPAGE_BPS { + return Ok(fatal_quote( + "invalid_slippage", + &["slippageBps"], + json!({ "maximum": MAX_SLIPPAGE_BPS }), + )); + } + + let (stored_max_a, stored_max_b) = if stored_reversed { + (max_b, max_a) + } else { + (max_a, max_b) + }; + let ideal_a = mul_div_floor(pool.reserve_a, stored_max_b, pool.reserve_b); + let ideal_b = mul_div_floor(pool.reserve_b, stored_max_a, pool.reserve_a); + let stored_actual_a = stored_max_a.min(ideal_a); + let stored_actual_b = stored_max_b.min(ideal_b); + if stored_actual_a == 0 || stored_actual_b == 0 { + return Ok(fatal_quote( + "amount_too_low", + &["maxAmountARaw", "maxAmountBRaw"], + json!({}), + )); + } + let expected_lp = + mul_div_floor(pool.liquidity_pool_supply, stored_actual_a, pool.reserve_a).min( + mul_div_floor(pool.liquidity_pool_supply, stored_actual_b, pool.reserve_b), + ); + if expected_lp == 0 { + return Ok(fatal_quote( + "amount_too_low", + &["maxAmountARaw", "maxAmountBRaw"], + json!({}), + )); + } + let minimum_lp = mul_div_floor( + expected_lp, + FEE_BPS_DENOMINATOR - u128::from(slippage_bps), + FEE_BPS_DENOMINATOR, + ); + if minimum_lp == 0 { + return Ok(fatal_quote("minimum_lp_zero", &["slippageBps"], json!({}))); + } + let (actual_a, actual_b, reserve_a, reserve_b) = if stored_reversed { + ( + stored_actual_b, + stored_actual_a, + pool.reserve_b, + pool.reserve_a, + ) + } else { + ( + stored_actual_a, + stored_actual_b, + pool.reserve_a, + pool.reserve_b, + ) + }; + + if let Some(error) = validate_active_accounts(input, pair, &pool, stored_reversed) { + return Ok(error); + } + let holdings = wallet_holdings(&input.snapshot.wallet_accounts, pair.token_program); + let holding_a = select_holding(&holdings, pair.token_a); + let holding_b = select_holding(&holdings, pair.token_b); + let lp_holding = select_holding(&holdings, pair.lp_definition); + let requires_fresh_lp = lp_holding.is_none(); + let funding = funding_issues( + input.snapshot.wallet_available, + pair, + &holding_a, + actual_a, + &holding_b, + actual_b, + ["maxAmountARaw", "maxAmountBRaw"], + ); + let can_submit = funding.is_empty(); + let warnings = if slippage_bps >= HIGH_SLIPPAGE_BPS { + vec![issue( + "high_slippage", + "High slippage tolerance.", + &["slippageBps"], + json!({ "slippageBps": slippage_bps }), + )] + } else { + Vec::new() + }; + let warning_codes = warnings + .iter() + .filter_map(|warning| warning["code"].as_str().map(String::from)) + .collect(); + let mut account_plan = active_account_plan( + input, + pair, + amm_program, + &pool, + stored_reversed, + AccountPlanHoldings { + token_a: holding_a.as_ref(), + token_b: holding_b.as_ref(), + lp: lp_holding.as_ref(), + }, + )?; + let sources = account_plan.take_sources(); + let commitment = QuoteCommitment { + schema: String::from(SCHEMA), + network_id: input.network_id.clone(), + network_fingerprint: input.network_fingerprint.clone(), + amm_program_id: program_id_bytes(amm_program), + token_a_id: pair.token_a.into_value(), + token_b_id: pair.token_b.into_value(), + fee_bps: input.request.fee_bps, + pool_status: 1, + request: RequestCommitment::Active { + max_a, + max_b, + slippage_bps, + }, + max_a, + max_b, + actual_a, + actual_b, + expected_lp, + lp_guard: minimum_lp, + requires_fresh_lp, + sources, + funding: funding_commitments(pair, &holding_a, actual_a, &holding_b, actual_b), + warnings: warning_codes, + }; + let quote_hash = hash_quote(&commitment)?; + let preview = account_plan.preview(); + let value = json!({ + "schema": SCHEMA, + "status": "ok", + "canSubmit": can_submit, + "code": if can_submit { "ready" } else { "funding_required" }, + "poolStatus": "active_pool", + "instruction": "AddLiquidity", + "quoteHash": quote_hash, + "feeBps": input.request.fee_bps, + "poolFeeBps": pool.fees.to_string(), + "poolId": pair.pool.to_string(), + "tokenAId": pair.token_a.to_string(), + "tokenBId": pair.token_b.to_string(), + "maxAmountARaw": max_a.to_string(), + "maxAmountBRaw": max_b.to_string(), + "actualAmountARaw": actual_a.to_string(), + "actualAmountBRaw": actual_b.to_string(), + "reserveARaw": reserve_a.to_string(), + "reserveBRaw": reserve_b.to_string(), + "liquiditySupplyRaw": pool.liquidity_pool_supply.to_string(), + "expectedLpRaw": expected_lp.to_string(), + "minimumLpRaw": minimum_lp.to_string(), + "initialPriceRealRaw": spot_price_q64_64(reserve_a, reserve_b).to_string(), + "requiresFreshLp": requires_fresh_lp, + "accountPreview": preview, + "errors": funding, + "warnings": warnings, + }); + let plan = if can_submit { + Some(NewPositionPlan::new( + account_plan, + QuoteBranch::Active { + max_a, + max_b, + minimum_lp, + stored_reversed, + }, + )?) + } else { + None + }; + Ok(QuoteComputation::Evaluated(EvaluatedQuote { + value, + quote_hash, + plan, + })) +} + +fn validate_active_accounts( + input: &QuoteRequest, + pair: PairIds, + pool: &PoolDefinition, + stored_reversed: bool, +) -> Option { + let expected_vault_a = if stored_reversed { + pair.vault_b + } else { + pair.vault_a + }; + let expected_vault_b = if stored_reversed { + pair.vault_a + } else { + pair.vault_b + }; + if pool.vault_a_id != expected_vault_a + || pool.vault_b_id != expected_vault_b + || pool.liquidity_pool_id != pair.lp_definition + { + return Some(fatal_quote( + "pool_unavailable", + &[], + json!({ "reason": "pool_account_mismatch" }), + )); + } + let (canonical_vault_a, canonical_vault_b) = ( + decode_holding(&input.snapshot.vault_a, pair.token_program, pair.token_a), + decode_holding(&input.snapshot.vault_b, pair.token_program, pair.token_b), + ); + let (Ok(vault_a_balance), Ok(vault_b_balance)) = (canonical_vault_a, canonical_vault_b) else { + return Some(fatal_quote( + "account_read_failed", + &[], + json!({ "role": "vault" }), + )); + }; + let (reserve_a, reserve_b) = if stored_reversed { + (pool.reserve_b, pool.reserve_a) + } else { + (pool.reserve_a, pool.reserve_b) + }; + if vault_a_balance < reserve_a || vault_b_balance < reserve_b { + return Some(fatal_quote( + "pool_unavailable", + &[], + json!({ "reason": "vault_below_reserve" }), + )); + } + let Ok((lp_id, lp_account)) = decode_account(&input.snapshot.lp_definition) else { + return Some(fatal_quote( + "account_read_failed", + &[], + json!({ "role": "lp_definition" }), + )); + }; + if lp_id != pair.lp_definition + || lp_account.program_owner != pair.token_program + || !matches!( + TokenDefinition::try_from(&lp_account.data), + Ok(TokenDefinition::Fungible { .. }) + ) + { + return Some(fatal_quote( + "pool_unavailable", + &[], + json!({ "reason": "invalid_lp_definition" }), + )); + } + let Ok((tick_id, tick_account)) = decode_account(&input.snapshot.current_tick) else { + return Some(fatal_quote( + "account_read_failed", + &[], + json!({ "role": "current_tick" }), + )); + }; + if tick_id != pair.current_tick + || tick_account.program_owner != pair.twap_program + || CurrentTickAccount::try_from(&tick_account.data).is_err() + { + return Some(fatal_quote( + "pool_unavailable", + &[], + json!({ "reason": "invalid_current_tick" }), + )); + } + if !valid_clock(&input.snapshot.clock, pair.clock) { + return Some(fatal_quote( + "account_read_failed", + &[], + json!({ "role": "clock" }), + )); + } + None +} + +fn decode_holding( + read: &AccountRead, + token_program: ProgramId, + definition_id: AccountId, +) -> Result { + let holding = decode_fungible_holding(read, token_program)?; + if holding.definition_id != definition_id { + return Err(String::from("invalid fungible holding")); + } + Ok(holding.balance) +} + +fn valid_clock(read: &AccountRead, expected_id: AccountId) -> bool { + matches!(decode_clock(read), Ok((id, _)) if id == expected_id) +} + +fn raw_value(value: Option<&str>) -> Result { + let Some(value) = value else { + return Err("amount_required"); + }; + if value.is_empty() { + return Err("amount_required"); + } + if !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err("invalid_raw_amount"); + } + value.parse().map_err(|_| "invalid_raw_amount") +} + +pub(super) fn minimum_opening_pair(price: u128) -> Result<(u128, u128), String> { + let minimum_initial_lp = U256::from(MINIMUM_LIQUIDITY + 1); + let target_product = minimum_initial_lp + .checked_mul(minimum_initial_lp) + .ok_or_else(|| String::from("minimum liquidity product overflow"))?; + if price >= Q64 { + let amount_a = binary_search_min(1, MINIMUM_LIQUIDITY + 1, |amount_a| { + let amount_b = div_ceil_u256(U256::from(amount_a) * U256::from(price), U256::from(Q64)); + U256::from(amount_a) * amount_b >= target_product + }); + let amount_b = div_ceil_u256(U256::from(amount_a) * U256::from(price), U256::from(Q64)); + Ok(( + amount_a, + u128::try_from(amount_b).map_err(|_| String::from("opening amount overflow"))?, + )) + } else { + let amount_b = binary_search_min(1, MINIMUM_LIQUIDITY + 1, |amount_b| { + let amount_a = div_ceil_u256(U256::from(amount_b) * U256::from(Q64), U256::from(price)); + amount_a * U256::from(amount_b) >= target_product + }); + let amount_a = div_ceil_u256(U256::from(amount_b) * U256::from(Q64), U256::from(price)); + Ok(( + u128::try_from(amount_a).map_err(|_| String::from("opening amount overflow"))?, + amount_b, + )) + } +} + +fn binary_search_min(mut low: u128, mut high: u128, predicate: impl Fn(u128) -> bool) -> u128 { + while low < high { + let mid = low + (high - low) / 2; + if predicate(mid) { + high = mid; + } else { + low = mid + 1; + } + } + low +} + +pub(super) fn div_ceil_u256(numerator: U256, denominator: U256) -> U256 { + numerator.div_ceil(denominator) +} diff --git a/apps/amm/client/src/api/quote_error.rs b/apps/amm/client/src/api/quote_error.rs new file mode 100644 index 00000000..df10ffd7 --- /dev/null +++ b/apps/amm/client/src/api/quote_error.rs @@ -0,0 +1,25 @@ +use serde_json::{json, Value}; + +use super::position::{QuoteComputation, QuoteFailure}; + +pub(super) fn issue(code: &str, message: &str, fields: &[&str], details: Value) -> Value { + json!({ + "code": code, + "message": message, + "details": details, + "recoverable": true, + "blockingFields": fields, + }) +} + +pub(super) fn fatal_quote( + code: &'static str, + fields: &[&'static str], + details: Value, +) -> QuoteComputation { + QuoteComputation::Failed(QuoteFailure { + code, + fields: fields.to_vec(), + details, + }) +} diff --git a/apps/amm/client/src/api/request.rs b/apps/amm/client/src/api/request.rs new file mode 100644 index 00000000..082b143d --- /dev/null +++ b/apps/amm/client/src/api/request.rs @@ -0,0 +1,116 @@ +use serde::Deserialize; + +use crate::account::AccountRead; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ConfigIdRequest { + pub amm_program_id: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct TokenIdsRequest { + pub amm_program_id: String, + pub config: AccountRead, + #[serde(default)] + pub wallet_accounts: Vec, + #[serde(default)] + pub configured_token_ids: Vec, + #[serde(default)] + pub recent_token_ids: Vec, + #[serde(default)] + pub resolved_token_ids: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ContextRequest { + pub network_id: String, + pub network_fingerprint: String, + pub amm_program_id: String, + pub wallet_available: bool, + pub config: AccountRead, + #[serde(default)] + pub wallet_accounts: Vec, + #[serde(default)] + pub token_definitions: Vec, + #[serde(default)] + pub configured_token_ids: Vec, + #[serde(default)] + pub recent_token_ids: Vec, + #[serde(default)] + pub resolved_token_ids: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PairIdsRequest { + pub amm_program_id: String, + pub config: AccountRead, + pub token_a_id: String, + pub token_b_id: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PositionRequest { + pub schema: String, + pub token_a_id: String, + pub token_b_id: String, + pub fee_bps: u32, + #[serde(default)] + pub amount_a_raw: Option, + #[serde(default)] + pub amount_b_raw: Option, + #[serde(default)] + pub max_amount_a_raw: Option, + #[serde(default)] + pub max_amount_b_raw: Option, + #[serde(default)] + pub slippage_bps: Option, + #[serde(default)] + pub initial_price_real_raw: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PairSnapshot { + pub config: AccountRead, + pub token_a: AccountRead, + pub token_b: AccountRead, + pub pool: AccountRead, + pub vault_a: AccountRead, + pub vault_b: AccountRead, + pub lp_definition: AccountRead, + pub lp_lock_holding: AccountRead, + pub current_tick: AccountRead, + pub clock: AccountRead, + pub wallet_available: bool, + #[serde(default)] + pub wallet_accounts: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct QuoteRequest { + pub network_id: String, + pub network_fingerprint: String, + pub amm_program_id: String, + pub request: PositionRequest, + pub snapshot: PairSnapshot, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PlanRequest { + pub network_id: String, + pub network_fingerprint: String, + pub amm_program_id: String, + pub request: PositionRequest, + pub snapshot: PairSnapshot, + pub quote_hash: String, + pub now_ms: u64, + #[serde(default)] + pub fresh_lp: Option, +} diff --git a/apps/amm/client/src/api/tests.rs b/apps/amm/client/src/api/tests.rs new file mode 100644 index 00000000..520899da --- /dev/null +++ b/apps/amm/client/src/api/tests.rs @@ -0,0 +1,703 @@ +use alloy_primitives::U256; +use amm_core::{ + compute_config_pda, compute_liquidity_token_pda, compute_lp_lock_holding_pda, compute_pool_pda, + compute_vault_pda, isqrt_product, spot_price_q64_64, AmmConfig, PoolDefinition, + MINIMUM_LIQUIDITY, +}; +use clock_core::{ClockAccountData, CLOCK_01_PROGRAM_ACCOUNT_ID}; +use nssa_core::{ + account::{Account, AccountId, Data, Nonce}, + program::ProgramId, +}; +use pretty_assertions::assert_eq; +use serde_json::{json, Value}; +use token_core::{TokenDefinition, TokenHolding}; +use twap_oracle_core::{compute_current_tick_account_pda, CurrentTickAccount}; + +use super::{ + accounts::{active_account_plan, missing_account_plan}, + context::{context, token_ids}, + holding::{select_holding, wallet_holdings, SelectedHolding}, + pair::{is_canonical_pair, pair_ids, PairIds}, + plan::plan, + position::AccountPlanHoldings, + quote::{div_ceil_u256, minimum_opening_pair, quote, Q64}, + ContextRequest, PairIdsRequest, PairSnapshot, PlanRequest, PositionRequest, QuoteRequest, + TokenIdsRequest, SCHEMA, +}; +use crate::{ + account::{account_id_hex, account_read, decode_account, parse_base58_id, program_id_bytes}, + AccountRead, +}; + +const AMM_PROGRAM: ProgramId = [11; 8]; +const TOKEN_PROGRAM: ProgramId = [22; 8]; +const TWAP_PROGRAM: ProgramId = [33; 8]; + +fn account(owner: ProgramId, data: Data) -> Account { + Account { + program_owner: owner, + balance: 0, + data, + nonce: Nonce(0), + } +} + +fn default_read(id: AccountId) -> AccountRead { + account_read(id, &Account::default()) +} + +fn config_account() -> Account { + account( + AMM_PROGRAM, + Data::from(&AmmConfig { + token_program_id: TOKEN_PROGRAM, + twap_oracle_program_id: TWAP_PROGRAM, + authority: AccountId::new([7; 32]), + }), + ) +} + +fn token_definition(name: &str, supply: u128) -> Account { + account( + TOKEN_PROGRAM, + Data::from(&TokenDefinition::Fungible { + name: String::from(name), + total_supply: supply, + metadata_id: None, + authority: None, + }), + ) +} + +fn token_holding(definition_id: AccountId, balance: u128) -> Account { + account( + TOKEN_PROGRAM, + Data::from(&TokenHolding::Fungible { + definition_id, + balance, + }), + ) +} + +fn clock_account() -> Account { + account( + [44; 8], + Data::try_from( + ClockAccountData { + block_id: 10, + timestamp: 1_000, + } + .to_bytes(), + ) + .unwrap(), + ) +} + +fn ids() -> PairIds { + let token_a = AccountId::new([2; 32]); + let token_b = AccountId::new([1; 32]); + let config = compute_config_pda(AMM_PROGRAM); + let pool = compute_pool_pda(AMM_PROGRAM, token_a, token_b); + PairIds { + token_a, + token_b, + config, + pool, + vault_a: compute_vault_pda(AMM_PROGRAM, pool, token_a), + vault_b: compute_vault_pda(AMM_PROGRAM, pool, token_b), + lp_definition: compute_liquidity_token_pda(AMM_PROGRAM, pool), + lp_lock_holding: compute_lp_lock_holding_pda(AMM_PROGRAM, pool), + current_tick: compute_current_tick_account_pda(TWAP_PROGRAM, pool), + clock: CLOCK_01_PROGRAM_ACCOUNT_ID, + token_program: TOKEN_PROGRAM, + twap_program: TWAP_PROGRAM, + } +} + +fn base_snapshot(pair: PairIds) -> PairSnapshot { + let holding_a_id = AccountId::new([61; 32]); + let holding_b_id = AccountId::new([62; 32]); + PairSnapshot { + config: account_read(pair.config, &config_account()), + token_a: account_read(pair.token_a, &token_definition("A", 1_000_000)), + token_b: account_read(pair.token_b, &token_definition("B", 2_000_000)), + pool: default_read(pair.pool), + vault_a: default_read(pair.vault_a), + vault_b: default_read(pair.vault_b), + lp_definition: default_read(pair.lp_definition), + lp_lock_holding: default_read(pair.lp_lock_holding), + current_tick: default_read(pair.current_tick), + clock: account_read(pair.clock, &clock_account()), + wallet_available: true, + wallet_accounts: vec![ + account_read(holding_a_id, &token_holding(pair.token_a, 1_000_000)), + account_read(holding_b_id, &token_holding(pair.token_b, 1_000_000)), + ], + } +} + +fn request(pair: PairIds) -> PositionRequest { + assert!(is_canonical_pair(pair.token_a, pair.token_b)); + PositionRequest { + schema: String::from(SCHEMA), + token_a_id: pair.token_a.to_string(), + token_b_id: pair.token_b.to_string(), + fee_bps: 30, + amount_a_raw: None, + amount_b_raw: None, + max_amount_a_raw: None, + max_amount_b_raw: None, + slippage_bps: None, + initial_price_real_raw: Some(Q64.to_string()), + } +} + +fn amm_program_id() -> String { + hex::encode(program_id_bytes(AMM_PROGRAM)) +} + +struct Scenario { + pair: PairIds, + request: PositionRequest, + snapshot: PairSnapshot, + network_id: &'static str, + network_fingerprint: &'static str, +} + +impl Scenario { + fn devnet() -> Self { + Self::new("devnet", "channel:test") + } + + fn testnet() -> Self { + Self::new("testnet", "block10:test") + } + + fn new(network_id: &'static str, network_fingerprint: &'static str) -> Self { + let pair = ids(); + Self { + pair, + request: request(pair), + snapshot: base_snapshot(pair), + network_id, + network_fingerprint, + } + } + + fn quote_request(&self) -> QuoteRequest { + QuoteRequest { + network_id: String::from(self.network_id), + network_fingerprint: String::from(self.network_fingerprint), + amm_program_id: amm_program_id(), + request: self.request.clone(), + snapshot: self.snapshot.clone(), + } + } + + fn quote(&self) -> Value { + quote(self.quote_request()).unwrap() + } + + fn plan(self, quote_hash: impl Into, fresh_lp: Option) -> Value { + plan(PlanRequest { + network_id: String::from(self.network_id), + network_fingerprint: String::from(self.network_fingerprint), + amm_program_id: amm_program_id(), + request: self.request, + snapshot: self.snapshot, + quote_hash: quote_hash.into(), + now_ms: 2_000, + fresh_lp, + }) + .unwrap() + } +} + +fn assert_preview_matches_plan( + quote_value: &Value, + plan_value: &Value, + fresh_lp: Option, +) { + let preview = quote_value["accountPreview"].as_array().unwrap(); + let account_ids = plan_value["accountIds"].as_array().unwrap(); + let signing_requirements = plan_value["signingRequirements"].as_array().unwrap(); + assert_eq!(preview.len(), account_ids.len()); + assert_eq!(preview.len(), signing_requirements.len()); + + for (order, row) in preview.iter().enumerate() { + assert_eq!(row["order"], order); + assert_eq!(row["signer"], signing_requirements[order]); + if let Some(account_id) = row["accountId"].as_str() { + let account_id = parse_base58_id(account_id, "preview account id").unwrap(); + assert_eq!(account_ids[order], account_id_hex(account_id)); + } else { + assert_eq!(row["role"], "user_holding_lp"); + assert_eq!(account_ids[order], account_id_hex(fresh_lp.unwrap())); + } + } +} + +#[test] +fn account_plan_sources_follow_pool_branch() { + let scenario = Scenario::devnet(); + let pair = scenario.pair; + let input = scenario.quote_request(); + let holdings = wallet_holdings(&input.snapshot.wallet_accounts, pair.token_program); + let holding_a = select_holding(&holdings, pair.token_a); + let holding_b = select_holding(&holdings, pair.token_b); + + let missing = missing_account_plan( + &input, + pair, + AMM_PROGRAM, + AccountPlanHoldings { + token_a: holding_a.as_ref(), + token_b: holding_b.as_ref(), + lp: None, + }, + ) + .unwrap(); + assert_eq!( + missing + .sources + .iter() + .map(|source| source.role.as_str()) + .collect::>(), + vec![ + "config", + "token_a", + "token_b", + "pool", + "vault_a", + "vault_b", + "lp_definition", + "lp_lock_holding", + "current_tick", + "holding_a", + "holding_b", + ] + ); + + let active = active_account_plan( + &input, + pair, + AMM_PROGRAM, + &PoolDefinition::default(), + false, + AccountPlanHoldings { + token_a: holding_a.as_ref(), + token_b: holding_b.as_ref(), + lp: None, + }, + ) + .unwrap(); + assert_eq!( + active + .sources + .iter() + .map(|source| source.role.as_str()) + .collect::>(), + vec![ + "config", + "token_a", + "token_b", + "pool", + "vault_a", + "vault_b", + "lp_definition", + "current_tick", + "holding_a", + "holding_b", + ] + ); +} + +#[test] +fn minimum_pair_exceeds_protocol_lock() { + for price in [1, Q64 / 2_500, Q64 / 10, Q64, Q64 * 2, u128::MAX] { + let (amount_a, amount_b) = minimum_opening_pair(price).unwrap(); + assert!(amount_a > 0); + assert!(amount_b > 0); + assert!(isqrt_product(amount_a, amount_b) > MINIMUM_LIQUIDITY); + } +} + +#[test] +fn minimum_pair_is_minimal_on_price_base_side() { + let (amount_a, amount_b) = minimum_opening_pair(Q64 * 2).unwrap(); + assert!(isqrt_product(amount_a, amount_b) > MINIMUM_LIQUIDITY); + let previous_b = div_ceil_u256( + U256::from(amount_a - 1) * U256::from(Q64 * 2), + U256::from(Q64), + ); + assert!( + U256::from(amount_a - 1) * previous_b <= U256::from(MINIMUM_LIQUIDITY * MINIMUM_LIQUIDITY) + ); +} + +#[test] +fn highest_balance_holding_wins_then_lowest_id() { + let definition = AccountId::new([9; 32]); + let holding = |id: u8, balance| SelectedHolding { + id: AccountId::new([id; 32]), + definition_id: definition, + balance, + account: account( + TOKEN_PROGRAM, + Data::from(&TokenHolding::Fungible { + definition_id: definition, + balance, + }), + ), + }; + let selected = select_holding( + &[holding(4, 10), holding(2, 20), holding(1, 20)], + definition, + ) + .unwrap(); + assert_eq!(selected.id, AccountId::new([1; 32])); +} + +#[test] +fn pair_manifest_uses_canonical_ids_and_current_program_types() { + let token_a = AccountId::new([2; 32]); + let token_b = AccountId::new([1; 32]); + let config_id = compute_config_pda(AMM_PROGRAM); + let result = pair_ids(PairIdsRequest { + amm_program_id: amm_program_id(), + config: account_read(config_id, &config_account()), + token_a_id: token_a.to_string(), + token_b_id: token_b.to_string(), + }) + .unwrap(); + assert_eq!(result["status"], "ok"); + assert_eq!(result["tokenAId"], account_id_hex(token_a)); + assert_eq!(result["tokenBId"], account_id_hex(token_b)); + assert_eq!( + result["poolId"], + account_id_hex(compute_pool_pda(AMM_PROGRAM, token_a, token_b)) + ); +} + +#[test] +fn pair_manifest_reports_invalid_token_as_domain_error() { + let pair = ids(); + let result = pair_ids(PairIdsRequest { + amm_program_id: amm_program_id(), + config: account_read(pair.config, &config_account()), + token_a_id: String::from("not-a-token-id"), + token_b_id: pair.token_b.to_string(), + }) + .expect("invalid user input is a domain result"); + + assert_eq!(result["status"], "error"); + assert_eq!(result["code"], "invalid_token_id"); +} + +#[test] +fn pair_manifest_reports_unavailable_config_as_domain_error() { + let pair = ids(); + let result = pair_ids(PairIdsRequest { + amm_program_id: amm_program_id(), + config: default_read(pair.config), + token_a_id: pair.token_a.to_string(), + token_b_id: pair.token_b.to_string(), + }) + .expect("unavailable chain state is a domain result"); + + assert_eq!(result["status"], "error"); + assert_eq!(result["code"], "config_unavailable"); +} + +#[test] +fn token_manifest_includes_compatible_wallet_holdings() { + let config_id = compute_config_pda(AMM_PROGRAM); + let configured = AccountId::new([1; 32]); + let held = AccountId::new([2; 32]); + let recent = AccountId::new([3; 32]); + let resolved = AccountId::new([4; 32]); + + let value = token_ids(TokenIdsRequest { + amm_program_id: amm_program_id(), + config: account_read(config_id, &config_account()), + wallet_accounts: vec![account_read( + AccountId::new([5; 32]), + &token_holding(held, 9), + )], + configured_token_ids: vec![account_id_hex(configured)], + recent_token_ids: vec![recent.to_string()], + resolved_token_ids: vec![resolved.to_string()], + }) + .unwrap(); + + assert_eq!( + value["tokenIds"], + json!([ + account_id_hex(configured), + account_id_hex(held), + account_id_hex(recent), + account_id_hex(resolved), + ]) + ); +} + +#[test] +fn wrong_program_holdings_do_not_contribute_token_candidates() { + let config_id = compute_config_pda(AMM_PROGRAM); + let config = config_account(); + let definition = AccountId::new([2; 32]); + let wrong_owner_holding = account( + [99; 8], + Data::from(&TokenHolding::Fungible { + definition_id: definition, + balance: 9, + }), + ); + let wallet_accounts = vec![account_read(AccountId::new([3; 32]), &wrong_owner_holding)]; + + let manifest = token_ids(TokenIdsRequest { + amm_program_id: amm_program_id(), + config: account_read(config_id, &config), + wallet_accounts: wallet_accounts.clone(), + configured_token_ids: Vec::new(), + recent_token_ids: Vec::new(), + resolved_token_ids: Vec::new(), + }) + .unwrap(); + assert_eq!(manifest["tokenIds"], json!([])); + + let value = context(ContextRequest { + network_id: String::from("testnet"), + network_fingerprint: String::from("block10:abc"), + amm_program_id: amm_program_id(), + wallet_available: true, + config: account_read(config_id, &config), + wallet_accounts, + token_definitions: vec![account_read( + definition, + &token_definition("Token", 1_000_000), + )], + configured_token_ids: Vec::new(), + recent_token_ids: Vec::new(), + resolved_token_ids: Vec::new(), + }) + .unwrap(); + assert_eq!(value["tokens"], json!([])); +} + +#[test] +fn context_selects_tokens_without_holdings() { + let token_id = AccountId::new([3; 32]); + let config_id = compute_config_pda(AMM_PROGRAM); + let value = context(ContextRequest { + network_id: String::from("testnet"), + network_fingerprint: String::from("block10:abc"), + amm_program_id: amm_program_id(), + wallet_available: true, + config: account_read(config_id, &config_account()), + wallet_accounts: Vec::new(), + token_definitions: vec![account_read( + token_id, + &token_definition("Token", 1_000_000), + )], + configured_token_ids: vec![account_id_hex(token_id)], + recent_token_ids: Vec::new(), + resolved_token_ids: Vec::new(), + }) + .unwrap(); + assert_eq!(value["tokens"][0]["selectable"], true); + assert_eq!(value["tokens"][0]["sources"], json!(["config"])); + assert!(value["tokens"][0].get("holdingId").is_none()); +} + +#[test] +fn missing_pool_snapshot_defaults_remain_real_accounts() { + let id = AccountId::new([5; 32]); + let read = default_read(id); + let (decoded_id, decoded) = decode_account(&read).unwrap(); + assert_eq!(decoded_id, id); + assert_eq!(decoded, Account::default()); +} + +#[test] +fn missing_pool_quote_and_plan_use_current_account_order() { + let scenario = Scenario::devnet(); + let quote_value = scenario.quote(); + assert_eq!(quote_value["status"], "ok"); + assert_eq!(quote_value["poolStatus"], "missing_pool"); + assert_eq!(quote_value["canSubmit"], true); + assert_eq!(quote_value["accountPreview"].as_array().unwrap().len(), 11); + let quote_hash = quote_value["quoteHash"].as_str().unwrap().to_owned(); + + let fresh_lp = AccountId::new([63; 32]); + let plan_value = scenario.plan(quote_hash, Some(default_read(fresh_lp))); + assert_eq!(plan_value["status"], "ready"); + assert_eq!(plan_value["accountIds"].as_array().unwrap().len(), 11); + assert_eq!(plan_value["accountIds"][8], account_id_hex(fresh_lp)); + assert_eq!(plan_value["signingRequirements"][6], true); + assert_eq!(plan_value["signingRequirements"][7], true); + assert_eq!(plan_value["signingRequirements"][8], true); + assert_preview_matches_plan("e_value, &plan_value, Some(fresh_lp)); +} + +#[test] +fn missing_pool_plan_rejects_fresh_lp_account_collision() { + let scenario = Scenario::devnet(); + let pool = scenario.pair.pool; + let quote_value = scenario.quote(); + let quote_hash = quote_value["quoteHash"].as_str().unwrap().to_owned(); + + let plan_value = scenario.plan(quote_hash, Some(default_read(pool))); + + assert_eq!(plan_value["status"], "error"); + assert_eq!(plan_value["code"], "wallet_submission_failed"); +} + +#[test] +fn missing_pool_quote_accepts_large_direct_raw_amounts() { + let mut scenario = Scenario::devnet(); + let amount_a = 100_000_000; + let amount_b = 150_000_000; + scenario.request.amount_a_raw = Some(amount_a.to_string()); + scenario.request.amount_b_raw = Some(amount_b.to_string()); + scenario.request.initial_price_real_raw = + Some(spot_price_q64_64(amount_a, amount_b).to_string()); + + let quote_value = scenario.quote(); + + assert_eq!(quote_value["status"], "ok"); + assert_eq!(quote_value["actualAmountARaw"], amount_a.to_string()); + assert_eq!(quote_value["actualAmountBRaw"], amount_b.to_string()); + assert!(quote_value.get("depositScaleBps").is_none()); +} + +#[test] +fn advancing_clock_does_not_stale_quote() { + let mut scenario = Scenario::testnet(); + let quote_value = scenario.quote(); + + scenario.snapshot.clock = account_read( + scenario.pair.clock, + &account( + [44; 8], + Data::try_from( + ClockAccountData { + block_id: 11, + timestamp: 1_500, + } + .to_bytes(), + ) + .unwrap(), + ), + ); + let plan_value = scenario.plan( + quote_value["quoteHash"].as_str().unwrap(), + Some(default_read(AccountId::new([63; 32]))), + ); + + assert_eq!(plan_value["status"], "ready"); +} + +#[test] +fn active_pool_quote_uses_ratio_and_existing_lp_holding() { + let mut scenario = Scenario::testnet(); + let pair = scenario.pair; + let pool = PoolDefinition { + definition_token_a_id: pair.token_a, + definition_token_b_id: pair.token_b, + vault_a_id: pair.vault_a, + vault_b_id: pair.vault_b, + liquidity_pool_id: pair.lp_definition, + liquidity_pool_supply: 10_000, + reserve_a: 10_000, + reserve_b: 20_000, + fees: 30, + }; + scenario.snapshot.pool = account_read(pair.pool, &account(AMM_PROGRAM, Data::from(&pool))); + scenario.snapshot.vault_a = + account_read(pair.vault_a, &token_holding(pair.token_a, pool.reserve_a)); + scenario.snapshot.vault_b = + account_read(pair.vault_b, &token_holding(pair.token_b, pool.reserve_b)); + scenario.snapshot.lp_definition = account_read( + pair.lp_definition, + &account( + TOKEN_PROGRAM, + Data::from(&TokenDefinition::Fungible { + name: String::from("LP"), + total_supply: pool.liquidity_pool_supply, + metadata_id: None, + authority: Some(pair.lp_definition), + }), + ), + ); + scenario.snapshot.current_tick = account_read( + pair.current_tick, + &account( + TWAP_PROGRAM, + Data::from(&CurrentTickAccount { + tick: 0, + last_updated: 1_000, + }), + ), + ); + scenario.snapshot.wallet_accounts = vec![ + account_read( + AccountId::new([61; 32]), + &token_holding(pair.token_a, 1_000), + ), + account_read( + AccountId::new([62; 32]), + &token_holding(pair.token_b, 2_000), + ), + ]; + let lp_holding = AccountId::new([64; 32]); + scenario.snapshot.wallet_accounts.push(account_read( + lp_holding, + &token_holding(pair.lp_definition, 500), + )); + scenario.request.initial_price_real_raw = None; + scenario.request.max_amount_a_raw = Some(String::from("1000")); + scenario.request.max_amount_b_raw = Some(String::from("3000")); + scenario.request.slippage_bps = Some(50); + + let quote_value = scenario.quote(); + assert_eq!(quote_value["poolStatus"], "active_pool"); + assert_eq!(quote_value["actualAmountARaw"], "1000"); + assert_eq!(quote_value["actualAmountBRaw"], "2000"); + assert_eq!(quote_value["expectedLpRaw"], "1000"); + assert_eq!(quote_value["minimumLpRaw"], "995"); + assert_eq!(quote_value["requiresFreshLp"], false); + assert_eq!(quote_value["canSubmit"], true); + assert_eq!(quote_value["errors"], json!([])); + + let plan_value = scenario.plan(quote_value["quoteHash"].as_str().unwrap(), None); + assert_eq!(plan_value["status"], "ready"); + assert_eq!(plan_value["accountIds"].as_array().unwrap().len(), 10); + assert_eq!(plan_value["accountIds"][7], account_id_hex(lp_holding)); + assert_eq!(plan_value["signingRequirements"][7], false); + assert_preview_matches_plan("e_value, &plan_value, None); +} + +#[test] +fn matching_unfunded_quote_has_no_transaction_plan() { + let mut scenario = Scenario::devnet(); + scenario.snapshot.wallet_available = false; + scenario.snapshot.wallet_accounts.clear(); + let quote_value = scenario.quote(); + assert_eq!(quote_value["canSubmit"], false); + + let plan_value = scenario.plan(quote_value["quoteHash"].as_str().unwrap(), None); + + assert_eq!(plan_value["status"], "error"); + assert_eq!(plan_value["code"], "quote_not_submittable"); + assert_eq!(plan_value["quote"], quote_value); +} + +#[test] +fn stale_hash_returns_recomputed_quote_without_plan() { + let value = Scenario::devnet().plan("sha256:deadbeef", None); + assert_eq!(value["status"], "error"); + assert_eq!(value["code"], "quote_changed"); + assert_eq!(value["quote"]["status"], "ok"); +} diff --git a/apps/amm/client/src/ffi.rs b/apps/amm/client/src/ffi.rs new file mode 100644 index 00000000..552e9508 --- /dev/null +++ b/apps/amm/client/src/ffi.rs @@ -0,0 +1,140 @@ +use std::{ + ffi::{c_char, CStr, CString}, + panic::{catch_unwind, AssertUnwindSafe}, +}; + +use serde::{de::DeserializeOwned, Serialize}; + +use crate::api::{ + self, AmmApiError, AmmResult, ConfigIdRequest, ContextRequest, PairIdsRequest, PlanRequest, + QuoteRequest, TokenIdsRequest, +}; + +#[derive(Serialize)] +struct Envelope { + ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +impl Envelope { + fn success(value: serde_json::Value) -> Self { + Self { + ok: true, + value: Some(value), + error: None, + } + } + + fn failure(error: impl Into) -> Self { + Self { + ok: false, + value: None, + error: Some(error.into()), + } + } +} + +fn call(request: *const c_char, operation: fn(T) -> AmmResult) -> *mut c_char { + let result = catch_unwind(AssertUnwindSafe(|| { + let request = request_text(request).map_err(AmmApiError::from)?; + let request = serde_json::from_str::(&request) + .map_err(|error| AmmApiError::from(format!("invalid request JSON: {error}")))?; + operation(request) + })); + + let envelope = match result { + Ok(Ok(value)) => Envelope::success(value), + Ok(Err(error)) => Envelope::failure(error.to_string()), + Err(_) => Envelope::failure("internal panic"), + }; + encode_envelope(&envelope) +} + +fn request_text(request: *const c_char) -> Result { + if request.is_null() { + return Err(String::from("request pointer is null")); + } + // SAFETY: The C++ caller passes a live NUL-terminated UTF-8 buffer for this call. + let request = unsafe { CStr::from_ptr(request) }; + request + .to_str() + .map(String::from) + .map_err(|error| format!("request is not UTF-8: {error}")) +} + +fn encode_envelope(envelope: &Envelope) -> *mut c_char { + let json = serde_json::to_string(envelope).unwrap_or_else(|_| { + String::from(r#"{"ok":false,"error":"response serialization failed"}"#) + }); + match CString::new(json) { + Ok(value) => value.into_raw(), + Err(_) => CString::new(r#"{"ok":false,"error":"response contains NUL"}"#) + .map_or(std::ptr::null_mut(), CString::into_raw), + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn amm_config_id(request_json: *const c_char) -> *mut c_char { + call::(request_json, api::config_id) +} + +#[unsafe(no_mangle)] +pub extern "C" fn amm_token_ids(request_json: *const c_char) -> *mut c_char { + call::(request_json, api::token_ids) +} + +#[unsafe(no_mangle)] +pub extern "C" fn amm_pair_ids(request_json: *const c_char) -> *mut c_char { + call::(request_json, api::pair_ids) +} + +#[unsafe(no_mangle)] +pub extern "C" fn amm_context(request_json: *const c_char) -> *mut c_char { + call::(request_json, api::context) +} + +#[unsafe(no_mangle)] +pub extern "C" fn amm_quote(request_json: *const c_char) -> *mut c_char { + call::(request_json, api::quote) +} + +#[unsafe(no_mangle)] +pub extern "C" fn amm_plan(request_json: *const c_char) -> *mut c_char { + call::(request_json, api::plan) +} + +/// Releases a string returned by an `amm_*` operation. +/// +/// # Safety +/// `value` must be null or a pointer returned by this library that has not been freed. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn amm_free(value: *mut c_char) { + if value.is_null() { + return; + } + // SAFETY: The caller contract requires a pointer produced by CString::into_raw above. + drop(unsafe { CString::from_raw(value) }); +} + +#[cfg(test)] +mod tests { + use std::ffi::CString; + + use super::*; + + #[test] + fn malformed_json_uses_boundary_failure_envelope() { + let request = CString::new("{").unwrap(); + let response = amm_config_id(request.as_ptr()); + assert!(!response.is_null()); + // SAFETY: response was returned by amm_config_id and remains live until amm_free. + let text = unsafe { CStr::from_ptr(response) }.to_str().unwrap(); + let value: serde_json::Value = serde_json::from_str(text).unwrap(); + assert_eq!(value["ok"], false); + // SAFETY: response was allocated by this library and has not been freed. + unsafe { amm_free(response) }; + } +} diff --git a/apps/amm/client/src/lib.rs b/apps/amm/client/src/lib.rs new file mode 100644 index 00000000..9b407f7b --- /dev/null +++ b/apps/amm/client/src/lib.rs @@ -0,0 +1,12 @@ +#![deny(unsafe_op_in_unsafe_fn)] + +mod account; +mod ffi; + +pub mod api; + +pub use api::{ + config_id, context, pair_ids, plan, quote, token_ids, AccountRead, AmmApiError, AmmResponse, + AmmResult, ConfigIdRequest, ContextRequest, PairIdsRequest, PairSnapshot, PlanRequest, + PositionRequest, QuoteRequest, TokenIdsRequest, WalletAccount, NEW_POSITION_SCHEMA, +}; diff --git a/apps/amm/client/tests/public_api.rs b/apps/amm/client/tests/public_api.rs new file mode 100644 index 00000000..7d38289d --- /dev/null +++ b/apps/amm/client/tests/public_api.rs @@ -0,0 +1,13 @@ +use amm_client::{config_id, ConfigIdRequest, NEW_POSITION_SCHEMA}; + +#[test] +fn direct_rust_api_does_not_require_ffi() { + let response = config_id(ConfigIdRequest { + amm_program_id: "0000000000000000000000000000000000000000000000000000000000000000".into(), + }) + .expect("valid program ID should produce a response"); + + assert_eq!(response["status"], "ok"); + assert!(response["configId"].is_string()); + assert_eq!(NEW_POSITION_SCHEMA, "new-position.v1"); +} diff --git a/apps/amm/config/networks.json b/apps/amm/config/networks.json new file mode 100644 index 00000000..75e03eba --- /dev/null +++ b/apps/amm/config/networks.json @@ -0,0 +1,18 @@ +{ + "testnet": { + "checkpointHash": "0d25d71fca70d7008a892f6b3f768a4c66badbcd64e67d79ca595b92f1db544a", + "ammProgramId": "77eeaa23668ad2675fb768cd7ecb1893387be464b9a51f16756006c1d307db07", + "tokenDefinitionIds": [ + "7b464ff9dd0d3bc07f7e2e0b0667ccd066d85ad12be4c79fc55687a863910aa6", + "48c81cf032e601ca367fc9816b957dbf5c0e4c11cf7008e8f4581ec1a67aab42", + "159caef810ea545951b3bd913efe625ee45008c80865c330e72a72ed48b61649", + "75f33110b185717209e3955f228d4a4448801d0ce8ba438a4a268050eeff3f44", + "fbd107ca4bb66bc58f59ac2d32a759be3ee0fb453f8fecd1991c11837d9660c7", + "5547fcb72644d95a385d313b887a96be41ff263bce6150b49fd87276839822bf", + "fa43e74a97d79c5f907ff3edabda5ad89bfbd3b0922572e675d4ad3c7b6029c7", + "4f3231d8a01e1d79f163bc27fce0c860a4a2f6890280e9d135eafbde0d68ed79", + "fa32f354408857006f8ea396b0419823bd04436eadb2d273d2618a46b4793ed8", + "00fe99e4fbd4c71f92e47c384c6235244c8cce39b6d6367e1e338eca0ffe01cb" + ] + } +} diff --git a/apps/amm/flake.lock b/apps/amm/flake.lock index 8f79e67e..f5272d3d 100644 --- a/apps/amm/flake.lock +++ b/apps/amm/flake.lock @@ -1,6 +1,36 @@ { "nodes": { + "amm_client": { + "inputs": { + "crane": "crane", + "nixpkgs": "nixpkgs" + }, + "locked": { + "path": "../..", + "type": "path" + }, + "original": { + "path": "../..", + "type": "path" + }, + "parent": [] + }, "crane": { + "locked": { + "lastModified": 1783203018, + "narHash": "sha256-G6R9IT/xwFuu+CYBWDUAok6AdC4ERC4ZfPPFtEpxnZE=", + "owner": "ipetkov", + "repo": "crane", + "rev": "80db5bdc391be8a1794f6d8a2d56e3a84ebcede2", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, + "crane_2": { "locked": { "lastModified": 1780532242, "narHash": "sha256-D+BsdpxmtUwtqGoY0IXPhHgTlmqgcZKCEo1oMyn7ep0=", @@ -76,7 +106,7 @@ }, "logos-blockchain-circuits": { "inputs": { - "nixpkgs": "nixpkgs_124" + "nixpkgs": "nixpkgs_125" }, "locked": { "lastModified": 1781004244, @@ -2225,7 +2255,7 @@ }, "logos-execution-zone": { "inputs": { - "crane": "crane", + "crane": "crane_2", "logos-blockchain-circuits": "logos-blockchain-circuits", "logos-liblogos": "logos-liblogos_4", "nixpkgs": [ @@ -2238,17 +2268,17 @@ "rust-rapidsnark": "rust-rapidsnark" }, "locked": { - "lastModified": 1784202021, - "narHash": "sha256-BG94Ob5tbJyw7GclNBdnOWqcBLRld8sc+xeOU8LI5RQ=", + "lastModified": 1782428741, + "narHash": "sha256-ltLcysXUdVUXAe25Tl8x7e7ZsTzj1sHlyS3glp97TAo=", "owner": "logos-blockchain", "repo": "logos-execution-zone", - "rev": "a7e06a660940a00093b1760560d37ff84aff5a05", + "rev": "e37876a64028a335eb693198a1ed6a0e875ec5b4", "type": "github" }, "original": { "owner": "logos-blockchain", "repo": "logos-execution-zone", - "rev": "a7e06a660940a00093b1760560d37ff84aff5a05", + "rev": "e37876a64028a335eb693198a1ed6a0e875ec5b4", "type": "github" } }, @@ -4763,7 +4793,7 @@ }, "logos-nix": { "inputs": { - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs_2" }, "locked": { "lastModified": 1774455309, @@ -4781,7 +4811,7 @@ }, "logos-nix_10": { "inputs": { - "nixpkgs": "nixpkgs_10" + "nixpkgs": "nixpkgs_11" }, "locked": { "lastModified": 1774455309, @@ -4799,7 +4829,7 @@ }, "logos-nix_100": { "inputs": { - "nixpkgs": "nixpkgs_100" + "nixpkgs": "nixpkgs_101" }, "locked": { "lastModified": 1773955630, @@ -4817,7 +4847,7 @@ }, "logos-nix_101": { "inputs": { - "nixpkgs": "nixpkgs_101" + "nixpkgs": "nixpkgs_102" }, "locked": { "lastModified": 1773955630, @@ -4835,7 +4865,7 @@ }, "logos-nix_102": { "inputs": { - "nixpkgs": "nixpkgs_102" + "nixpkgs": "nixpkgs_103" }, "locked": { "lastModified": 1773955630, @@ -4853,7 +4883,7 @@ }, "logos-nix_103": { "inputs": { - "nixpkgs": "nixpkgs_103" + "nixpkgs": "nixpkgs_104" }, "locked": { "lastModified": 1773955630, @@ -4871,7 +4901,7 @@ }, "logos-nix_104": { "inputs": { - "nixpkgs": "nixpkgs_104" + "nixpkgs": "nixpkgs_105" }, "locked": { "lastModified": 1773955630, @@ -4889,7 +4919,7 @@ }, "logos-nix_105": { "inputs": { - "nixpkgs": "nixpkgs_105" + "nixpkgs": "nixpkgs_106" }, "locked": { "lastModified": 1774455309, @@ -4907,7 +4937,7 @@ }, "logos-nix_106": { "inputs": { - "nixpkgs": "nixpkgs_106" + "nixpkgs": "nixpkgs_107" }, "locked": { "lastModified": 1773955630, @@ -4925,7 +4955,7 @@ }, "logos-nix_107": { "inputs": { - "nixpkgs": "nixpkgs_107" + "nixpkgs": "nixpkgs_108" }, "locked": { "lastModified": 1774455309, @@ -4943,7 +4973,7 @@ }, "logos-nix_108": { "inputs": { - "nixpkgs": "nixpkgs_108" + "nixpkgs": "nixpkgs_109" }, "locked": { "lastModified": 1774455309, @@ -4961,7 +4991,7 @@ }, "logos-nix_109": { "inputs": { - "nixpkgs": "nixpkgs_109" + "nixpkgs": "nixpkgs_110" }, "locked": { "lastModified": 1773955630, @@ -4979,7 +5009,7 @@ }, "logos-nix_11": { "inputs": { - "nixpkgs": "nixpkgs_11" + "nixpkgs": "nixpkgs_12" }, "locked": { "lastModified": 1773955630, @@ -4997,7 +5027,7 @@ }, "logos-nix_110": { "inputs": { - "nixpkgs": "nixpkgs_110" + "nixpkgs": "nixpkgs_111" }, "locked": { "lastModified": 1773955630, @@ -5015,7 +5045,7 @@ }, "logos-nix_111": { "inputs": { - "nixpkgs": "nixpkgs_111" + "nixpkgs": "nixpkgs_112" }, "locked": { "lastModified": 1774455309, @@ -5033,7 +5063,7 @@ }, "logos-nix_112": { "inputs": { - "nixpkgs": "nixpkgs_112" + "nixpkgs": "nixpkgs_113" }, "locked": { "lastModified": 1774455309, @@ -5051,7 +5081,7 @@ }, "logos-nix_113": { "inputs": { - "nixpkgs": "nixpkgs_113" + "nixpkgs": "nixpkgs_114" }, "locked": { "lastModified": 1773955630, @@ -5069,7 +5099,7 @@ }, "logos-nix_114": { "inputs": { - "nixpkgs": "nixpkgs_114" + "nixpkgs": "nixpkgs_115" }, "locked": { "lastModified": 1773955630, @@ -5087,7 +5117,7 @@ }, "logos-nix_115": { "inputs": { - "nixpkgs": "nixpkgs_115" + "nixpkgs": "nixpkgs_116" }, "locked": { "lastModified": 1774455309, @@ -5105,7 +5135,7 @@ }, "logos-nix_116": { "inputs": { - "nixpkgs": "nixpkgs_116" + "nixpkgs": "nixpkgs_117" }, "locked": { "lastModified": 1774455309, @@ -5123,7 +5153,7 @@ }, "logos-nix_117": { "inputs": { - "nixpkgs": "nixpkgs_117" + "nixpkgs": "nixpkgs_118" }, "locked": { "lastModified": 1773955630, @@ -5141,7 +5171,7 @@ }, "logos-nix_118": { "inputs": { - "nixpkgs": "nixpkgs_118" + "nixpkgs": "nixpkgs_119" }, "locked": { "lastModified": 1773955630, @@ -5159,7 +5189,7 @@ }, "logos-nix_119": { "inputs": { - "nixpkgs": "nixpkgs_119" + "nixpkgs": "nixpkgs_120" }, "locked": { "lastModified": 1773955630, @@ -5177,7 +5207,7 @@ }, "logos-nix_12": { "inputs": { - "nixpkgs": "nixpkgs_12" + "nixpkgs": "nixpkgs_13" }, "locked": { "lastModified": 1774455309, @@ -5195,7 +5225,7 @@ }, "logos-nix_120": { "inputs": { - "nixpkgs": "nixpkgs_120" + "nixpkgs": "nixpkgs_121" }, "locked": { "lastModified": 1773955630, @@ -5213,7 +5243,7 @@ }, "logos-nix_121": { "inputs": { - "nixpkgs": "nixpkgs_121" + "nixpkgs": "nixpkgs_122" }, "locked": { "lastModified": 1774455309, @@ -5231,7 +5261,7 @@ }, "logos-nix_122": { "inputs": { - "nixpkgs": "nixpkgs_122" + "nixpkgs": "nixpkgs_123" }, "locked": { "lastModified": 1773955630, @@ -5249,7 +5279,7 @@ }, "logos-nix_123": { "inputs": { - "nixpkgs": "nixpkgs_123" + "nixpkgs": "nixpkgs_124" }, "locked": { "lastModified": 1773955630, @@ -5267,7 +5297,7 @@ }, "logos-nix_124": { "inputs": { - "nixpkgs": "nixpkgs_125" + "nixpkgs": "nixpkgs_126" }, "locked": { "lastModified": 1774455309, @@ -5285,7 +5315,7 @@ }, "logos-nix_125": { "inputs": { - "nixpkgs": "nixpkgs_126" + "nixpkgs": "nixpkgs_127" }, "locked": { "lastModified": 1774455309, @@ -5303,7 +5333,7 @@ }, "logos-nix_126": { "inputs": { - "nixpkgs": "nixpkgs_127" + "nixpkgs": "nixpkgs_128" }, "locked": { "lastModified": 1774455309, @@ -5321,7 +5351,7 @@ }, "logos-nix_127": { "inputs": { - "nixpkgs": "nixpkgs_128" + "nixpkgs": "nixpkgs_129" }, "locked": { "lastModified": 1774455309, @@ -5339,7 +5369,7 @@ }, "logos-nix_128": { "inputs": { - "nixpkgs": "nixpkgs_129" + "nixpkgs": "nixpkgs_130" }, "locked": { "lastModified": 1773955630, @@ -5357,7 +5387,7 @@ }, "logos-nix_129": { "inputs": { - "nixpkgs": "nixpkgs_130" + "nixpkgs": "nixpkgs_131" }, "locked": { "lastModified": 1774455309, @@ -5375,7 +5405,7 @@ }, "logos-nix_13": { "inputs": { - "nixpkgs": "nixpkgs_13" + "nixpkgs": "nixpkgs_14" }, "locked": { "lastModified": 1773955630, @@ -5393,7 +5423,7 @@ }, "logos-nix_130": { "inputs": { - "nixpkgs": "nixpkgs_131" + "nixpkgs": "nixpkgs_132" }, "locked": { "lastModified": 1774455309, @@ -5411,7 +5441,7 @@ }, "logos-nix_131": { "inputs": { - "nixpkgs": "nixpkgs_132" + "nixpkgs": "nixpkgs_133" }, "locked": { "lastModified": 1774455309, @@ -5429,7 +5459,7 @@ }, "logos-nix_132": { "inputs": { - "nixpkgs": "nixpkgs_133" + "nixpkgs": "nixpkgs_134" }, "locked": { "lastModified": 1774455309, @@ -5447,7 +5477,7 @@ }, "logos-nix_133": { "inputs": { - "nixpkgs": "nixpkgs_134" + "nixpkgs": "nixpkgs_135" }, "locked": { "lastModified": 1774455309, @@ -5465,7 +5495,7 @@ }, "logos-nix_134": { "inputs": { - "nixpkgs": "nixpkgs_135" + "nixpkgs": "nixpkgs_136" }, "locked": { "lastModified": 1774455309, @@ -5483,7 +5513,7 @@ }, "logos-nix_135": { "inputs": { - "nixpkgs": "nixpkgs_136" + "nixpkgs": "nixpkgs_137" }, "locked": { "lastModified": 1773955630, @@ -5501,7 +5531,7 @@ }, "logos-nix_136": { "inputs": { - "nixpkgs": "nixpkgs_137" + "nixpkgs": "nixpkgs_138" }, "locked": { "lastModified": 1774455309, @@ -5519,7 +5549,7 @@ }, "logos-nix_137": { "inputs": { - "nixpkgs": "nixpkgs_138" + "nixpkgs": "nixpkgs_139" }, "locked": { "lastModified": 1773955630, @@ -5537,7 +5567,7 @@ }, "logos-nix_138": { "inputs": { - "nixpkgs": "nixpkgs_139" + "nixpkgs": "nixpkgs_140" }, "locked": { "lastModified": 1774455309, @@ -5555,7 +5585,7 @@ }, "logos-nix_139": { "inputs": { - "nixpkgs": "nixpkgs_140" + "nixpkgs": "nixpkgs_141" }, "locked": { "lastModified": 1773955630, @@ -5573,7 +5603,7 @@ }, "logos-nix_14": { "inputs": { - "nixpkgs": "nixpkgs_14" + "nixpkgs": "nixpkgs_15" }, "locked": { "lastModified": 1774455309, @@ -5591,7 +5621,7 @@ }, "logos-nix_140": { "inputs": { - "nixpkgs": "nixpkgs_141" + "nixpkgs": "nixpkgs_142" }, "locked": { "lastModified": 1774455309, @@ -5609,7 +5639,7 @@ }, "logos-nix_141": { "inputs": { - "nixpkgs": "nixpkgs_142" + "nixpkgs": "nixpkgs_143" }, "locked": { "lastModified": 1774455309, @@ -5627,7 +5657,7 @@ }, "logos-nix_142": { "inputs": { - "nixpkgs": "nixpkgs_143" + "nixpkgs": "nixpkgs_144" }, "locked": { "lastModified": 1773955630, @@ -5645,7 +5675,7 @@ }, "logos-nix_143": { "inputs": { - "nixpkgs": "nixpkgs_144" + "nixpkgs": "nixpkgs_145" }, "locked": { "lastModified": 1774455309, @@ -5663,7 +5693,7 @@ }, "logos-nix_144": { "inputs": { - "nixpkgs": "nixpkgs_145" + "nixpkgs": "nixpkgs_146" }, "locked": { "lastModified": 1773955630, @@ -5681,7 +5711,7 @@ }, "logos-nix_145": { "inputs": { - "nixpkgs": "nixpkgs_146" + "nixpkgs": "nixpkgs_147" }, "locked": { "lastModified": 1774455309, @@ -5699,7 +5729,7 @@ }, "logos-nix_146": { "inputs": { - "nixpkgs": "nixpkgs_147" + "nixpkgs": "nixpkgs_148" }, "locked": { "lastModified": 1773955630, @@ -5717,7 +5747,7 @@ }, "logos-nix_147": { "inputs": { - "nixpkgs": "nixpkgs_148" + "nixpkgs": "nixpkgs_149" }, "locked": { "lastModified": 1774455309, @@ -5735,7 +5765,7 @@ }, "logos-nix_148": { "inputs": { - "nixpkgs": "nixpkgs_149" + "nixpkgs": "nixpkgs_150" }, "locked": { "lastModified": 1773955630, @@ -5753,7 +5783,7 @@ }, "logos-nix_149": { "inputs": { - "nixpkgs": "nixpkgs_150" + "nixpkgs": "nixpkgs_151" }, "locked": { "lastModified": 1773955630, @@ -5771,7 +5801,7 @@ }, "logos-nix_15": { "inputs": { - "nixpkgs": "nixpkgs_15" + "nixpkgs": "nixpkgs_16" }, "locked": { "lastModified": 1773955630, @@ -5789,7 +5819,7 @@ }, "logos-nix_150": { "inputs": { - "nixpkgs": "nixpkgs_151" + "nixpkgs": "nixpkgs_152" }, "locked": { "lastModified": 1774455309, @@ -5807,7 +5837,7 @@ }, "logos-nix_151": { "inputs": { - "nixpkgs": "nixpkgs_152" + "nixpkgs": "nixpkgs_153" }, "locked": { "lastModified": 1773955630, @@ -5825,7 +5855,7 @@ }, "logos-nix_152": { "inputs": { - "nixpkgs": "nixpkgs_153" + "nixpkgs": "nixpkgs_154" }, "locked": { "lastModified": 1773955630, @@ -5843,7 +5873,7 @@ }, "logos-nix_153": { "inputs": { - "nixpkgs": "nixpkgs_154" + "nixpkgs": "nixpkgs_155" }, "locked": { "lastModified": 1773955630, @@ -5861,7 +5891,7 @@ }, "logos-nix_154": { "inputs": { - "nixpkgs": "nixpkgs_155" + "nixpkgs": "nixpkgs_156" }, "locked": { "lastModified": 1773955630, @@ -5879,7 +5909,7 @@ }, "logos-nix_155": { "inputs": { - "nixpkgs": "nixpkgs_156" + "nixpkgs": "nixpkgs_157" }, "locked": { "lastModified": 1774455309, @@ -5897,7 +5927,7 @@ }, "logos-nix_156": { "inputs": { - "nixpkgs": "nixpkgs_157" + "nixpkgs": "nixpkgs_158" }, "locked": { "lastModified": 1773955630, @@ -5915,7 +5945,7 @@ }, "logos-nix_157": { "inputs": { - "nixpkgs": "nixpkgs_158" + "nixpkgs": "nixpkgs_159" }, "locked": { "lastModified": 1774455309, @@ -5933,7 +5963,7 @@ }, "logos-nix_158": { "inputs": { - "nixpkgs": "nixpkgs_159" + "nixpkgs": "nixpkgs_160" }, "locked": { "lastModified": 1774455309, @@ -5951,7 +5981,7 @@ }, "logos-nix_159": { "inputs": { - "nixpkgs": "nixpkgs_160" + "nixpkgs": "nixpkgs_161" }, "locked": { "lastModified": 1773955630, @@ -5969,7 +5999,7 @@ }, "logos-nix_16": { "inputs": { - "nixpkgs": "nixpkgs_16" + "nixpkgs": "nixpkgs_17" }, "locked": { "lastModified": 1774455309, @@ -5987,7 +6017,7 @@ }, "logos-nix_160": { "inputs": { - "nixpkgs": "nixpkgs_161" + "nixpkgs": "nixpkgs_162" }, "locked": { "lastModified": 1773955630, @@ -6005,7 +6035,7 @@ }, "logos-nix_161": { "inputs": { - "nixpkgs": "nixpkgs_162" + "nixpkgs": "nixpkgs_163" }, "locked": { "lastModified": 1773955630, @@ -6023,7 +6053,7 @@ }, "logos-nix_162": { "inputs": { - "nixpkgs": "nixpkgs_163" + "nixpkgs": "nixpkgs_164" }, "locked": { "lastModified": 1773955630, @@ -6041,7 +6071,7 @@ }, "logos-nix_163": { "inputs": { - "nixpkgs": "nixpkgs_164" + "nixpkgs": "nixpkgs_165" }, "locked": { "lastModified": 1773955630, @@ -6059,7 +6089,7 @@ }, "logos-nix_164": { "inputs": { - "nixpkgs": "nixpkgs_165" + "nixpkgs": "nixpkgs_166" }, "locked": { "lastModified": 1774455309, @@ -6077,7 +6107,7 @@ }, "logos-nix_165": { "inputs": { - "nixpkgs": "nixpkgs_166" + "nixpkgs": "nixpkgs_167" }, "locked": { "lastModified": 1773955630, @@ -6095,7 +6125,7 @@ }, "logos-nix_166": { "inputs": { - "nixpkgs": "nixpkgs_167" + "nixpkgs": "nixpkgs_168" }, "locked": { "lastModified": 1774455309, @@ -6113,7 +6143,7 @@ }, "logos-nix_167": { "inputs": { - "nixpkgs": "nixpkgs_168" + "nixpkgs": "nixpkgs_169" }, "locked": { "lastModified": 1774455309, @@ -6131,7 +6161,7 @@ }, "logos-nix_168": { "inputs": { - "nixpkgs": "nixpkgs_169" + "nixpkgs": "nixpkgs_170" }, "locked": { "lastModified": 1773955630, @@ -6149,7 +6179,7 @@ }, "logos-nix_169": { "inputs": { - "nixpkgs": "nixpkgs_170" + "nixpkgs": "nixpkgs_171" }, "locked": { "lastModified": 1773955630, @@ -6167,7 +6197,7 @@ }, "logos-nix_17": { "inputs": { - "nixpkgs": "nixpkgs_17" + "nixpkgs": "nixpkgs_18" }, "locked": { "lastModified": 1773955630, @@ -6185,7 +6215,7 @@ }, "logos-nix_170": { "inputs": { - "nixpkgs": "nixpkgs_171" + "nixpkgs": "nixpkgs_172" }, "locked": { "lastModified": 1774455309, @@ -6203,7 +6233,7 @@ }, "logos-nix_171": { "inputs": { - "nixpkgs": "nixpkgs_172" + "nixpkgs": "nixpkgs_173" }, "locked": { "lastModified": 1774455309, @@ -6221,7 +6251,7 @@ }, "logos-nix_172": { "inputs": { - "nixpkgs": "nixpkgs_173" + "nixpkgs": "nixpkgs_174" }, "locked": { "lastModified": 1773955630, @@ -6239,7 +6269,7 @@ }, "logos-nix_173": { "inputs": { - "nixpkgs": "nixpkgs_174" + "nixpkgs": "nixpkgs_175" }, "locked": { "lastModified": 1773955630, @@ -6257,7 +6287,7 @@ }, "logos-nix_174": { "inputs": { - "nixpkgs": "nixpkgs_175" + "nixpkgs": "nixpkgs_176" }, "locked": { "lastModified": 1774455309, @@ -6275,7 +6305,7 @@ }, "logos-nix_175": { "inputs": { - "nixpkgs": "nixpkgs_176" + "nixpkgs": "nixpkgs_177" }, "locked": { "lastModified": 1774455309, @@ -6293,7 +6323,7 @@ }, "logos-nix_176": { "inputs": { - "nixpkgs": "nixpkgs_177" + "nixpkgs": "nixpkgs_178" }, "locked": { "lastModified": 1773955630, @@ -6311,7 +6341,7 @@ }, "logos-nix_177": { "inputs": { - "nixpkgs": "nixpkgs_178" + "nixpkgs": "nixpkgs_179" }, "locked": { "lastModified": 1773955630, @@ -6329,7 +6359,7 @@ }, "logos-nix_178": { "inputs": { - "nixpkgs": "nixpkgs_179" + "nixpkgs": "nixpkgs_180" }, "locked": { "lastModified": 1773955630, @@ -6347,7 +6377,7 @@ }, "logos-nix_179": { "inputs": { - "nixpkgs": "nixpkgs_180" + "nixpkgs": "nixpkgs_181" }, "locked": { "lastModified": 1773955630, @@ -6365,7 +6395,7 @@ }, "logos-nix_18": { "inputs": { - "nixpkgs": "nixpkgs_18" + "nixpkgs": "nixpkgs_19" }, "locked": { "lastModified": 1773955630, @@ -6383,7 +6413,7 @@ }, "logos-nix_180": { "inputs": { - "nixpkgs": "nixpkgs_181" + "nixpkgs": "nixpkgs_182" }, "locked": { "lastModified": 1774455309, @@ -6401,7 +6431,7 @@ }, "logos-nix_181": { "inputs": { - "nixpkgs": "nixpkgs_182" + "nixpkgs": "nixpkgs_183" }, "locked": { "lastModified": 1773955630, @@ -6419,7 +6449,7 @@ }, "logos-nix_182": { "inputs": { - "nixpkgs": "nixpkgs_183" + "nixpkgs": "nixpkgs_184" }, "locked": { "lastModified": 1773955630, @@ -6437,7 +6467,7 @@ }, "logos-nix_183": { "inputs": { - "nixpkgs": "nixpkgs_184" + "nixpkgs": "nixpkgs_185" }, "locked": { "lastModified": 1774455309, @@ -6455,7 +6485,7 @@ }, "logos-nix_184": { "inputs": { - "nixpkgs": "nixpkgs_185" + "nixpkgs": "nixpkgs_186" }, "locked": { "lastModified": 1773955630, @@ -6473,7 +6503,7 @@ }, "logos-nix_185": { "inputs": { - "nixpkgs": "nixpkgs_186" + "nixpkgs": "nixpkgs_187" }, "locked": { "lastModified": 1774455309, @@ -6491,7 +6521,7 @@ }, "logos-nix_186": { "inputs": { - "nixpkgs": "nixpkgs_187" + "nixpkgs": "nixpkgs_188" }, "locked": { "lastModified": 1773955630, @@ -6509,7 +6539,7 @@ }, "logos-nix_187": { "inputs": { - "nixpkgs": "nixpkgs_188" + "nixpkgs": "nixpkgs_189" }, "locked": { "lastModified": 1774455309, @@ -6527,7 +6557,7 @@ }, "logos-nix_188": { "inputs": { - "nixpkgs": "nixpkgs_189" + "nixpkgs": "nixpkgs_190" }, "locked": { "lastModified": 1773955630, @@ -6545,7 +6575,7 @@ }, "logos-nix_189": { "inputs": { - "nixpkgs": "nixpkgs_190" + "nixpkgs": "nixpkgs_191" }, "locked": { "lastModified": 1774455309, @@ -6563,7 +6593,7 @@ }, "logos-nix_19": { "inputs": { - "nixpkgs": "nixpkgs_19" + "nixpkgs": "nixpkgs_20" }, "locked": { "lastModified": 1774455309, @@ -6581,7 +6611,7 @@ }, "logos-nix_190": { "inputs": { - "nixpkgs": "nixpkgs_191" + "nixpkgs": "nixpkgs_192" }, "locked": { "lastModified": 1773955630, @@ -6599,7 +6629,7 @@ }, "logos-nix_191": { "inputs": { - "nixpkgs": "nixpkgs_192" + "nixpkgs": "nixpkgs_193" }, "locked": { "lastModified": 1774455309, @@ -6617,7 +6647,7 @@ }, "logos-nix_192": { "inputs": { - "nixpkgs": "nixpkgs_193" + "nixpkgs": "nixpkgs_194" }, "locked": { "lastModified": 1773955630, @@ -6635,7 +6665,7 @@ }, "logos-nix_193": { "inputs": { - "nixpkgs": "nixpkgs_194" + "nixpkgs": "nixpkgs_195" }, "locked": { "lastModified": 1773955630, @@ -6653,7 +6683,7 @@ }, "logos-nix_194": { "inputs": { - "nixpkgs": "nixpkgs_195" + "nixpkgs": "nixpkgs_196" }, "locked": { "lastModified": 1774455309, @@ -6671,7 +6701,7 @@ }, "logos-nix_195": { "inputs": { - "nixpkgs": "nixpkgs_196" + "nixpkgs": "nixpkgs_197" }, "locked": { "lastModified": 1773955630, @@ -6689,7 +6719,7 @@ }, "logos-nix_196": { "inputs": { - "nixpkgs": "nixpkgs_197" + "nixpkgs": "nixpkgs_198" }, "locked": { "lastModified": 1773955630, @@ -6707,7 +6737,7 @@ }, "logos-nix_197": { "inputs": { - "nixpkgs": "nixpkgs_198" + "nixpkgs": "nixpkgs_199" }, "locked": { "lastModified": 1773955630, @@ -6725,7 +6755,7 @@ }, "logos-nix_198": { "inputs": { - "nixpkgs": "nixpkgs_199" + "nixpkgs": "nixpkgs_200" }, "locked": { "lastModified": 1773955630, @@ -6743,7 +6773,7 @@ }, "logos-nix_199": { "inputs": { - "nixpkgs": "nixpkgs_200" + "nixpkgs": "nixpkgs_201" }, "locked": { "lastModified": 1774455309, @@ -6761,7 +6791,7 @@ }, "logos-nix_2": { "inputs": { - "nixpkgs": "nixpkgs_2" + "nixpkgs": "nixpkgs_3" }, "locked": { "lastModified": 1773955630, @@ -6779,7 +6809,7 @@ }, "logos-nix_20": { "inputs": { - "nixpkgs": "nixpkgs_20" + "nixpkgs": "nixpkgs_21" }, "locked": { "lastModified": 1773955630, @@ -6797,7 +6827,7 @@ }, "logos-nix_200": { "inputs": { - "nixpkgs": "nixpkgs_201" + "nixpkgs": "nixpkgs_202" }, "locked": { "lastModified": 1773955630, @@ -6815,7 +6845,7 @@ }, "logos-nix_201": { "inputs": { - "nixpkgs": "nixpkgs_202" + "nixpkgs": "nixpkgs_203" }, "locked": { "lastModified": 1774455309, @@ -6833,7 +6863,7 @@ }, "logos-nix_202": { "inputs": { - "nixpkgs": "nixpkgs_203" + "nixpkgs": "nixpkgs_204" }, "locked": { "lastModified": 1774455309, @@ -6851,7 +6881,7 @@ }, "logos-nix_203": { "inputs": { - "nixpkgs": "nixpkgs_204" + "nixpkgs": "nixpkgs_205" }, "locked": { "lastModified": 1773955630, @@ -6869,7 +6899,7 @@ }, "logos-nix_204": { "inputs": { - "nixpkgs": "nixpkgs_205" + "nixpkgs": "nixpkgs_206" }, "locked": { "lastModified": 1773955630, @@ -6887,7 +6917,7 @@ }, "logos-nix_205": { "inputs": { - "nixpkgs": "nixpkgs_206" + "nixpkgs": "nixpkgs_207" }, "locked": { "lastModified": 1773955630, @@ -6905,7 +6935,7 @@ }, "logos-nix_206": { "inputs": { - "nixpkgs": "nixpkgs_207" + "nixpkgs": "nixpkgs_208" }, "locked": { "lastModified": 1773955630, @@ -6923,7 +6953,7 @@ }, "logos-nix_207": { "inputs": { - "nixpkgs": "nixpkgs_208" + "nixpkgs": "nixpkgs_209" }, "locked": { "lastModified": 1773955630, @@ -6941,7 +6971,7 @@ }, "logos-nix_208": { "inputs": { - "nixpkgs": "nixpkgs_209" + "nixpkgs": "nixpkgs_210" }, "locked": { "lastModified": 1774455309, @@ -6959,7 +6989,7 @@ }, "logos-nix_209": { "inputs": { - "nixpkgs": "nixpkgs_210" + "nixpkgs": "nixpkgs_211" }, "locked": { "lastModified": 1773955630, @@ -6977,7 +7007,7 @@ }, "logos-nix_21": { "inputs": { - "nixpkgs": "nixpkgs_21" + "nixpkgs": "nixpkgs_22" }, "locked": { "lastModified": 1773955630, @@ -6995,7 +7025,7 @@ }, "logos-nix_210": { "inputs": { - "nixpkgs": "nixpkgs_211" + "nixpkgs": "nixpkgs_212" }, "locked": { "lastModified": 1774455309, @@ -7013,7 +7043,7 @@ }, "logos-nix_211": { "inputs": { - "nixpkgs": "nixpkgs_212" + "nixpkgs": "nixpkgs_213" }, "locked": { "lastModified": 1774455309, @@ -7031,7 +7061,7 @@ }, "logos-nix_212": { "inputs": { - "nixpkgs": "nixpkgs_213" + "nixpkgs": "nixpkgs_214" }, "locked": { "lastModified": 1773955630, @@ -7049,7 +7079,7 @@ }, "logos-nix_213": { "inputs": { - "nixpkgs": "nixpkgs_214" + "nixpkgs": "nixpkgs_215" }, "locked": { "lastModified": 1773955630, @@ -7067,7 +7097,7 @@ }, "logos-nix_214": { "inputs": { - "nixpkgs": "nixpkgs_215" + "nixpkgs": "nixpkgs_216" }, "locked": { "lastModified": 1774455309, @@ -7085,7 +7115,7 @@ }, "logos-nix_215": { "inputs": { - "nixpkgs": "nixpkgs_216" + "nixpkgs": "nixpkgs_217" }, "locked": { "lastModified": 1774455309, @@ -7103,7 +7133,7 @@ }, "logos-nix_216": { "inputs": { - "nixpkgs": "nixpkgs_217" + "nixpkgs": "nixpkgs_218" }, "locked": { "lastModified": 1773955630, @@ -7121,7 +7151,7 @@ }, "logos-nix_217": { "inputs": { - "nixpkgs": "nixpkgs_218" + "nixpkgs": "nixpkgs_219" }, "locked": { "lastModified": 1773955630, @@ -7139,7 +7169,7 @@ }, "logos-nix_218": { "inputs": { - "nixpkgs": "nixpkgs_219" + "nixpkgs": "nixpkgs_220" }, "locked": { "lastModified": 1774455309, @@ -7157,7 +7187,7 @@ }, "logos-nix_219": { "inputs": { - "nixpkgs": "nixpkgs_220" + "nixpkgs": "nixpkgs_221" }, "locked": { "lastModified": 1774455309, @@ -7175,7 +7205,7 @@ }, "logos-nix_22": { "inputs": { - "nixpkgs": "nixpkgs_22" + "nixpkgs": "nixpkgs_23" }, "locked": { "lastModified": 1773955630, @@ -7193,7 +7223,7 @@ }, "logos-nix_220": { "inputs": { - "nixpkgs": "nixpkgs_221" + "nixpkgs": "nixpkgs_222" }, "locked": { "lastModified": 1773955630, @@ -7211,7 +7241,7 @@ }, "logos-nix_221": { "inputs": { - "nixpkgs": "nixpkgs_222" + "nixpkgs": "nixpkgs_223" }, "locked": { "lastModified": 1773955630, @@ -7229,7 +7259,7 @@ }, "logos-nix_222": { "inputs": { - "nixpkgs": "nixpkgs_223" + "nixpkgs": "nixpkgs_224" }, "locked": { "lastModified": 1773955630, @@ -7247,7 +7277,7 @@ }, "logos-nix_223": { "inputs": { - "nixpkgs": "nixpkgs_224" + "nixpkgs": "nixpkgs_225" }, "locked": { "lastModified": 1773955630, @@ -7265,7 +7295,7 @@ }, "logos-nix_224": { "inputs": { - "nixpkgs": "nixpkgs_225" + "nixpkgs": "nixpkgs_226" }, "locked": { "lastModified": 1774455309, @@ -7283,7 +7313,7 @@ }, "logos-nix_225": { "inputs": { - "nixpkgs": "nixpkgs_226" + "nixpkgs": "nixpkgs_227" }, "locked": { "lastModified": 1773955630, @@ -7301,7 +7331,7 @@ }, "logos-nix_226": { "inputs": { - "nixpkgs": "nixpkgs_227" + "nixpkgs": "nixpkgs_228" }, "locked": { "lastModified": 1773955630, @@ -7319,7 +7349,7 @@ }, "logos-nix_227": { "inputs": { - "nixpkgs": "nixpkgs_228" + "nixpkgs": "nixpkgs_229" }, "locked": { "lastModified": 1774455309, @@ -7337,7 +7367,7 @@ }, "logos-nix_228": { "inputs": { - "nixpkgs": "nixpkgs_229" + "nixpkgs": "nixpkgs_230" }, "locked": { "lastModified": 1773955630, @@ -7355,7 +7385,7 @@ }, "logos-nix_229": { "inputs": { - "nixpkgs": "nixpkgs_230" + "nixpkgs": "nixpkgs_231" }, "locked": { "lastModified": 1774455309, @@ -7373,7 +7403,7 @@ }, "logos-nix_23": { "inputs": { - "nixpkgs": "nixpkgs_23" + "nixpkgs": "nixpkgs_24" }, "locked": { "lastModified": 1773955630, @@ -7391,7 +7421,7 @@ }, "logos-nix_230": { "inputs": { - "nixpkgs": "nixpkgs_231" + "nixpkgs": "nixpkgs_232" }, "locked": { "lastModified": 1774455309, @@ -7409,7 +7439,7 @@ }, "logos-nix_231": { "inputs": { - "nixpkgs": "nixpkgs_232" + "nixpkgs": "nixpkgs_233" }, "locked": { "lastModified": 1773955630, @@ -7427,7 +7457,7 @@ }, "logos-nix_232": { "inputs": { - "nixpkgs": "nixpkgs_233" + "nixpkgs": "nixpkgs_234" }, "locked": { "lastModified": 1773955630, @@ -7445,7 +7475,7 @@ }, "logos-nix_233": { "inputs": { - "nixpkgs": "nixpkgs_234" + "nixpkgs": "nixpkgs_235" }, "locked": { "lastModified": 1773955630, @@ -7463,7 +7493,7 @@ }, "logos-nix_234": { "inputs": { - "nixpkgs": "nixpkgs_235" + "nixpkgs": "nixpkgs_236" }, "locked": { "lastModified": 1773955630, @@ -7481,7 +7511,7 @@ }, "logos-nix_235": { "inputs": { - "nixpkgs": "nixpkgs_236" + "nixpkgs": "nixpkgs_237" }, "locked": { "lastModified": 1773955630, @@ -7499,7 +7529,7 @@ }, "logos-nix_236": { "inputs": { - "nixpkgs": "nixpkgs_237" + "nixpkgs": "nixpkgs_238" }, "locked": { "lastModified": 1774455309, @@ -7517,7 +7547,7 @@ }, "logos-nix_237": { "inputs": { - "nixpkgs": "nixpkgs_238" + "nixpkgs": "nixpkgs_239" }, "locked": { "lastModified": 1773955630, @@ -7535,7 +7565,7 @@ }, "logos-nix_238": { "inputs": { - "nixpkgs": "nixpkgs_239" + "nixpkgs": "nixpkgs_240" }, "locked": { "lastModified": 1774455309, @@ -7553,7 +7583,7 @@ }, "logos-nix_239": { "inputs": { - "nixpkgs": "nixpkgs_240" + "nixpkgs": "nixpkgs_241" }, "locked": { "lastModified": 1774455309, @@ -7571,7 +7601,7 @@ }, "logos-nix_24": { "inputs": { - "nixpkgs": "nixpkgs_24" + "nixpkgs": "nixpkgs_25" }, "locked": { "lastModified": 1774455309, @@ -7589,7 +7619,7 @@ }, "logos-nix_240": { "inputs": { - "nixpkgs": "nixpkgs_241" + "nixpkgs": "nixpkgs_242" }, "locked": { "lastModified": 1773955630, @@ -7607,7 +7637,7 @@ }, "logos-nix_241": { "inputs": { - "nixpkgs": "nixpkgs_242" + "nixpkgs": "nixpkgs_243" }, "locked": { "lastModified": 1773955630, @@ -7625,7 +7655,7 @@ }, "logos-nix_242": { "inputs": { - "nixpkgs": "nixpkgs_243" + "nixpkgs": "nixpkgs_244" }, "locked": { "lastModified": 1774455309, @@ -7643,7 +7673,7 @@ }, "logos-nix_243": { "inputs": { - "nixpkgs": "nixpkgs_244" + "nixpkgs": "nixpkgs_245" }, "locked": { "lastModified": 1774455309, @@ -7661,7 +7691,7 @@ }, "logos-nix_244": { "inputs": { - "nixpkgs": "nixpkgs_245" + "nixpkgs": "nixpkgs_246" }, "locked": { "lastModified": 1773955630, @@ -7679,7 +7709,7 @@ }, "logos-nix_245": { "inputs": { - "nixpkgs": "nixpkgs_246" + "nixpkgs": "nixpkgs_247" }, "locked": { "lastModified": 1773955630, @@ -7697,7 +7727,7 @@ }, "logos-nix_246": { "inputs": { - "nixpkgs": "nixpkgs_247" + "nixpkgs": "nixpkgs_248" }, "locked": { "lastModified": 1774455309, @@ -7715,7 +7745,7 @@ }, "logos-nix_247": { "inputs": { - "nixpkgs": "nixpkgs_248" + "nixpkgs": "nixpkgs_249" }, "locked": { "lastModified": 1774455309, @@ -7733,7 +7763,7 @@ }, "logos-nix_248": { "inputs": { - "nixpkgs": "nixpkgs_249" + "nixpkgs": "nixpkgs_250" }, "locked": { "lastModified": 1773955630, @@ -7751,7 +7781,7 @@ }, "logos-nix_249": { "inputs": { - "nixpkgs": "nixpkgs_250" + "nixpkgs": "nixpkgs_251" }, "locked": { "lastModified": 1773955630, @@ -7769,7 +7799,7 @@ }, "logos-nix_25": { "inputs": { - "nixpkgs": "nixpkgs_25" + "nixpkgs": "nixpkgs_26" }, "locked": { "lastModified": 1773955630, @@ -7787,7 +7817,7 @@ }, "logos-nix_250": { "inputs": { - "nixpkgs": "nixpkgs_251" + "nixpkgs": "nixpkgs_252" }, "locked": { "lastModified": 1773955630, @@ -7805,7 +7835,7 @@ }, "logos-nix_251": { "inputs": { - "nixpkgs": "nixpkgs_252" + "nixpkgs": "nixpkgs_253" }, "locked": { "lastModified": 1773955630, @@ -7823,7 +7853,7 @@ }, "logos-nix_252": { "inputs": { - "nixpkgs": "nixpkgs_253" + "nixpkgs": "nixpkgs_254" }, "locked": { "lastModified": 1774455309, @@ -7841,7 +7871,7 @@ }, "logos-nix_253": { "inputs": { - "nixpkgs": "nixpkgs_254" + "nixpkgs": "nixpkgs_255" }, "locked": { "lastModified": 1773955630, @@ -7859,7 +7889,7 @@ }, "logos-nix_254": { "inputs": { - "nixpkgs": "nixpkgs_255" + "nixpkgs": "nixpkgs_256" }, "locked": { "lastModified": 1773955630, @@ -7877,7 +7907,7 @@ }, "logos-nix_255": { "inputs": { - "nixpkgs": "nixpkgs_256" + "nixpkgs": "nixpkgs_257" }, "locked": { "lastModified": 1774455309, @@ -7895,7 +7925,7 @@ }, "logos-nix_256": { "inputs": { - "nixpkgs": "nixpkgs_257" + "nixpkgs": "nixpkgs_258" }, "locked": { "lastModified": 1774455309, @@ -7913,7 +7943,7 @@ }, "logos-nix_257": { "inputs": { - "nixpkgs": "nixpkgs_258" + "nixpkgs": "nixpkgs_259" }, "locked": { "lastModified": 1773955630, @@ -7931,7 +7961,7 @@ }, "logos-nix_258": { "inputs": { - "nixpkgs": "nixpkgs_259" + "nixpkgs": "nixpkgs_260" }, "locked": { "lastModified": 1774455309, @@ -7949,7 +7979,7 @@ }, "logos-nix_259": { "inputs": { - "nixpkgs": "nixpkgs_260" + "nixpkgs": "nixpkgs_261" }, "locked": { "lastModified": 1774455309, @@ -7967,7 +7997,7 @@ }, "logos-nix_26": { "inputs": { - "nixpkgs": "nixpkgs_26" + "nixpkgs": "nixpkgs_27" }, "locked": { "lastModified": 1774455309, @@ -7985,7 +8015,7 @@ }, "logos-nix_260": { "inputs": { - "nixpkgs": "nixpkgs_261" + "nixpkgs": "nixpkgs_262" }, "locked": { "lastModified": 1774455309, @@ -8003,7 +8033,7 @@ }, "logos-nix_261": { "inputs": { - "nixpkgs": "nixpkgs_262" + "nixpkgs": "nixpkgs_263" }, "locked": { "lastModified": 1774455309, @@ -8021,7 +8051,7 @@ }, "logos-nix_262": { "inputs": { - "nixpkgs": "nixpkgs_263" + "nixpkgs": "nixpkgs_264" }, "locked": { "lastModified": 1773955630, @@ -8039,7 +8069,7 @@ }, "logos-nix_263": { "inputs": { - "nixpkgs": "nixpkgs_264" + "nixpkgs": "nixpkgs_265" }, "locked": { "lastModified": 1773955630, @@ -8057,7 +8087,7 @@ }, "logos-nix_264": { "inputs": { - "nixpkgs": "nixpkgs_265" + "nixpkgs": "nixpkgs_266" }, "locked": { "lastModified": 1773955630, @@ -8075,7 +8105,7 @@ }, "logos-nix_265": { "inputs": { - "nixpkgs": "nixpkgs_266" + "nixpkgs": "nixpkgs_267" }, "locked": { "lastModified": 1773955630, @@ -8093,7 +8123,7 @@ }, "logos-nix_266": { "inputs": { - "nixpkgs": "nixpkgs_267" + "nixpkgs": "nixpkgs_268" }, "locked": { "lastModified": 1774455309, @@ -8111,7 +8141,7 @@ }, "logos-nix_267": { "inputs": { - "nixpkgs": "nixpkgs_268" + "nixpkgs": "nixpkgs_269" }, "locked": { "lastModified": 1774455309, @@ -8129,7 +8159,7 @@ }, "logos-nix_268": { "inputs": { - "nixpkgs": "nixpkgs_269" + "nixpkgs": "nixpkgs_270" }, "locked": { "lastModified": 1773955630, @@ -8147,7 +8177,7 @@ }, "logos-nix_269": { "inputs": { - "nixpkgs": "nixpkgs_271" + "nixpkgs": "nixpkgs_272" }, "locked": { "lastModified": 1774455309, @@ -8165,7 +8195,7 @@ }, "logos-nix_27": { "inputs": { - "nixpkgs": "nixpkgs_27" + "nixpkgs": "nixpkgs_28" }, "locked": { "lastModified": 1774455309, @@ -8183,7 +8213,7 @@ }, "logos-nix_270": { "inputs": { - "nixpkgs": "nixpkgs_272" + "nixpkgs": "nixpkgs_273" }, "locked": { "lastModified": 1773955630, @@ -8201,7 +8231,7 @@ }, "logos-nix_271": { "inputs": { - "nixpkgs": "nixpkgs_273" + "nixpkgs": "nixpkgs_274" }, "locked": { "lastModified": 1774455309, @@ -8219,7 +8249,7 @@ }, "logos-nix_272": { "inputs": { - "nixpkgs": "nixpkgs_274" + "nixpkgs": "nixpkgs_275" }, "locked": { "lastModified": 1773955630, @@ -8237,7 +8267,7 @@ }, "logos-nix_273": { "inputs": { - "nixpkgs": "nixpkgs_275" + "nixpkgs": "nixpkgs_276" }, "locked": { "lastModified": 1774455309, @@ -8255,7 +8285,7 @@ }, "logos-nix_274": { "inputs": { - "nixpkgs": "nixpkgs_276" + "nixpkgs": "nixpkgs_277" }, "locked": { "lastModified": 1773955630, @@ -8273,7 +8303,7 @@ }, "logos-nix_275": { "inputs": { - "nixpkgs": "nixpkgs_277" + "nixpkgs": "nixpkgs_278" }, "locked": { "lastModified": 1774455309, @@ -8291,7 +8321,7 @@ }, "logos-nix_276": { "inputs": { - "nixpkgs": "nixpkgs_278" + "nixpkgs": "nixpkgs_279" }, "locked": { "lastModified": 1774455309, @@ -8309,7 +8339,7 @@ }, "logos-nix_277": { "inputs": { - "nixpkgs": "nixpkgs_279" + "nixpkgs": "nixpkgs_280" }, "locked": { "lastModified": 1774455309, @@ -8327,7 +8357,7 @@ }, "logos-nix_278": { "inputs": { - "nixpkgs": "nixpkgs_280" + "nixpkgs": "nixpkgs_281" }, "locked": { "lastModified": 1774455309, @@ -8345,7 +8375,7 @@ }, "logos-nix_279": { "inputs": { - "nixpkgs": "nixpkgs_281" + "nixpkgs": "nixpkgs_282" }, "locked": { "lastModified": 1773955630, @@ -8363,7 +8393,7 @@ }, "logos-nix_28": { "inputs": { - "nixpkgs": "nixpkgs_28" + "nixpkgs": "nixpkgs_29" }, "locked": { "lastModified": 1773955630, @@ -8381,7 +8411,7 @@ }, "logos-nix_280": { "inputs": { - "nixpkgs": "nixpkgs_282" + "nixpkgs": "nixpkgs_283" }, "locked": { "lastModified": 1774455309, @@ -8399,7 +8429,7 @@ }, "logos-nix_281": { "inputs": { - "nixpkgs": "nixpkgs_283" + "nixpkgs": "nixpkgs_284" }, "locked": { "lastModified": 1773955630, @@ -8417,7 +8447,7 @@ }, "logos-nix_282": { "inputs": { - "nixpkgs": "nixpkgs_284" + "nixpkgs": "nixpkgs_285" }, "locked": { "lastModified": 1774455309, @@ -8435,7 +8465,7 @@ }, "logos-nix_283": { "inputs": { - "nixpkgs": "nixpkgs_285" + "nixpkgs": "nixpkgs_286" }, "locked": { "lastModified": 1773955630, @@ -8453,7 +8483,7 @@ }, "logos-nix_284": { "inputs": { - "nixpkgs": "nixpkgs_286" + "nixpkgs": "nixpkgs_287" }, "locked": { "lastModified": 1774455309, @@ -8471,7 +8501,7 @@ }, "logos-nix_285": { "inputs": { - "nixpkgs": "nixpkgs_287" + "nixpkgs": "nixpkgs_288" }, "locked": { "lastModified": 1773955630, @@ -8489,7 +8519,7 @@ }, "logos-nix_286": { "inputs": { - "nixpkgs": "nixpkgs_288" + "nixpkgs": "nixpkgs_289" }, "locked": { "lastModified": 1773955630, @@ -8507,7 +8537,7 @@ }, "logos-nix_287": { "inputs": { - "nixpkgs": "nixpkgs_289" + "nixpkgs": "nixpkgs_290" }, "locked": { "lastModified": 1774455309, @@ -8525,7 +8555,7 @@ }, "logos-nix_288": { "inputs": { - "nixpkgs": "nixpkgs_290" + "nixpkgs": "nixpkgs_291" }, "locked": { "lastModified": 1773955630, @@ -8543,7 +8573,7 @@ }, "logos-nix_289": { "inputs": { - "nixpkgs": "nixpkgs_291" + "nixpkgs": "nixpkgs_292" }, "locked": { "lastModified": 1773955630, @@ -8561,7 +8591,7 @@ }, "logos-nix_29": { "inputs": { - "nixpkgs": "nixpkgs_29" + "nixpkgs": "nixpkgs_30" }, "locked": { "lastModified": 1773955630, @@ -8579,7 +8609,7 @@ }, "logos-nix_290": { "inputs": { - "nixpkgs": "nixpkgs_292" + "nixpkgs": "nixpkgs_293" }, "locked": { "lastModified": 1773955630, @@ -8597,7 +8627,7 @@ }, "logos-nix_291": { "inputs": { - "nixpkgs": "nixpkgs_293" + "nixpkgs": "nixpkgs_294" }, "locked": { "lastModified": 1773955630, @@ -8615,7 +8645,7 @@ }, "logos-nix_292": { "inputs": { - "nixpkgs": "nixpkgs_294" + "nixpkgs": "nixpkgs_295" }, "locked": { "lastModified": 1774455309, @@ -8633,7 +8663,7 @@ }, "logos-nix_293": { "inputs": { - "nixpkgs": "nixpkgs_295" + "nixpkgs": "nixpkgs_296" }, "locked": { "lastModified": 1773955630, @@ -8651,7 +8681,7 @@ }, "logos-nix_294": { "inputs": { - "nixpkgs": "nixpkgs_296" + "nixpkgs": "nixpkgs_297" }, "locked": { "lastModified": 1774455309, @@ -8669,7 +8699,7 @@ }, "logos-nix_295": { "inputs": { - "nixpkgs": "nixpkgs_297" + "nixpkgs": "nixpkgs_298" }, "locked": { "lastModified": 1774455309, @@ -8687,7 +8717,7 @@ }, "logos-nix_296": { "inputs": { - "nixpkgs": "nixpkgs_298" + "nixpkgs": "nixpkgs_299" }, "locked": { "lastModified": 1773955630, @@ -8705,7 +8735,7 @@ }, "logos-nix_297": { "inputs": { - "nixpkgs": "nixpkgs_299" + "nixpkgs": "nixpkgs_300" }, "locked": { "lastModified": 1773955630, @@ -8723,7 +8753,7 @@ }, "logos-nix_298": { "inputs": { - "nixpkgs": "nixpkgs_300" + "nixpkgs": "nixpkgs_301" }, "locked": { "lastModified": 1773955630, @@ -8741,7 +8771,7 @@ }, "logos-nix_299": { "inputs": { - "nixpkgs": "nixpkgs_301" + "nixpkgs": "nixpkgs_302" }, "locked": { "lastModified": 1773955630, @@ -8759,7 +8789,7 @@ }, "logos-nix_3": { "inputs": { - "nixpkgs": "nixpkgs_3" + "nixpkgs": "nixpkgs_4" }, "locked": { "lastModified": 1774455309, @@ -8777,7 +8807,7 @@ }, "logos-nix_30": { "inputs": { - "nixpkgs": "nixpkgs_30" + "nixpkgs": "nixpkgs_31" }, "locked": { "lastModified": 1773955630, @@ -8795,7 +8825,7 @@ }, "logos-nix_300": { "inputs": { - "nixpkgs": "nixpkgs_302" + "nixpkgs": "nixpkgs_303" }, "locked": { "lastModified": 1773955630, @@ -8813,7 +8843,7 @@ }, "logos-nix_301": { "inputs": { - "nixpkgs": "nixpkgs_303" + "nixpkgs": "nixpkgs_304" }, "locked": { "lastModified": 1774455309, @@ -8831,7 +8861,7 @@ }, "logos-nix_302": { "inputs": { - "nixpkgs": "nixpkgs_304" + "nixpkgs": "nixpkgs_305" }, "locked": { "lastModified": 1773955630, @@ -8849,7 +8879,7 @@ }, "logos-nix_303": { "inputs": { - "nixpkgs": "nixpkgs_305" + "nixpkgs": "nixpkgs_306" }, "locked": { "lastModified": 1774455309, @@ -8867,7 +8897,7 @@ }, "logos-nix_304": { "inputs": { - "nixpkgs": "nixpkgs_306" + "nixpkgs": "nixpkgs_307" }, "locked": { "lastModified": 1774455309, @@ -8885,7 +8915,7 @@ }, "logos-nix_305": { "inputs": { - "nixpkgs": "nixpkgs_307" + "nixpkgs": "nixpkgs_308" }, "locked": { "lastModified": 1773955630, @@ -8903,7 +8933,7 @@ }, "logos-nix_306": { "inputs": { - "nixpkgs": "nixpkgs_308" + "nixpkgs": "nixpkgs_309" }, "locked": { "lastModified": 1773955630, @@ -8921,7 +8951,7 @@ }, "logos-nix_307": { "inputs": { - "nixpkgs": "nixpkgs_309" + "nixpkgs": "nixpkgs_310" }, "locked": { "lastModified": 1774455309, @@ -8939,7 +8969,7 @@ }, "logos-nix_308": { "inputs": { - "nixpkgs": "nixpkgs_310" + "nixpkgs": "nixpkgs_311" }, "locked": { "lastModified": 1774455309, @@ -8957,7 +8987,7 @@ }, "logos-nix_309": { "inputs": { - "nixpkgs": "nixpkgs_311" + "nixpkgs": "nixpkgs_312" }, "locked": { "lastModified": 1773955630, @@ -8975,7 +9005,7 @@ }, "logos-nix_31": { "inputs": { - "nixpkgs": "nixpkgs_31" + "nixpkgs": "nixpkgs_32" }, "locked": { "lastModified": 1773955630, @@ -8993,7 +9023,7 @@ }, "logos-nix_310": { "inputs": { - "nixpkgs": "nixpkgs_312" + "nixpkgs": "nixpkgs_313" }, "locked": { "lastModified": 1773955630, @@ -9011,7 +9041,7 @@ }, "logos-nix_311": { "inputs": { - "nixpkgs": "nixpkgs_313" + "nixpkgs": "nixpkgs_314" }, "locked": { "lastModified": 1774455309, @@ -9029,7 +9059,7 @@ }, "logos-nix_312": { "inputs": { - "nixpkgs": "nixpkgs_314" + "nixpkgs": "nixpkgs_315" }, "locked": { "lastModified": 1774455309, @@ -9047,7 +9077,7 @@ }, "logos-nix_313": { "inputs": { - "nixpkgs": "nixpkgs_315" + "nixpkgs": "nixpkgs_316" }, "locked": { "lastModified": 1773955630, @@ -9065,7 +9095,7 @@ }, "logos-nix_314": { "inputs": { - "nixpkgs": "nixpkgs_316" + "nixpkgs": "nixpkgs_317" }, "locked": { "lastModified": 1773955630, @@ -9083,7 +9113,7 @@ }, "logos-nix_315": { "inputs": { - "nixpkgs": "nixpkgs_317" + "nixpkgs": "nixpkgs_318" }, "locked": { "lastModified": 1773955630, @@ -9101,7 +9131,7 @@ }, "logos-nix_316": { "inputs": { - "nixpkgs": "nixpkgs_318" + "nixpkgs": "nixpkgs_319" }, "locked": { "lastModified": 1773955630, @@ -9119,7 +9149,7 @@ }, "logos-nix_317": { "inputs": { - "nixpkgs": "nixpkgs_319" + "nixpkgs": "nixpkgs_320" }, "locked": { "lastModified": 1774455309, @@ -9137,7 +9167,7 @@ }, "logos-nix_318": { "inputs": { - "nixpkgs": "nixpkgs_320" + "nixpkgs": "nixpkgs_321" }, "locked": { "lastModified": 1773955630, @@ -9155,7 +9185,7 @@ }, "logos-nix_319": { "inputs": { - "nixpkgs": "nixpkgs_321" + "nixpkgs": "nixpkgs_322" }, "locked": { "lastModified": 1773955630, @@ -9173,7 +9203,7 @@ }, "logos-nix_32": { "inputs": { - "nixpkgs": "nixpkgs_32" + "nixpkgs": "nixpkgs_33" }, "locked": { "lastModified": 1773955630, @@ -9191,7 +9221,7 @@ }, "logos-nix_320": { "inputs": { - "nixpkgs": "nixpkgs_322" + "nixpkgs": "nixpkgs_323" }, "locked": { "lastModified": 1774455309, @@ -9209,7 +9239,7 @@ }, "logos-nix_321": { "inputs": { - "nixpkgs": "nixpkgs_323" + "nixpkgs": "nixpkgs_324" }, "locked": { "lastModified": 1773955630, @@ -9227,7 +9257,7 @@ }, "logos-nix_322": { "inputs": { - "nixpkgs": "nixpkgs_324" + "nixpkgs": "nixpkgs_325" }, "locked": { "lastModified": 1774455309, @@ -9245,7 +9275,7 @@ }, "logos-nix_323": { "inputs": { - "nixpkgs": "nixpkgs_325" + "nixpkgs": "nixpkgs_326" }, "locked": { "lastModified": 1773955630, @@ -9263,7 +9293,7 @@ }, "logos-nix_324": { "inputs": { - "nixpkgs": "nixpkgs_326" + "nixpkgs": "nixpkgs_327" }, "locked": { "lastModified": 1774455309, @@ -9281,7 +9311,7 @@ }, "logos-nix_325": { "inputs": { - "nixpkgs": "nixpkgs_327" + "nixpkgs": "nixpkgs_328" }, "locked": { "lastModified": 1773955630, @@ -9299,7 +9329,7 @@ }, "logos-nix_326": { "inputs": { - "nixpkgs": "nixpkgs_328" + "nixpkgs": "nixpkgs_329" }, "locked": { "lastModified": 1774455309, @@ -9317,7 +9347,7 @@ }, "logos-nix_327": { "inputs": { - "nixpkgs": "nixpkgs_329" + "nixpkgs": "nixpkgs_330" }, "locked": { "lastModified": 1773955630, @@ -9335,7 +9365,7 @@ }, "logos-nix_328": { "inputs": { - "nixpkgs": "nixpkgs_330" + "nixpkgs": "nixpkgs_331" }, "locked": { "lastModified": 1774455309, @@ -9353,7 +9383,7 @@ }, "logos-nix_329": { "inputs": { - "nixpkgs": "nixpkgs_331" + "nixpkgs": "nixpkgs_332" }, "locked": { "lastModified": 1773955630, @@ -9371,7 +9401,7 @@ }, "logos-nix_33": { "inputs": { - "nixpkgs": "nixpkgs_33" + "nixpkgs": "nixpkgs_34" }, "locked": { "lastModified": 1774455309, @@ -9389,7 +9419,7 @@ }, "logos-nix_330": { "inputs": { - "nixpkgs": "nixpkgs_332" + "nixpkgs": "nixpkgs_333" }, "locked": { "lastModified": 1773955630, @@ -9407,7 +9437,7 @@ }, "logos-nix_331": { "inputs": { - "nixpkgs": "nixpkgs_333" + "nixpkgs": "nixpkgs_334" }, "locked": { "lastModified": 1774455309, @@ -9425,7 +9455,7 @@ }, "logos-nix_332": { "inputs": { - "nixpkgs": "nixpkgs_334" + "nixpkgs": "nixpkgs_335" }, "locked": { "lastModified": 1773955630, @@ -9443,7 +9473,7 @@ }, "logos-nix_333": { "inputs": { - "nixpkgs": "nixpkgs_335" + "nixpkgs": "nixpkgs_336" }, "locked": { "lastModified": 1773955630, @@ -9461,7 +9491,7 @@ }, "logos-nix_334": { "inputs": { - "nixpkgs": "nixpkgs_336" + "nixpkgs": "nixpkgs_337" }, "locked": { "lastModified": 1773955630, @@ -9479,7 +9509,7 @@ }, "logos-nix_335": { "inputs": { - "nixpkgs": "nixpkgs_337" + "nixpkgs": "nixpkgs_338" }, "locked": { "lastModified": 1773955630, @@ -9497,7 +9527,7 @@ }, "logos-nix_336": { "inputs": { - "nixpkgs": "nixpkgs_338" + "nixpkgs": "nixpkgs_339" }, "locked": { "lastModified": 1774455309, @@ -9515,7 +9545,7 @@ }, "logos-nix_337": { "inputs": { - "nixpkgs": "nixpkgs_339" + "nixpkgs": "nixpkgs_340" }, "locked": { "lastModified": 1773955630, @@ -9533,7 +9563,7 @@ }, "logos-nix_338": { "inputs": { - "nixpkgs": "nixpkgs_340" + "nixpkgs": "nixpkgs_341" }, "locked": { "lastModified": 1774455309, @@ -9551,7 +9581,7 @@ }, "logos-nix_339": { "inputs": { - "nixpkgs": "nixpkgs_341" + "nixpkgs": "nixpkgs_342" }, "locked": { "lastModified": 1774455309, @@ -9569,7 +9599,7 @@ }, "logos-nix_34": { "inputs": { - "nixpkgs": "nixpkgs_34" + "nixpkgs": "nixpkgs_35" }, "locked": { "lastModified": 1773955630, @@ -9587,7 +9617,7 @@ }, "logos-nix_340": { "inputs": { - "nixpkgs": "nixpkgs_342" + "nixpkgs": "nixpkgs_343" }, "locked": { "lastModified": 1773955630, @@ -9605,7 +9635,7 @@ }, "logos-nix_341": { "inputs": { - "nixpkgs": "nixpkgs_343" + "nixpkgs": "nixpkgs_344" }, "locked": { "lastModified": 1773955630, @@ -9623,7 +9653,7 @@ }, "logos-nix_342": { "inputs": { - "nixpkgs": "nixpkgs_344" + "nixpkgs": "nixpkgs_345" }, "locked": { "lastModified": 1773955630, @@ -9641,7 +9671,7 @@ }, "logos-nix_343": { "inputs": { - "nixpkgs": "nixpkgs_345" + "nixpkgs": "nixpkgs_346" }, "locked": { "lastModified": 1773955630, @@ -9659,7 +9689,7 @@ }, "logos-nix_344": { "inputs": { - "nixpkgs": "nixpkgs_346" + "nixpkgs": "nixpkgs_347" }, "locked": { "lastModified": 1773955630, @@ -9677,7 +9707,7 @@ }, "logos-nix_345": { "inputs": { - "nixpkgs": "nixpkgs_347" + "nixpkgs": "nixpkgs_348" }, "locked": { "lastModified": 1774455309, @@ -9695,7 +9725,7 @@ }, "logos-nix_346": { "inputs": { - "nixpkgs": "nixpkgs_348" + "nixpkgs": "nixpkgs_349" }, "locked": { "lastModified": 1773955630, @@ -9713,7 +9743,7 @@ }, "logos-nix_347": { "inputs": { - "nixpkgs": "nixpkgs_349" + "nixpkgs": "nixpkgs_350" }, "locked": { "lastModified": 1774455309, @@ -9731,7 +9761,7 @@ }, "logos-nix_348": { "inputs": { - "nixpkgs": "nixpkgs_350" + "nixpkgs": "nixpkgs_351" }, "locked": { "lastModified": 1774455309, @@ -9749,7 +9779,7 @@ }, "logos-nix_349": { "inputs": { - "nixpkgs": "nixpkgs_351" + "nixpkgs": "nixpkgs_352" }, "locked": { "lastModified": 1773955630, @@ -9767,7 +9797,7 @@ }, "logos-nix_35": { "inputs": { - "nixpkgs": "nixpkgs_35" + "nixpkgs": "nixpkgs_36" }, "locked": { "lastModified": 1774455309, @@ -9785,7 +9815,7 @@ }, "logos-nix_350": { "inputs": { - "nixpkgs": "nixpkgs_352" + "nixpkgs": "nixpkgs_353" }, "locked": { "lastModified": 1773955630, @@ -9803,7 +9833,7 @@ }, "logos-nix_351": { "inputs": { - "nixpkgs": "nixpkgs_353" + "nixpkgs": "nixpkgs_354" }, "locked": { "lastModified": 1774455309, @@ -9821,7 +9851,7 @@ }, "logos-nix_352": { "inputs": { - "nixpkgs": "nixpkgs_354" + "nixpkgs": "nixpkgs_355" }, "locked": { "lastModified": 1774455309, @@ -9839,7 +9869,7 @@ }, "logos-nix_353": { "inputs": { - "nixpkgs": "nixpkgs_355" + "nixpkgs": "nixpkgs_356" }, "locked": { "lastModified": 1773955630, @@ -9857,7 +9887,7 @@ }, "logos-nix_354": { "inputs": { - "nixpkgs": "nixpkgs_356" + "nixpkgs": "nixpkgs_357" }, "locked": { "lastModified": 1773955630, @@ -9875,7 +9905,7 @@ }, "logos-nix_355": { "inputs": { - "nixpkgs": "nixpkgs_357" + "nixpkgs": "nixpkgs_358" }, "locked": { "lastModified": 1774455309, @@ -9893,7 +9923,7 @@ }, "logos-nix_356": { "inputs": { - "nixpkgs": "nixpkgs_358" + "nixpkgs": "nixpkgs_359" }, "locked": { "lastModified": 1774455309, @@ -9911,7 +9941,7 @@ }, "logos-nix_357": { "inputs": { - "nixpkgs": "nixpkgs_359" + "nixpkgs": "nixpkgs_360" }, "locked": { "lastModified": 1773955630, @@ -9929,7 +9959,7 @@ }, "logos-nix_358": { "inputs": { - "nixpkgs": "nixpkgs_360" + "nixpkgs": "nixpkgs_361" }, "locked": { "lastModified": 1773955630, @@ -9947,7 +9977,7 @@ }, "logos-nix_359": { "inputs": { - "nixpkgs": "nixpkgs_361" + "nixpkgs": "nixpkgs_362" }, "locked": { "lastModified": 1773955630, @@ -9965,7 +9995,7 @@ }, "logos-nix_36": { "inputs": { - "nixpkgs": "nixpkgs_36" + "nixpkgs": "nixpkgs_37" }, "locked": { "lastModified": 1774455309, @@ -9983,7 +10013,7 @@ }, "logos-nix_360": { "inputs": { - "nixpkgs": "nixpkgs_362" + "nixpkgs": "nixpkgs_363" }, "locked": { "lastModified": 1773955630, @@ -10001,7 +10031,7 @@ }, "logos-nix_361": { "inputs": { - "nixpkgs": "nixpkgs_363" + "nixpkgs": "nixpkgs_364" }, "locked": { "lastModified": 1774455309, @@ -10019,7 +10049,7 @@ }, "logos-nix_362": { "inputs": { - "nixpkgs": "nixpkgs_364" + "nixpkgs": "nixpkgs_365" }, "locked": { "lastModified": 1773955630, @@ -10037,7 +10067,7 @@ }, "logos-nix_363": { "inputs": { - "nixpkgs": "nixpkgs_365" + "nixpkgs": "nixpkgs_366" }, "locked": { "lastModified": 1773955630, @@ -10055,7 +10085,7 @@ }, "logos-nix_364": { "inputs": { - "nixpkgs": "nixpkgs_366" + "nixpkgs": "nixpkgs_367" }, "locked": { "lastModified": 1774455309, @@ -10073,7 +10103,7 @@ }, "logos-nix_365": { "inputs": { - "nixpkgs": "nixpkgs_367" + "nixpkgs": "nixpkgs_368" }, "locked": { "lastModified": 1773955630, @@ -10091,7 +10121,7 @@ }, "logos-nix_366": { "inputs": { - "nixpkgs": "nixpkgs_368" + "nixpkgs": "nixpkgs_369" }, "locked": { "lastModified": 1774455309, @@ -10109,7 +10139,7 @@ }, "logos-nix_367": { "inputs": { - "nixpkgs": "nixpkgs_369" + "nixpkgs": "nixpkgs_370" }, "locked": { "lastModified": 1774455309, @@ -10127,7 +10157,7 @@ }, "logos-nix_368": { "inputs": { - "nixpkgs": "nixpkgs_370" + "nixpkgs": "nixpkgs_371" }, "locked": { "lastModified": 1773955630, @@ -10145,7 +10175,7 @@ }, "logos-nix_369": { "inputs": { - "nixpkgs": "nixpkgs_371" + "nixpkgs": "nixpkgs_372" }, "locked": { "lastModified": 1773955630, @@ -10163,7 +10193,7 @@ }, "logos-nix_37": { "inputs": { - "nixpkgs": "nixpkgs_37" + "nixpkgs": "nixpkgs_38" }, "locked": { "lastModified": 1773955630, @@ -10181,7 +10211,7 @@ }, "logos-nix_370": { "inputs": { - "nixpkgs": "nixpkgs_372" + "nixpkgs": "nixpkgs_373" }, "locked": { "lastModified": 1773955630, @@ -10199,7 +10229,7 @@ }, "logos-nix_371": { "inputs": { - "nixpkgs": "nixpkgs_373" + "nixpkgs": "nixpkgs_374" }, "locked": { "lastModified": 1773955630, @@ -10217,7 +10247,7 @@ }, "logos-nix_372": { "inputs": { - "nixpkgs": "nixpkgs_374" + "nixpkgs": "nixpkgs_375" }, "locked": { "lastModified": 1774455309, @@ -10235,7 +10265,7 @@ }, "logos-nix_373": { "inputs": { - "nixpkgs": "nixpkgs_375" + "nixpkgs": "nixpkgs_376" }, "locked": { "lastModified": 1774455309, @@ -10253,7 +10283,7 @@ }, "logos-nix_374": { "inputs": { - "nixpkgs": "nixpkgs_376" + "nixpkgs": "nixpkgs_377" }, "locked": { "lastModified": 1773955630, @@ -10271,7 +10301,7 @@ }, "logos-nix_375": { "inputs": { - "nixpkgs": "nixpkgs_377" + "nixpkgs": "nixpkgs_378" }, "locked": { "lastModified": 1774455309, @@ -10289,7 +10319,7 @@ }, "logos-nix_376": { "inputs": { - "nixpkgs": "nixpkgs_378" + "nixpkgs": "nixpkgs_379" }, "locked": { "lastModified": 1773955630, @@ -10307,7 +10337,7 @@ }, "logos-nix_377": { "inputs": { - "nixpkgs": "nixpkgs_379" + "nixpkgs": "nixpkgs_380" }, "locked": { "lastModified": 1774455309, @@ -10325,7 +10355,7 @@ }, "logos-nix_378": { "inputs": { - "nixpkgs": "nixpkgs_380" + "nixpkgs": "nixpkgs_381" }, "locked": { "lastModified": 1774455309, @@ -10343,7 +10373,7 @@ }, "logos-nix_379": { "inputs": { - "nixpkgs": "nixpkgs_381" + "nixpkgs": "nixpkgs_382" }, "locked": { "lastModified": 1773955630, @@ -10361,7 +10391,7 @@ }, "logos-nix_38": { "inputs": { - "nixpkgs": "nixpkgs_38" + "nixpkgs": "nixpkgs_39" }, "locked": { "lastModified": 1773955630, @@ -10379,7 +10409,7 @@ }, "logos-nix_380": { "inputs": { - "nixpkgs": "nixpkgs_382" + "nixpkgs": "nixpkgs_383" }, "locked": { "lastModified": 1773955630, @@ -10397,7 +10427,7 @@ }, "logos-nix_381": { "inputs": { - "nixpkgs": "nixpkgs_383" + "nixpkgs": "nixpkgs_384" }, "locked": { "lastModified": 1774455309, @@ -10415,7 +10445,7 @@ }, "logos-nix_382": { "inputs": { - "nixpkgs": "nixpkgs_384" + "nixpkgs": "nixpkgs_385" }, "locked": { "lastModified": 1774455309, @@ -10433,7 +10463,7 @@ }, "logos-nix_383": { "inputs": { - "nixpkgs": "nixpkgs_385" + "nixpkgs": "nixpkgs_386" }, "locked": { "lastModified": 1773955630, @@ -10451,7 +10481,7 @@ }, "logos-nix_384": { "inputs": { - "nixpkgs": "nixpkgs_386" + "nixpkgs": "nixpkgs_387" }, "locked": { "lastModified": 1773955630, @@ -10469,7 +10499,7 @@ }, "logos-nix_385": { "inputs": { - "nixpkgs": "nixpkgs_387" + "nixpkgs": "nixpkgs_388" }, "locked": { "lastModified": 1774455309, @@ -10487,7 +10517,7 @@ }, "logos-nix_386": { "inputs": { - "nixpkgs": "nixpkgs_388" + "nixpkgs": "nixpkgs_389" }, "locked": { "lastModified": 1774455309, @@ -10505,7 +10535,7 @@ }, "logos-nix_387": { "inputs": { - "nixpkgs": "nixpkgs_389" + "nixpkgs": "nixpkgs_390" }, "locked": { "lastModified": 1773955630, @@ -10523,7 +10553,7 @@ }, "logos-nix_388": { "inputs": { - "nixpkgs": "nixpkgs_390" + "nixpkgs": "nixpkgs_391" }, "locked": { "lastModified": 1773955630, @@ -10541,7 +10571,7 @@ }, "logos-nix_389": { "inputs": { - "nixpkgs": "nixpkgs_391" + "nixpkgs": "nixpkgs_392" }, "locked": { "lastModified": 1773955630, @@ -10559,7 +10589,7 @@ }, "logos-nix_39": { "inputs": { - "nixpkgs": "nixpkgs_39" + "nixpkgs": "nixpkgs_40" }, "locked": { "lastModified": 1774455309, @@ -10577,7 +10607,7 @@ }, "logos-nix_390": { "inputs": { - "nixpkgs": "nixpkgs_392" + "nixpkgs": "nixpkgs_393" }, "locked": { "lastModified": 1773955630, @@ -10595,7 +10625,7 @@ }, "logos-nix_391": { "inputs": { - "nixpkgs": "nixpkgs_393" + "nixpkgs": "nixpkgs_394" }, "locked": { "lastModified": 1774455309, @@ -10613,7 +10643,7 @@ }, "logos-nix_392": { "inputs": { - "nixpkgs": "nixpkgs_394" + "nixpkgs": "nixpkgs_395" }, "locked": { "lastModified": 1773955630, @@ -10631,7 +10661,7 @@ }, "logos-nix_393": { "inputs": { - "nixpkgs": "nixpkgs_395" + "nixpkgs": "nixpkgs_396" }, "locked": { "lastModified": 1773955630, @@ -10649,7 +10679,7 @@ }, "logos-nix_394": { "inputs": { - "nixpkgs": "nixpkgs_396" + "nixpkgs": "nixpkgs_397" }, "locked": { "lastModified": 1774455309, @@ -10667,7 +10697,7 @@ }, "logos-nix_395": { "inputs": { - "nixpkgs": "nixpkgs_397" + "nixpkgs": "nixpkgs_398" }, "locked": { "lastModified": 1773955630, @@ -10685,7 +10715,7 @@ }, "logos-nix_396": { "inputs": { - "nixpkgs": "nixpkgs_398" + "nixpkgs": "nixpkgs_399" }, "locked": { "lastModified": 1773955630, @@ -10703,7 +10733,7 @@ }, "logos-nix_4": { "inputs": { - "nixpkgs": "nixpkgs_4" + "nixpkgs": "nixpkgs_5" }, "locked": { "lastModified": 1773955630, @@ -10721,7 +10751,7 @@ }, "logos-nix_40": { "inputs": { - "nixpkgs": "nixpkgs_40" + "nixpkgs": "nixpkgs_41" }, "locked": { "lastModified": 1774455309, @@ -10739,7 +10769,7 @@ }, "logos-nix_41": { "inputs": { - "nixpkgs": "nixpkgs_41" + "nixpkgs": "nixpkgs_42" }, "locked": { "lastModified": 1773955630, @@ -10757,7 +10787,7 @@ }, "logos-nix_42": { "inputs": { - "nixpkgs": "nixpkgs_42" + "nixpkgs": "nixpkgs_43" }, "locked": { "lastModified": 1773955630, @@ -10775,7 +10805,7 @@ }, "logos-nix_43": { "inputs": { - "nixpkgs": "nixpkgs_43" + "nixpkgs": "nixpkgs_44" }, "locked": { "lastModified": 1774455309, @@ -10793,7 +10823,7 @@ }, "logos-nix_44": { "inputs": { - "nixpkgs": "nixpkgs_44" + "nixpkgs": "nixpkgs_45" }, "locked": { "lastModified": 1774455309, @@ -10811,7 +10841,7 @@ }, "logos-nix_45": { "inputs": { - "nixpkgs": "nixpkgs_45" + "nixpkgs": "nixpkgs_46" }, "locked": { "lastModified": 1773955630, @@ -10829,7 +10859,7 @@ }, "logos-nix_46": { "inputs": { - "nixpkgs": "nixpkgs_46" + "nixpkgs": "nixpkgs_47" }, "locked": { "lastModified": 1773955630, @@ -10847,7 +10877,7 @@ }, "logos-nix_47": { "inputs": { - "nixpkgs": "nixpkgs_47" + "nixpkgs": "nixpkgs_48" }, "locked": { "lastModified": 1773955630, @@ -10865,7 +10895,7 @@ }, "logos-nix_48": { "inputs": { - "nixpkgs": "nixpkgs_48" + "nixpkgs": "nixpkgs_49" }, "locked": { "lastModified": 1773955630, @@ -10883,7 +10913,7 @@ }, "logos-nix_49": { "inputs": { - "nixpkgs": "nixpkgs_49" + "nixpkgs": "nixpkgs_50" }, "locked": { "lastModified": 1774455309, @@ -10901,7 +10931,7 @@ }, "logos-nix_5": { "inputs": { - "nixpkgs": "nixpkgs_5" + "nixpkgs": "nixpkgs_6" }, "locked": { "lastModified": 1774455309, @@ -10919,7 +10949,7 @@ }, "logos-nix_50": { "inputs": { - "nixpkgs": "nixpkgs_50" + "nixpkgs": "nixpkgs_51" }, "locked": { "lastModified": 1773955630, @@ -10937,7 +10967,7 @@ }, "logos-nix_51": { "inputs": { - "nixpkgs": "nixpkgs_51" + "nixpkgs": "nixpkgs_52" }, "locked": { "lastModified": 1773955630, @@ -10955,7 +10985,7 @@ }, "logos-nix_52": { "inputs": { - "nixpkgs": "nixpkgs_52" + "nixpkgs": "nixpkgs_53" }, "locked": { "lastModified": 1774455309, @@ -10973,7 +11003,7 @@ }, "logos-nix_53": { "inputs": { - "nixpkgs": "nixpkgs_53" + "nixpkgs": "nixpkgs_54" }, "locked": { "lastModified": 1773955630, @@ -10991,7 +11021,7 @@ }, "logos-nix_54": { "inputs": { - "nixpkgs": "nixpkgs_54" + "nixpkgs": "nixpkgs_55" }, "locked": { "lastModified": 1774455309, @@ -11009,7 +11039,7 @@ }, "logos-nix_55": { "inputs": { - "nixpkgs": "nixpkgs_55" + "nixpkgs": "nixpkgs_56" }, "locked": { "lastModified": 1773955630, @@ -11027,7 +11057,7 @@ }, "logos-nix_56": { "inputs": { - "nixpkgs": "nixpkgs_56" + "nixpkgs": "nixpkgs_57" }, "locked": { "lastModified": 1774455309, @@ -11045,7 +11075,7 @@ }, "logos-nix_57": { "inputs": { - "nixpkgs": "nixpkgs_57" + "nixpkgs": "nixpkgs_58" }, "locked": { "lastModified": 1773955630, @@ -11063,7 +11093,7 @@ }, "logos-nix_58": { "inputs": { - "nixpkgs": "nixpkgs_58" + "nixpkgs": "nixpkgs_59" }, "locked": { "lastModified": 1774455309, @@ -11081,7 +11111,7 @@ }, "logos-nix_59": { "inputs": { - "nixpkgs": "nixpkgs_59" + "nixpkgs": "nixpkgs_60" }, "locked": { "lastModified": 1773955630, @@ -11099,7 +11129,7 @@ }, "logos-nix_6": { "inputs": { - "nixpkgs": "nixpkgs_6" + "nixpkgs": "nixpkgs_7" }, "locked": { "lastModified": 1773955630, @@ -11117,7 +11147,7 @@ }, "logos-nix_60": { "inputs": { - "nixpkgs": "nixpkgs_60" + "nixpkgs": "nixpkgs_61" }, "locked": { "lastModified": 1774455309, @@ -11135,7 +11165,7 @@ }, "logos-nix_61": { "inputs": { - "nixpkgs": "nixpkgs_61" + "nixpkgs": "nixpkgs_62" }, "locked": { "lastModified": 1773955630, @@ -11153,7 +11183,7 @@ }, "logos-nix_62": { "inputs": { - "nixpkgs": "nixpkgs_62" + "nixpkgs": "nixpkgs_63" }, "locked": { "lastModified": 1773955630, @@ -11171,7 +11201,7 @@ }, "logos-nix_63": { "inputs": { - "nixpkgs": "nixpkgs_63" + "nixpkgs": "nixpkgs_64" }, "locked": { "lastModified": 1774455309, @@ -11189,7 +11219,7 @@ }, "logos-nix_64": { "inputs": { - "nixpkgs": "nixpkgs_64" + "nixpkgs": "nixpkgs_65" }, "locked": { "lastModified": 1773955630, @@ -11207,7 +11237,7 @@ }, "logos-nix_65": { "inputs": { - "nixpkgs": "nixpkgs_65" + "nixpkgs": "nixpkgs_66" }, "locked": { "lastModified": 1773955630, @@ -11225,7 +11255,7 @@ }, "logos-nix_66": { "inputs": { - "nixpkgs": "nixpkgs_66" + "nixpkgs": "nixpkgs_67" }, "locked": { "lastModified": 1773955630, @@ -11243,7 +11273,7 @@ }, "logos-nix_67": { "inputs": { - "nixpkgs": "nixpkgs_67" + "nixpkgs": "nixpkgs_68" }, "locked": { "lastModified": 1773955630, @@ -11261,7 +11291,7 @@ }, "logos-nix_68": { "inputs": { - "nixpkgs": "nixpkgs_68" + "nixpkgs": "nixpkgs_69" }, "locked": { "lastModified": 1774455309, @@ -11279,7 +11309,7 @@ }, "logos-nix_69": { "inputs": { - "nixpkgs": "nixpkgs_69" + "nixpkgs": "nixpkgs_70" }, "locked": { "lastModified": 1773955630, @@ -11297,7 +11327,7 @@ }, "logos-nix_7": { "inputs": { - "nixpkgs": "nixpkgs_7" + "nixpkgs": "nixpkgs_8" }, "locked": { "lastModified": 1774455309, @@ -11315,7 +11345,7 @@ }, "logos-nix_70": { "inputs": { - "nixpkgs": "nixpkgs_70" + "nixpkgs": "nixpkgs_71" }, "locked": { "lastModified": 1774455309, @@ -11333,7 +11363,7 @@ }, "logos-nix_71": { "inputs": { - "nixpkgs": "nixpkgs_71" + "nixpkgs": "nixpkgs_72" }, "locked": { "lastModified": 1774455309, @@ -11351,7 +11381,7 @@ }, "logos-nix_72": { "inputs": { - "nixpkgs": "nixpkgs_72" + "nixpkgs": "nixpkgs_73" }, "locked": { "lastModified": 1773955630, @@ -11369,7 +11399,7 @@ }, "logos-nix_73": { "inputs": { - "nixpkgs": "nixpkgs_73" + "nixpkgs": "nixpkgs_74" }, "locked": { "lastModified": 1773955630, @@ -11387,7 +11417,7 @@ }, "logos-nix_74": { "inputs": { - "nixpkgs": "nixpkgs_74" + "nixpkgs": "nixpkgs_75" }, "locked": { "lastModified": 1773955630, @@ -11405,7 +11435,7 @@ }, "logos-nix_75": { "inputs": { - "nixpkgs": "nixpkgs_75" + "nixpkgs": "nixpkgs_76" }, "locked": { "lastModified": 1773955630, @@ -11423,7 +11453,7 @@ }, "logos-nix_76": { "inputs": { - "nixpkgs": "nixpkgs_76" + "nixpkgs": "nixpkgs_77" }, "locked": { "lastModified": 1773955630, @@ -11441,7 +11471,7 @@ }, "logos-nix_77": { "inputs": { - "nixpkgs": "nixpkgs_77" + "nixpkgs": "nixpkgs_78" }, "locked": { "lastModified": 1774455309, @@ -11459,7 +11489,7 @@ }, "logos-nix_78": { "inputs": { - "nixpkgs": "nixpkgs_78" + "nixpkgs": "nixpkgs_79" }, "locked": { "lastModified": 1773955630, @@ -11477,7 +11507,7 @@ }, "logos-nix_79": { "inputs": { - "nixpkgs": "nixpkgs_79" + "nixpkgs": "nixpkgs_80" }, "locked": { "lastModified": 1774455309, @@ -11495,7 +11525,7 @@ }, "logos-nix_8": { "inputs": { - "nixpkgs": "nixpkgs_8" + "nixpkgs": "nixpkgs_9" }, "locked": { "lastModified": 1774455309, @@ -11513,7 +11543,7 @@ }, "logos-nix_80": { "inputs": { - "nixpkgs": "nixpkgs_80" + "nixpkgs": "nixpkgs_81" }, "locked": { "lastModified": 1774455309, @@ -11531,7 +11561,7 @@ }, "logos-nix_81": { "inputs": { - "nixpkgs": "nixpkgs_81" + "nixpkgs": "nixpkgs_82" }, "locked": { "lastModified": 1773955630, @@ -11549,7 +11579,7 @@ }, "logos-nix_82": { "inputs": { - "nixpkgs": "nixpkgs_82" + "nixpkgs": "nixpkgs_83" }, "locked": { "lastModified": 1773955630, @@ -11567,7 +11597,7 @@ }, "logos-nix_83": { "inputs": { - "nixpkgs": "nixpkgs_83" + "nixpkgs": "nixpkgs_84" }, "locked": { "lastModified": 1774455309, @@ -11585,7 +11615,7 @@ }, "logos-nix_84": { "inputs": { - "nixpkgs": "nixpkgs_84" + "nixpkgs": "nixpkgs_85" }, "locked": { "lastModified": 1774455309, @@ -11603,7 +11633,7 @@ }, "logos-nix_85": { "inputs": { - "nixpkgs": "nixpkgs_85" + "nixpkgs": "nixpkgs_86" }, "locked": { "lastModified": 1773955630, @@ -11621,7 +11651,7 @@ }, "logos-nix_86": { "inputs": { - "nixpkgs": "nixpkgs_86" + "nixpkgs": "nixpkgs_87" }, "locked": { "lastModified": 1773955630, @@ -11639,7 +11669,7 @@ }, "logos-nix_87": { "inputs": { - "nixpkgs": "nixpkgs_87" + "nixpkgs": "nixpkgs_88" }, "locked": { "lastModified": 1774455309, @@ -11657,7 +11687,7 @@ }, "logos-nix_88": { "inputs": { - "nixpkgs": "nixpkgs_88" + "nixpkgs": "nixpkgs_89" }, "locked": { "lastModified": 1774455309, @@ -11675,7 +11705,7 @@ }, "logos-nix_89": { "inputs": { - "nixpkgs": "nixpkgs_89" + "nixpkgs": "nixpkgs_90" }, "locked": { "lastModified": 1773955630, @@ -11693,7 +11723,7 @@ }, "logos-nix_9": { "inputs": { - "nixpkgs": "nixpkgs_9" + "nixpkgs": "nixpkgs_10" }, "locked": { "lastModified": 1774455309, @@ -11711,7 +11741,7 @@ }, "logos-nix_90": { "inputs": { - "nixpkgs": "nixpkgs_90" + "nixpkgs": "nixpkgs_91" }, "locked": { "lastModified": 1773955630, @@ -11729,7 +11759,7 @@ }, "logos-nix_91": { "inputs": { - "nixpkgs": "nixpkgs_91" + "nixpkgs": "nixpkgs_92" }, "locked": { "lastModified": 1773955630, @@ -11747,7 +11777,7 @@ }, "logos-nix_92": { "inputs": { - "nixpkgs": "nixpkgs_92" + "nixpkgs": "nixpkgs_93" }, "locked": { "lastModified": 1773955630, @@ -11765,7 +11795,7 @@ }, "logos-nix_93": { "inputs": { - "nixpkgs": "nixpkgs_93" + "nixpkgs": "nixpkgs_94" }, "locked": { "lastModified": 1774455309, @@ -11783,7 +11813,7 @@ }, "logos-nix_94": { "inputs": { - "nixpkgs": "nixpkgs_94" + "nixpkgs": "nixpkgs_95" }, "locked": { "lastModified": 1773955630, @@ -11801,7 +11831,7 @@ }, "logos-nix_95": { "inputs": { - "nixpkgs": "nixpkgs_95" + "nixpkgs": "nixpkgs_96" }, "locked": { "lastModified": 1773955630, @@ -11819,7 +11849,7 @@ }, "logos-nix_96": { "inputs": { - "nixpkgs": "nixpkgs_96" + "nixpkgs": "nixpkgs_97" }, "locked": { "lastModified": 1774455309, @@ -11837,7 +11867,7 @@ }, "logos-nix_97": { "inputs": { - "nixpkgs": "nixpkgs_97" + "nixpkgs": "nixpkgs_98" }, "locked": { "lastModified": 1773955630, @@ -11855,7 +11885,7 @@ }, "logos-nix_98": { "inputs": { - "nixpkgs": "nixpkgs_98" + "nixpkgs": "nixpkgs_99" }, "locked": { "lastModified": 1774455309, @@ -11873,7 +11903,7 @@ }, "logos-nix_99": { "inputs": { - "nixpkgs": "nixpkgs_99" + "nixpkgs": "nixpkgs_100" }, "locked": { "lastModified": 1774455309, @@ -20098,11 +20128,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1759036355, - "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", + "lastModified": 1783776592, + "narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", + "rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3", "type": "github" }, "original": { @@ -20546,32 +20576,32 @@ }, "nixpkgs_124": { "locked": { - "lastModified": 1767313136, - "narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=", + "lastModified": 1759036355, + "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d", + "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-25.05", + "ref": "nixos-unstable", "repo": "nixpkgs", "type": "github" } }, "nixpkgs_125": { "locked": { - "lastModified": 1759036355, - "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", + "lastModified": 1767313136, + "narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", + "rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-unstable", + "ref": "nixos-25.05", "repo": "nixpkgs", "type": "github" } @@ -23138,32 +23168,32 @@ }, "nixpkgs_270": { "locked": { - "lastModified": 1767313136, - "narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=", + "lastModified": 1759036355, + "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d", + "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-25.05", + "ref": "nixos-unstable", "repo": "nixpkgs", "type": "github" } }, "nixpkgs_271": { "locked": { - "lastModified": 1759036355, - "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", + "lastModified": 1767313136, + "narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", + "rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-unstable", + "ref": "nixos-25.05", "repo": "nixpkgs", "type": "github" } @@ -25408,6 +25438,22 @@ "type": "github" } }, + "nixpkgs_399": { + "locked": { + "lastModified": 1759036355, + "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs_4": { "locked": { "lastModified": 1759036355, @@ -26762,6 +26808,7 @@ }, "root": { "inputs": { + "amm_client": "amm_client", "logos-module-builder": "logos-module-builder", "logos_execution_zone": "logos_execution_zone", "shared_wallet": "shared_wallet" @@ -26834,7 +26881,7 @@ }, "rust-rapidsnark": { "inputs": { - "nixpkgs": "nixpkgs_270" + "nixpkgs": "nixpkgs_271" }, "locked": { "lastModified": 1781090841, diff --git a/apps/amm/flake.nix b/apps/amm/flake.nix index 44121c52..5a2990dc 100644 --- a/apps/amm/flake.nix +++ b/apps/amm/flake.nix @@ -26,6 +26,8 @@ inputs.logos-execution-zone.url = "github:logos-blockchain/logos-execution-zone?rev=a7e06a660940a00093b1760560d37ff84aff5a05"; }; + + amm_client.url = "path:../.."; }; # NOTE: this flake is no longer built standalone. The amm_client_ffi crate @@ -47,6 +49,12 @@ preConfigure = '' cmakeFlagsArray+=("-DLOGOS_WALLET_SOURCE_DIR=${shared_wallet}") ''; + externalLibInputs = { + amm_client = { + input = inputs.amm_client; + packages.default = "amm_client"; + }; + }; postInstall = '' # The builder installs the view under lib/qml after this hook. Its # import descriptor points back to this compiled shared QML module. diff --git a/apps/amm/metadata.json b/apps/amm/metadata.json index e5e84dca..bc2cce38 100644 --- a/apps/amm/metadata.json +++ b/apps/amm/metadata.json @@ -11,11 +11,12 @@ "nix": { "packages": { - "build": [], - "runtime": ["qt6.qtdeclarative", "zstd", "krb5", "abseil-cpp"] + "build": ["pkg-config"], + "runtime": ["qt6.qtdeclarative", "zstd", "krb5", "abseil-cpp", "libbase58"] }, "external_libraries": [ - { "name": "amm_client_ffi" } + { "name": "amm_client_ffi" }, + { "name": "amm_client" } ], "cmake": { "find_packages": [], diff --git a/apps/amm/qml/Main.qml b/apps/amm/qml/Main.qml index 1703e33d..b7000439 100644 --- a/apps/amm/qml/Main.qml +++ b/apps/amm/qml/Main.qml @@ -45,7 +45,7 @@ Item { height: show ? 32 : 0 visible: height > 0 clip: true - color: Theme.palette.error + color: Theme.palette.warning Behavior on height { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } } @@ -56,7 +56,7 @@ Item { elide: Text.ElideMiddle font.pixelSize: 12 font.weight: Font.Medium - color: Theme.palette.text + color: Theme.palette.background text: qsTr("Unable to connect to network") } } @@ -88,6 +88,8 @@ Item { LiquidityPage { anchors.fill: parent + backend: root.ready ? root.backend : null + runtime: logos visible: navbar.currentIndex === 1 } } diff --git a/apps/amm/qml/components/liquidity/AddLiquidityForm.qml b/apps/amm/qml/components/liquidity/AddLiquidityForm.qml deleted file mode 100644 index db8c59b3..00000000 --- a/apps/amm/qml/components/liquidity/AddLiquidityForm.qml +++ /dev/null @@ -1,222 +0,0 @@ -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Layouts 1.15 -import "../shared" -import "../../state" - -Rectangle { - id: root - - required property DummyPoolState poolState - - property real slippageTolerancePercent: 0.5 - property string amountA: "" - property string amountB: "" - property string lastEditedToken: "A" - readonly property real parsedA: root.poolState.parseAmount(root.amountA) - readonly property real parsedB: root.poolState.parseAmount(root.amountB) - readonly property var preview: root.poolState.addLiquidityPreview(root.parsedA, root.parsedB) - readonly property int minLpReceived: root.poolState.minReceivedAmount(root.preview.deltaLp, root.slippageTolerancePercent) - readonly property bool hasAnyAmount: root.parsedA > 0 || root.parsedB > 0 - readonly property bool amountAOverBalance: root.parsedA > root.poolState.walletBalanceA - readonly property bool amountBOverBalance: root.parsedB > root.poolState.walletBalanceB - readonly property bool minReceivedIsZero: root.hasAnyAmount && root.minLpReceived === 0 - readonly property bool zeroTokenDeposit: root.hasAnyAmount && (root.preview.actualA === 0 || root.preview.actualB === 0) - readonly property bool zeroLpDeposit: root.preview.actualA > 0 && root.preview.actualB > 0 && root.preview.deltaLp === 0 - readonly property bool canSubmit: root.hasAnyAmount && !root.amountAOverBalance && !root.amountBOverBalance && !root.minReceivedIsZero && !root.zeroTokenDeposit && !root.zeroLpDeposit - readonly property string submitButtonText: !root.hasAnyAmount ? qsTr("Enter an amount") : root.amountAOverBalance ? qsTr("Insufficient %1 balance").arg(root.poolState.tokenA) : root.amountBOverBalance ? qsTr("Insufficient %1 balance").arg(root.poolState.tokenB) : root.zeroTokenDeposit ? qsTr("Amount rounds to zero") : root.zeroLpDeposit ? qsTr("LP output is 0") : root.minReceivedIsZero ? qsTr("Minimum received is 0") : qsTr("Add Liquidity") - readonly property string warningText: root.zeroTokenDeposit ? qsTr("Deposit would be rejected because one token amount rounds to zero") : root.zeroLpDeposit ? qsTr("Deposit would mint 0 LP tokens") : "" - - signal slippageToleranceChangeRequested(real tolerancePercent) - signal addLiquidityRequested(var snapshot) - - color: "#00000000" - implicitHeight: content.implicitHeight - radius: 0 - border.width: 0 - - ColumnLayout { - id: content - - anchors.fill: parent - spacing: 10 - - TokenAmountInput { - balance: root.poolState.formatTokenAmount(root.poolState.walletBalanceA, root.poolState.tokenA) - errorText: root.amountAOverBalance ? qsTr("Insufficient %1 balance").arg(root.poolState.tokenA) : "" - helperText: root.lastEditedToken === "B" && root.amountA.length > 0 ? qsTr("Calculated from current pool ratio") : "" - label: qsTr("Token A amount") - token: root.poolState.tokenA - text: root.amountA - - Layout.fillWidth: true - - onEditingChanged: function (value) { - root.updateFromTokenA(value); - } - onMaxClicked: root.useMax("A") - } - - TokenAmountInput { - balance: root.poolState.formatTokenAmount(root.poolState.walletBalanceB, root.poolState.tokenB) - errorText: root.amountBOverBalance ? qsTr("Insufficient %1 balance").arg(root.poolState.tokenB) : "" - helperText: root.lastEditedToken === "A" && root.amountB.length > 0 ? qsTr("Calculated from current pool ratio") : "" - label: qsTr("Token B amount") - token: root.poolState.tokenB - text: root.amountB - - Layout.fillWidth: true - - onEditingChanged: function (value) { - root.updateFromTokenB(value); - } - onMaxClicked: root.useMax("B") - } - - SummaryRow { - label: qsTr("Current price") - value: qsTr("1 %1 = %2 %3").arg(root.poolState.tokenB).arg(root.poolState.formatInteger(root.poolState.tokenAPerTokenB)).arg(root.poolState.tokenA) - - Layout.fillWidth: true - } - - SummaryRow { - estimated: true - estimateHelp: qsTr("Estimated with the same integer floor math used by the add-liquidity contract path.") - label: qsTr("Estimated LP tokens") - value: root.poolState.formatLpAmount(root.preview.deltaLp) - visible: root.hasAnyAmount - - Layout.fillWidth: true - } - - SlippageToleranceControl { - tolerancePercent: root.slippageTolerancePercent - - Layout.fillWidth: true - - onToleranceChangeRequested: function (tolerancePercent) { - root.slippageToleranceChangeRequested(tolerancePercent); - } - } - - SummaryRow { - label: qsTr("Min LP received") - value: root.poolState.formatLpAmount(root.minLpReceived) - visible: root.hasAnyAmount - - Layout.fillWidth: true - } - - Text { - color: "#F08A76" - font.pixelSize: 12 - lineHeight: 1.25 - text: qsTr("Minimum received is 0. Increase amount or lower slippage.") - visible: root.minReceivedIsZero - wrapMode: Text.WordWrap - - Layout.fillWidth: true - } - - Text { - color: "#F08A76" - font.pixelSize: 12 - lineHeight: 1.25 - text: root.warningText - visible: root.warningText.length > 0 - wrapMode: Text.WordWrap - - Layout.fillWidth: true - } - - Button { - id: submitButton - - activeFocusOnTab: true - enabled: root.canSubmit - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: root.submitButtonText - - Accessible.name: submitButton.text - - Layout.fillWidth: true - Layout.minimumHeight: 44 - Layout.preferredHeight: 44 - - onClicked: root.addLiquidityRequested(root.submitSnapshot()) - - contentItem: Text { - color: submitButton.enabled ? "#151515" : "#7D756E" - elide: Text.ElideRight - font.bold: true - font.pixelSize: 13 - horizontalAlignment: Text.AlignHCenter - text: submitButton.text - verticalAlignment: Text.AlignVCenter - } - - background: Rectangle { - border.color: submitButton.enabled ? "#F26A21" : "#343434" - border.width: 1 - color: submitButton.enabled ? submitButton.pressed ? "#D95C1E" : submitButton.hovered || submitButton.activeFocus ? "#FF8A3D" : "#F26A21" : "#181818" - radius: 6 - } - } - } - - function setAmounts(nextA, nextB, intentToken, showZero) { - root.lastEditedToken = intentToken; - root.amountA = nextA > 0 || showZero ? root.poolState.formatInputAmount(nextA) : ""; - root.amountB = nextB > 0 || showZero ? root.poolState.formatInputAmount(nextB) : ""; - } - - function updateFromTokenA(value) { - if (value.length === 0) { - setAmounts(0, 0, "A", false); - return; - } - - const nextA = root.poolState.parseAmount(value); - setAmounts(nextA, root.poolState.amountBForA(nextA), "A", true); - } - - function updateFromTokenB(value) { - if (value.length === 0) { - setAmounts(0, 0, "B", false); - return; - } - - const nextB = root.poolState.parseAmount(value); - setAmounts(root.poolState.amountAForB(nextB), nextB, "B", true); - } - - function useMax(intentToken) { - const capped = root.poolState.maxAddLiquidityForBalances(); - setAmounts(capped.actualA, capped.actualB, intentToken, false); - } - - function resetForm() { - root.amountA = ""; - root.amountB = ""; - root.lastEditedToken = "A"; - } - - function submitSnapshot() { - return { - "action": "add", - "actualA": root.preview.actualA, - "actualB": root.preview.actualB, - "currentRatio": qsTr("1 %1 = %2 %3").arg(root.poolState.tokenB).arg(root.poolState.formatInteger(root.poolState.tokenAPerTokenB)).arg(root.poolState.tokenA), - "deltaLp": root.preview.deltaLp, - "depositA": root.poolState.formatTokenAmount(root.preview.actualA, root.poolState.tokenA), - "depositB": root.poolState.formatTokenAmount(root.preview.actualB, root.poolState.tokenB), - "feeTier": root.poolState.feeTier, - "minLpReceived": root.poolState.formatLpAmount(root.minLpReceived), - "slippageTolerance": root.poolState.formatPercent(root.slippageTolerancePercent), - "tokenA": root.poolState.tokenA, - "tokenB": root.poolState.tokenB - }; - } -} diff --git a/apps/amm/qml/components/liquidity/AmmActionCard.qml b/apps/amm/qml/components/liquidity/AmmActionCard.qml new file mode 100644 index 00000000..d8f3151d --- /dev/null +++ b/apps/amm/qml/components/liquidity/AmmActionCard.qml @@ -0,0 +1,15 @@ +import QtQuick + +Rectangle { + required property var theme + + implicitWidth: 480 + radius: 24 + color: theme.colors.cardBg + border.color: theme.colors.border + border.width: 1 + + Behavior on color { + ColorAnimation { duration: 180 } + } +} diff --git a/apps/amm/qml/components/liquidity/AmmPairSeparator.qml b/apps/amm/qml/components/liquidity/AmmPairSeparator.qml new file mode 100644 index 00000000..8c578cac --- /dev/null +++ b/apps/amm/qml/components/liquidity/AmmPairSeparator.qml @@ -0,0 +1,61 @@ +import QtQuick +import QtQuick.Controls + +Item { + id: root + + required property var theme + property string symbol: "\u2193" + property string accessibleName: qsTr("Swap token order") + + signal clicked + + implicitHeight: 40 + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right + height: 1 + color: root.theme.colors.divider + } + + Button { + id: button + + anchors.centerIn: parent + width: 36 + height: 36 + hoverEnabled: true + enabled: root.enabled + + Accessible.name: root.accessibleName + ToolTip.visible: hovered + ToolTip.text: Accessible.name + + onClicked: root.clicked() + + contentItem: Text { + text: root.symbol + color: button.enabled + ? root.theme.colors.textPrimary + : root.theme.colors.textPlaceholder + font.pixelSize: 16 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + radius: 18 + color: button.hovered || button.activeFocus + ? root.theme.colors.panelHoverBg + : root.theme.colors.panelBg + border.color: root.theme.colors.borderStrong + border.width: 1 + + Behavior on color { + ColorAnimation { duration: 120 } + } + } + } +} diff --git a/apps/amm/qml/components/liquidity/AmmPrimaryButton.qml b/apps/amm/qml/components/liquidity/AmmPrimaryButton.qml new file mode 100644 index 00000000..f75f3d18 --- /dev/null +++ b/apps/amm/qml/components/liquidity/AmmPrimaryButton.qml @@ -0,0 +1,43 @@ +import QtQuick +import QtQuick.Controls + +Button { + id: root + + required property var theme + + activeFocusOnTab: true + focusPolicy: Qt.StrongFocus + hoverEnabled: true + implicitHeight: 56 + + Accessible.name: text + + contentItem: Text { + text: root.text + color: root.enabled ? "#FFFFFF" : root.theme.colors.textSecondary + font.pixelSize: 17 + font.weight: Font.Medium + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + + background: Rectangle { + radius: 20 + color: !root.enabled + ? root.theme.colors.panelBg + : root.pressed + ? root.theme.colors.ctaPressedBg + : root.hovered || root.activeFocus + ? root.theme.colors.ctaHoverBg + : root.theme.colors.ctaBg + border.color: root.activeFocus && root.enabled + ? root.theme.colors.textPrimary : "transparent" + border.width: 1 + + Behavior on color { + ColorAnimation { duration: 120 } + } + } +} diff --git a/apps/amm/qml/components/liquidity/AmmTheme.qml b/apps/amm/qml/components/liquidity/AmmTheme.qml new file mode 100644 index 00000000..06a847b8 --- /dev/null +++ b/apps/amm/qml/components/liquidity/AmmTheme.qml @@ -0,0 +1,50 @@ +import QtQml + +QtObject { + property bool isDark: true + readonly property var colors: isDark ? dark : light + + readonly property var light: ({ + "background": "#F4EDE3", + "cardBg": "#FFFFFF", + "inputBg": "#EFE7DB", + "panelBg": "#E7E1D8", + "panelHoverBg": "#D9D0C2", + "textPrimary": "#151515", + "textSecondary": "#7D756E", + "textPlaceholder": "#A9A098", + "border": Qt.rgba(0, 0, 0, 0.08), + "borderStrong": Qt.rgba(0, 0, 0, 0.10), + "divider": Qt.rgba(0, 0, 0, 0.06), + "ctaBg": "#F26A21", + "ctaHoverBg": "#D95C1E", + "ctaPressedBg": "#C85018", + "selection": "#F2D8C7", + "noTokenCircle": "#A9A098", + "success": "#4F9B64", + "warning": "#B8732A", + "error": "#D85F4B" + }) + + readonly property var dark: ({ + "background": "#151515", + "cardBg": "#1B1B1B", + "inputBg": "#101010", + "panelBg": "#181818", + "panelHoverBg": "#202020", + "textPrimary": "#E7E1D8", + "textSecondary": "#A9A098", + "textPlaceholder": "#8E8780", + "border": Qt.rgba(1, 1, 1, 0.08), + "borderStrong": Qt.rgba(1, 1, 1, 0.10), + "divider": Qt.rgba(1, 1, 1, 0.06), + "ctaBg": "#F26A21", + "ctaHoverBg": "#FF8A3D", + "ctaPressedBg": "#D95C1E", + "selection": "#211914", + "noTokenCircle": "#343434", + "success": "#78C88D", + "warning": "#F2B366", + "error": "#F08A76" + }) +} diff --git a/apps/amm/qml/components/liquidity/AmmTokenAccessory.qml b/apps/amm/qml/components/liquidity/AmmTokenAccessory.qml new file mode 100644 index 00000000..df3919e3 --- /dev/null +++ b/apps/amm/qml/components/liquidity/AmmTokenAccessory.qml @@ -0,0 +1,46 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts + +ColumnLayout { + id: root + + required property var theme + property bool hasToken: false + property string tokenColor: root.theme.colors.noTokenCircle + property string tokenLetter: "" + property string tokenText: qsTr("Select token") + property string balance: "" + property string accessibleName: qsTr("Select token") + property bool invalid: false + + signal clicked + + spacing: 2 + + Text { + Layout.fillWidth: true + visible: root.balance.length > 0 + text: qsTr("Balance %1").arg(root.balance) + color: root.theme.colors.textSecondary + font.pixelSize: 10 + horizontalAlignment: Text.AlignRight + elide: Text.ElideRight + } + + AmmTokenSelectButton { + objectName: "tokenSelectButton" + Layout.fillWidth: true + theme: root.theme + enabled: root.enabled + invalid: root.invalid + hasToken: root.hasToken + tokenColor: root.tokenColor + tokenLetter: root.tokenLetter + text: root.tokenText + maximumTextWidth: 112 + Accessible.name: root.accessibleName + onClicked: root.clicked() + } +} diff --git a/apps/amm/qml/components/liquidity/AmmTokenAmountSurface.qml b/apps/amm/qml/components/liquidity/AmmTokenAmountSurface.qml new file mode 100644 index 00000000..39ccd5d7 --- /dev/null +++ b/apps/amm/qml/components/liquidity/AmmTokenAmountSurface.qml @@ -0,0 +1,195 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Rectangle { + id: root + + required property var theme + property string label: "" + property string amount: "" + property string supportingText: "" + property string errorText: "" + property string supportingActionText: "" + property bool readOnly: false + property bool muted: false + property bool invalid: root.errorText.length > 0 + property Component accessory + property real accessoryWidth: 0 + property real accessoryHeight: 0 + property Component adjustment + property real adjustmentWidth: 0 + property real adjustmentHeight: 0 + + signal amountEdited(string value) + signal amountEditingFinished(string value) + signal supportingActionClicked + + implicitHeight: Math.max(110, contentRow.implicitHeight + 24) + radius: 16 + color: root.muted ? root.theme.colors.panelBg : root.theme.colors.inputBg + border.color: root.invalid + ? root.theme.colors.error + : amountInput.activeFocus + ? root.theme.colors.ctaBg + : "transparent" + border.width: 1 + + Binding { + target: amountInput + property: "text" + value: root.amount + } + + RowLayout { + id: contentRow + + anchors.fill: parent + anchors.leftMargin: 16 + anchors.rightMargin: 16 + anchors.topMargin: 12 + anchors.bottomMargin: 12 + spacing: 10 + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Text { + text: root.label + color: root.theme.colors.textSecondary + font.pixelSize: 13 + elide: Text.ElideRight + Layout.fillWidth: true + } + + Text { + Layout.fillWidth: true + visible: root.errorText.length > 0 + text: root.errorText + color: root.theme.colors.error + font.pixelSize: 11 + wrapMode: Text.Wrap + } + + Item { + Layout.fillWidth: true + Layout.preferredHeight: 44 + + TextInput { + id: amountInput + + anchors.fill: parent + readOnly: root.readOnly + color: root.readOnly || root.muted + ? root.theme.colors.textSecondary + : root.theme.colors.textPrimary + font.pixelSize: { + var length = Math.max(1, String(root.amount || "0").length) + return Math.max(14, Math.min(34, Math.floor(width / (length * 0.68)))) + } + font.weight: Font.Bold + horizontalAlignment: Text.AlignLeft + selectionColor: root.theme.colors.selection + selectedTextColor: root.theme.colors.textPrimary + clip: true + inputMethodHints: Qt.ImhFormattedNumbersOnly + maximumLength: 80 + validator: RegularExpressionValidator { + regularExpression: /^[0-9]*\.?[0-9]*$/ + } + + Accessible.name: root.label + + onTextEdited: { + if (!root.readOnly) + root.amountEdited(text) + } + onEditingFinished: { + if (!root.readOnly) + root.amountEditingFinished(text) + } + } + + Text { + anchors.fill: parent + text: "0" + color: root.theme.colors.textPlaceholder + font: amountInput.font + visible: amountInput.text === "" && !amountInput.activeFocus + verticalAlignment: Text.AlignVCenter + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 6 + + Loader { + active: root.adjustment !== null + visible: active + sourceComponent: root.adjustment + Layout.preferredWidth: active ? root.adjustmentWidth : 0 + Layout.preferredHeight: active ? root.adjustmentHeight : 0 + } + + Button { + id: supportingAction + + visible: root.supportingActionText.length > 0 + enabled: visible && !root.readOnly + implicitWidth: 40 + implicitHeight: 24 + text: root.supportingActionText + hoverEnabled: true + Accessible.name: text + onClicked: root.supportingActionClicked() + + contentItem: Text { + text: supportingAction.text + color: supportingAction.enabled + ? root.theme.colors.ctaBg + : root.theme.colors.textPlaceholder + font.pixelSize: 11 + font.weight: Font.Bold + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + radius: 6 + color: supportingAction.hovered || supportingAction.activeFocus + ? root.theme.colors.selection : "transparent" + } + } + + Text { + Layout.fillWidth: true + text: root.supportingText + color: root.theme.colors.textSecondary + font.pixelSize: 11 + horizontalAlignment: root.adjustment !== null + || root.supportingActionText.length > 0 + ? Text.AlignRight : Text.AlignLeft + elide: Text.ElideRight + visible: text.length > 0 + } + } + } + + Loader { + sourceComponent: root.accessory + visible: status === Loader.Ready + Layout.alignment: Qt.AlignTop + Layout.topMargin: 17 + Layout.preferredWidth: root.accessoryWidth + Layout.preferredHeight: root.accessoryHeight + } + } + + Behavior on color { + ColorAnimation { duration: 180 } + } +} diff --git a/apps/amm/qml/components/liquidity/AmmTokenSelectButton.qml b/apps/amm/qml/components/liquidity/AmmTokenSelectButton.qml new file mode 100644 index 00000000..859af85c --- /dev/null +++ b/apps/amm/qml/components/liquidity/AmmTokenSelectButton.qml @@ -0,0 +1,80 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Button { + id: root + + required property var theme + property bool hasToken: false + property string tokenColor: root.theme.colors.noTokenCircle + property string tokenLetter: "" + property bool showIndicator: true + property bool invalid: false + property real maximumTextWidth: 112 + + implicitWidth: contentRow.implicitWidth + 24 + implicitHeight: 40 + leftPadding: 12 + rightPadding: 12 + hoverEnabled: true + + contentItem: RowLayout { + id: contentRow + + spacing: 6 + + Rectangle { + Layout.preferredWidth: 24 + Layout.preferredHeight: 24 + visible: root.hasToken + radius: 12 + color: root.tokenColor + + Text { + anchors.centerIn: parent + text: root.tokenLetter + color: "#FFFFFF" + font.pixelSize: 10 + font.weight: Font.Bold + } + } + + Text { + Layout.maximumWidth: root.maximumTextWidth + text: root.text + color: root.enabled + ? root.theme.colors.textPrimary + : root.theme.colors.textPlaceholder + font.pixelSize: 15 + font.weight: root.hasToken ? Font.Medium : Font.Normal + elide: Text.ElideRight + } + + Text { + visible: root.showIndicator + text: "\u25BE" + color: root.enabled + ? root.theme.colors.textSecondary + : root.theme.colors.textPlaceholder + font.pixelSize: 10 + } + } + + background: Rectangle { + radius: 20 + color: root.pressed + ? root.theme.colors.selection + : root.hovered || root.activeFocus + ? root.theme.colors.panelHoverBg + : root.theme.colors.panelBg + border.width: root.invalid || root.activeFocus ? 1 : 0 + border.color: root.invalid ? root.theme.colors.error : root.theme.colors.ctaBg + + Behavior on color { + ColorAnimation { duration: 120 } + } + } +} diff --git a/apps/amm/qml/components/liquidity/AmountMath.js b/apps/amm/qml/components/liquidity/AmountMath.js new file mode 100644 index 00000000..11309831 --- /dev/null +++ b/apps/amm/qml/components/liquidity/AmountMath.js @@ -0,0 +1,295 @@ +.pragma library + +var base58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" +var U128_MAX = "340282366920938463463374607431768211455" +var U32_MAX = "4294967295" +var Q64 = "18446744073709551616" + +function normalize(value) { + var text = String(value === undefined || value === null ? "" : value) + var index = 0 + while (index + 1 < text.length && text.charAt(index) === "0") + ++index + return text.length > 0 ? text.slice(index) : "0" +} + +function isUnsigned(value) { + return /^[0-9]+$/.test(String(value)) +} + +function compare(left, right) { + var a = normalize(left) + var b = normalize(right) + if (a.length !== b.length) + return a.length < b.length ? -1 : 1 + if (a === b) + return 0 + return a < b ? -1 : 1 +} + +function compareBase58Ids(left, right) { + var leftStart = 0 + var rightStart = 0 + while (leftStart < left.length && left[leftStart] === "1") + ++leftStart + while (rightStart < right.length && right[rightStart] === "1") + ++rightStart + + var leftLength = left.length - leftStart + var rightLength = right.length - rightStart + if (leftLength !== rightLength) + return leftLength < rightLength ? -1 : 1 + + for (var i = 0; i < leftLength; ++i) { + var leftDigit = base58Alphabet.indexOf(left[leftStart + i]) + var rightDigit = base58Alphabet.indexOf(right[rightStart + i]) + if (leftDigit < 0 || rightDigit < 0) + return 0 + if (leftDigit !== rightDigit) + return leftDigit < rightDigit ? -1 : 1 + } + return 0 +} + +function subtract(left, right) { + var a = normalize(left) + var b = normalize(right) + var result = [] + var borrow = 0 + var j = b.length - 1 + for (var i = a.length - 1; i >= 0; --i) { + var digit = Number(a.charAt(i)) - borrow - (j >= 0 ? Number(b.charAt(j)) : 0) + if (digit < 0) { + digit += 10 + borrow = 1 + } else { + borrow = 0 + } + result.unshift(String(digit)) + --j + } + return normalize(result.join("")) +} + +function multiply(left, right) { + var a = normalize(left) + var b = normalize(right) + if (a === "0" || b === "0") + return "0" + + var result = [] + for (var z = 0; z < a.length + b.length; ++z) + result.push(0) + + for (var i = a.length - 1; i >= 0; --i) { + for (var j = b.length - 1; j >= 0; --j) { + var low = i + j + 1 + var high = i + j + var product = Number(a.charAt(i)) * Number(b.charAt(j)) + result[low] + result[low] = product % 10 + result[high] += Math.floor(product / 10) + } + } + return normalize(result.join("")) +} + +function divide(left, right) { + var numerator = normalize(left) + var denominator = normalize(right) + if (denominator === "0") + return { "quotient": "0", "remainder": numerator, "valid": false } + + var quotient = "" + var remainder = "0" + for (var i = 0; i < numerator.length; ++i) { + remainder = normalize((remainder === "0" ? "" : remainder) + numerator.charAt(i)) + var digit = 0 + while (compare(remainder, denominator) >= 0) { + remainder = subtract(remainder, denominator) + ++digit + } + quotient += String(digit) + } + return { "quotient": normalize(quotient), "remainder": remainder, "valid": true } +} + +function increment(value) { + var digits = normalize(value).split("") + for (var i = digits.length - 1; i >= 0; --i) { + if (digits[i] !== "9") { + digits[i] = String(Number(digits[i]) + 1) + return digits.join("") + } + digits[i] = "0" + } + digits.unshift("1") + return digits.join("") +} + +function pow10(exponent) { + var value = "1" + for (var i = 0; i < exponent; ++i) + value += "0" + return value +} + +function parseHuman(text, decimals) { + var value = String(text) + if (value.length === 0) + return { "ok": false, "code": "amount_required", "raw": "" } + var match = /^(0|[1-9][0-9]*)(?:\.([0-9]*))?$/.exec(value) + if (!match) + return { "ok": false, "code": "invalid_amount_format", "raw": "" } + + var fraction = match[2] || "" + if (fraction.length > decimals) + return { "ok": false, "code": "invalid_amount_precision", "raw": "" } + var raw = normalize(match[1] + fraction + pow10(decimals - fraction.length).slice(1)) + if (compare(raw, U128_MAX) > 0) + return { "ok": false, "code": "invalid_raw_amount", "raw": "" } + return { "ok": true, "code": "", "raw": raw } +} + +function formatRaw(rawValue, decimals) { + if (!isUnsigned(rawValue)) + return "" + var raw = normalize(rawValue) + if (decimals === 0) + return raw + while (raw.length <= decimals) + raw = "0" + raw + var whole = raw.slice(0, raw.length - decimals) + var fraction = raw.slice(raw.length - decimals).replace(/0+$/, "") + return fraction.length > 0 ? whole + "." + fraction : whole +} + +function parsePrice(text) { + var value = String(text) + if (value.length === 0) + return { "ok": false, "code": "amount_required" } + var match = /^(0|[1-9][0-9]*)(?:\.([0-9]*))?$/.exec(value) + if (!match) + return { "ok": false, "code": "invalid_amount_format" } + var fraction = match[2] || "" + var numerator = normalize(match[1] + fraction) + if (numerator === "0") + return { "ok": false, "code": "amount_must_be_positive" } + return { + "ok": true, + "code": "", + "numerator": numerator, + "scale": fraction.length + } +} + +function parseRatio(amountA, amountB) { + var parsedA = parsePrice(amountA) + if (!parsedA.ok) + return { "ok": false, "code": parsedA.code } + var parsedB = parsePrice(amountB) + if (!parsedB.ok) + return { "ok": false, "code": parsedB.code } + + return { + "ok": true, + "code": "", + "numerator": multiply(parsedB.numerator, pow10(parsedA.scale)), + "denominator": multiply(parsedA.numerator, pow10(parsedB.scale)) + } +} + +function ratioToQ64(amountA, amountB, canonicalDecimalsA, canonicalDecimalsB, + displayIsCanonical) { + var ratio = parseRatio(amountA, amountB) + if (!ratio.ok) + return { "ok": false, "code": ratio.code, "raw": "" } + + var numerator + var denominator + if (displayIsCanonical) { + numerator = multiply(multiply(ratio.numerator, Q64), pow10(canonicalDecimalsB)) + denominator = multiply(ratio.denominator, pow10(canonicalDecimalsA)) + } else { + numerator = multiply(multiply(ratio.denominator, Q64), pow10(canonicalDecimalsB)) + denominator = multiply(ratio.numerator, pow10(canonicalDecimalsA)) + } + var raw = divide(numerator, denominator).quotient + if (raw === "0") + return { "ok": false, "code": "amount_too_low", "raw": "" } + if (compare(raw, U128_MAX) > 0) + return { "ok": false, "code": "invalid_raw_amount", "raw": "" } + return { "ok": true, "code": "", "raw": raw } +} + +function pairAmount(raw, fromA, decimalsA, decimalsB, priceAmountA, priceAmountB) { + if (!isUnsigned(raw)) + return { "ok": false, "code": "invalid_raw_amount", "raw": "" } + var ratio = parseRatio(priceAmountA, priceAmountB) + if (!ratio.ok) + return { "ok": false, "code": ratio.code, "raw": "" } + + var numerator + var denominator + if (fromA) { + numerator = multiply(multiply(raw, ratio.numerator), pow10(decimalsB)) + denominator = multiply(ratio.denominator, pow10(decimalsA)) + } else { + numerator = multiply(multiply(raw, ratio.denominator), pow10(decimalsA)) + denominator = multiply(ratio.numerator, pow10(decimalsB)) + } + var result = divide(numerator, denominator) + if (!result.valid) + return { "ok": false, "code": "invalid_raw_amount", "raw": "" } + var rounded = compare(multiply(result.remainder, "2"), denominator) >= 0 + ? increment(result.quotient) : result.quotient + if (rounded === "0") + return { "ok": false, "code": "amount_too_low", "raw": "" } + if (compare(rounded, U128_MAX) > 0) + return { "ok": false, "code": "invalid_raw_amount", "raw": "" } + return { "ok": true, "code": "", "raw": rounded } +} + +function ratioValue(amountA, amountB, precision) { + var ratio = parseRatio(amountA, amountB) + return ratio.ok ? formatRatio(ratio.numerator, ratio.denominator, precision) : "" +} + +function formatRatio(numerator, denominator, precision) { + var result = divide(numerator, denominator) + if (!result.valid) + return "" + var fraction = "" + var remainder = result.remainder + for (var i = 0; i < precision && remainder !== "0"; ++i) { + var digit = divide(multiply(remainder, "10"), denominator) + fraction += digit.quotient + remainder = digit.remainder + } + fraction = fraction.replace(/0+$/, "") + return fraction.length > 0 ? result.quotient + "." + fraction : result.quotient +} + +function priceFromQ64(rawValue, canonicalDecimalsA, canonicalDecimalsB, displayIsCanonical) { + if (!isUnsigned(rawValue) || normalize(rawValue) === "0") + return "" + var numerator + var denominator + if (displayIsCanonical) { + numerator = multiply(rawValue, pow10(canonicalDecimalsA)) + denominator = multiply(Q64, pow10(canonicalDecimalsB)) + } else { + numerator = multiply(Q64, pow10(canonicalDecimalsB)) + denominator = multiply(rawValue, pow10(canonicalDecimalsA)) + } + return formatRatio(numerator, denominator, 12) +} + +function mulDivFloor(left, right, denominator) { + return divide(multiply(left, right), denominator).quotient +} + +function toU32(value) { + if (!isUnsigned(value) || compare(value, U32_MAX) > 0) + return -1 + return Number(normalize(value)) +} diff --git a/apps/amm/qml/components/liquidity/LiquidityActionTabs.qml b/apps/amm/qml/components/liquidity/LiquidityActionTabs.qml deleted file mode 100644 index 5a7c1d1f..00000000 --- a/apps/amm/qml/components/liquidity/LiquidityActionTabs.qml +++ /dev/null @@ -1,91 +0,0 @@ -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Layouts 1.15 - -Rectangle { - id: root - - property int currentIndex: 0 - - signal tabRequested(int index) - - color: "#181818" - implicitHeight: 42 - radius: 8 - border.color: "#303030" - border.width: 1 - - RowLayout { - anchors.fill: parent - anchors.margins: 4 - spacing: 4 - - Button { - id: addTab - - activeFocusOnTab: true - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: qsTr("Add liquidity") - - Accessible.name: addTab.text - Accessible.role: Accessible.PageTab - - Layout.fillHeight: true - Layout.fillWidth: true - - onClicked: root.tabRequested(0) - - contentItem: Text { - color: root.currentIndex === 0 ? "#F2D8C7" : addTab.hovered || addTab.activeFocus ? "#E7E1D8" : "#8E8780" - elide: Text.ElideRight - font.bold: true - font.pixelSize: 12 - horizontalAlignment: Text.AlignHCenter - text: addTab.text - verticalAlignment: Text.AlignVCenter - } - - background: Rectangle { - border.color: addTab.activeFocus || root.currentIndex === 0 ? "#F26A21" : "#181818" - border.width: 1 - color: addTab.pressed ? "#2A1D16" : root.currentIndex === 0 ? "#211914" : addTab.hovered || addTab.activeFocus ? "#202020" : "#121212" - radius: 6 - } - } - - Button { - id: removeTab - - activeFocusOnTab: true - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: qsTr("Remove liquidity") - - Accessible.name: removeTab.text - Accessible.role: Accessible.PageTab - - Layout.fillHeight: true - Layout.fillWidth: true - - onClicked: root.tabRequested(1) - - contentItem: Text { - color: root.currentIndex === 1 ? "#F2D8C7" : removeTab.hovered || removeTab.activeFocus ? "#E7E1D8" : "#8E8780" - elide: Text.ElideRight - font.bold: true - font.pixelSize: 12 - horizontalAlignment: Text.AlignHCenter - text: removeTab.text - verticalAlignment: Text.AlignVCenter - } - - background: Rectangle { - border.color: removeTab.activeFocus || root.currentIndex === 1 ? "#F26A21" : "#181818" - border.width: 1 - color: removeTab.pressed ? "#2A1D16" : root.currentIndex === 1 ? "#211914" : removeTab.hovered || removeTab.activeFocus ? "#202020" : "#121212" - radius: 6 - } - } - } -} diff --git a/apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml b/apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml index 68af40da..cbcc3b68 100644 --- a/apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml +++ b/apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml @@ -5,87 +5,47 @@ ColumnLayout { id: root property var snapshot: ({}) - readonly property bool isAdd: root.snapshot.action === "add" spacing: 8 - ColumnLayout { - Layout.fillWidth: true - spacing: 8 - visible: root.isAdd - - SummaryRow { - Layout.fillWidth: true - label: qsTr("Deposit %1").arg(root.snapshot.tokenA || "") - value: root.snapshot.depositA || "" - } - - SummaryRow { - Layout.fillWidth: true - label: qsTr("Deposit %1").arg(root.snapshot.tokenB || "") - value: root.snapshot.depositB || "" - } - - SummaryRow { - Layout.fillWidth: true - label: qsTr("Receive at least") - value: root.snapshot.minLpReceived || "" - } - - SummaryRow { - Layout.fillWidth: true - label: qsTr("Current ratio") - value: root.snapshot.currentRatio || "" - } - - SummaryRow { - Layout.fillWidth: true - label: qsTr("Fee tier") - value: root.snapshot.feeTier || "" - } - - SummaryRow { - Layout.fillWidth: true - label: qsTr("Slippage tolerance") - value: root.snapshot.slippageTolerance || "" - } + function actionText(instruction) { + if (instruction === "NewDefinition") + return qsTr("Create pool") + if (instruction === "AddLiquidity") + return qsTr("Add liquidity") + return instruction || "-" } - ColumnLayout { + SummaryRow { Layout.fillWidth: true - spacing: 8 - visible: !root.isAdd - - SummaryRow { - Layout.fillWidth: true - label: qsTr("Burn LP") - value: qsTr("%1 (%2)") - .arg(root.snapshot.burnText || "") - .arg(root.snapshot.burnPercent || "") - } + label: qsTr("Pair") + value: root.snapshot.pairText || "-" + } - SummaryRow { - Layout.fillWidth: true - label: qsTr("Receive at least %1").arg(root.snapshot.tokenA || "") - value: root.snapshot.minTokenAReceived || "" - } + SummaryRow { + Layout.fillWidth: true + label: qsTr("Action") + value: root.actionText(root.snapshot.instruction) + } - SummaryRow { - Layout.fillWidth: true - label: qsTr("Receive at least %1").arg(root.snapshot.tokenB || "") - value: root.snapshot.minTokenBReceived || "" - } + SummaryRow { + Layout.fillWidth: true + label: qsTr("Fee") + value: root.snapshot.feeText || "-" + } - SummaryRow { - Layout.fillWidth: true - label: qsTr("Slippage tolerance") - value: root.snapshot.slippageTolerance || "" - } + SummaryRow { + Layout.fillWidth: true + label: qsTr("Deposit") + value: qsTr("%1 + %2") + .arg(root.snapshot.depositAText || "-") + .arg(root.snapshot.depositBText || "-") + valueWrapAnywhere: true + } - SummaryRow { - Layout.fillWidth: true - label: qsTr("Post-removal share") - value: root.snapshot.postRemovalShare || "" - } + SummaryRow { + Layout.fillWidth: true + label: qsTr("Expected LP") + value: root.snapshot.expectedLpText || "-" } } diff --git a/apps/amm/qml/components/liquidity/NewPositionForm.qml b/apps/amm/qml/components/liquidity/NewPositionForm.qml new file mode 100644 index 00000000..c70000fc --- /dev/null +++ b/apps/amm/qml/components/liquidity/NewPositionForm.qml @@ -0,0 +1,1542 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import Logos.Controls +import Logos.Icons +import Logos.Wallet + +import "AmountMath.js" as AmountMath + +AmmActionCard { + id: root + + theme: fallbackTheme + + AmmTheme { + id: fallbackTheme + } + + property var newPositionContext: ({}) + property var flowState: ({}) + property string selectedTokenAId: "" + property string selectedTokenBId: "" + property int selectedFeeBps: 30 + property int slippageBps: 50 + property string amountA: "" + property string amountB: "" + property string priceAmountA: "1" + property string priceAmountB: "1" + property string minimumAmountARaw: "" + property string minimumAmountBRaw: "" + property var localErrors: [] + property string resolvingTokenId: "" + property string resolvingTokenSide: "" + property string tokenResolutionError: "" + property string tokenResolutionErrorSide: "" + property string tokenResolutionMessage: "" + property string confirmedPoolStatus: "" + property var activePoolQuote: ({}) + property string headingText: qsTr("New position") + property string headingDetail: "" + property bool showRefreshAction: true + + readonly property var quotePayload: root.flowState.quote || ({}) + readonly property bool contextLoading: root.flowState.contextLoading === true + readonly property bool quoteLoading: root.flowState.quoteLoading === true + readonly property bool submitting: root.flowState.submitting === true + readonly property bool quoteStale: root.flowState.quoteStale === true + readonly property bool poolCreationPending: root.flowState.poolCreationPending === true + readonly property string submitError: root.flowState.errorCode + ? root.issueText(root.flowState.errorCode) : "" + readonly property string transactionId: String(root.flowState.transactionId || "") + readonly property var emptyToken: ({ + "definitionId": "", + "name": "", + "totalSupplyRaw": "0", + "balanceRaw": "0", + "selectable": false + }) + readonly property var tokens: root.newPositionContext && root.newPositionContext.tokens + ? root.newPositionContext.tokens : [] + readonly property var feeTiers: root.newPositionContext && root.newPositionContext.feeTiers + ? root.newPositionContext.feeTiers : [] + readonly property var tokenA: root.tokenById(root.selectedTokenAId) + readonly property var tokenB: root.tokenById(root.selectedTokenBId) + readonly property int decimalsA: 0 + readonly property int decimalsB: 0 + readonly property bool displayIsCanonical: root.selectedTokenAId.length > 0 + && AmountMath.compareBase58Ids(root.selectedTokenAId, + root.selectedTokenBId) > 0 + readonly property int canonicalDecimalsA: root.displayIsCanonical ? root.decimalsA : root.decimalsB + readonly property int canonicalDecimalsB: root.displayIsCanonical ? root.decimalsB : root.decimalsA + readonly property string initialPrice: AmountMath.ratioValue(root.priceAmountA, + root.priceAmountB, + 12) + readonly property string inverseInitialPrice: AmountMath.ratioValue(root.priceAmountB, + root.priceAmountA, + 12) + readonly property string poolStatus: root.effectivePoolStatus() + readonly property bool activePool: root.poolStatus === "active_pool" + readonly property bool missingPool: root.poolStatus === "missing_pool" + readonly property int poolFeeBps: root.knownPoolFeeBps() + readonly property bool compact: root.width < 420 + readonly property bool hasPair: root.selectedTokenAId.length > 0 + && root.selectedTokenBId.length > 0 + && root.selectedTokenAId !== root.selectedTokenBId + readonly property bool resolvingToken: root.resolvingTokenId.length > 0 + readonly property bool canConfirm: root.quotePayload.schema === "new-position.v1" + && root.quotePayload.status === "ok" + && root.quotePayload.canSubmit === true + && root.quoteMatchesPair() + && String(root.quotePayload.quoteHash || "").length > 0 + && !root.contextLoading + && !root.quoteLoading + && !root.quoteStale + && !root.submitting + && !root.poolCreationPending + + signal quoteRequested(bool immediate, var quoteRequest) + signal confirmationRequested(var snapshot) + signal tokenResolveRequested(string tokenId) + signal draftChanged + signal refreshRequested + + readonly property int contentPadding: width >= 600 ? 24 : 16 + + implicitHeight: content.implicitHeight + root.contentPadding * 2 + implicitWidth: 480 + + Component.onCompleted: Qt.callLater(root.ensurePair) + onNewPositionContextChanged: Qt.callLater(root.applyContextChange) + function applyContextChange() { + if (root.resolvingToken) + root.finishTokenResolution() + else + root.ensurePair() + } + onQuotePayloadChanged: { + if (root.quoteStale) + return + root.rememberPoolStatus() + root.rememberActivePoolQuote() + Qt.callLater(root.applyQuoteSideEffects) + } + + Component { + id: priceAmountAAdjustment + + PriceRatioAdjustment { + amount: root.priceAmountA + enabled: !root.submitting + fieldName: "priceAmountAField" + invalid: root.fieldHasError("initialPrice") + onEdited: function(value) { root.editPrice("A", value) } + } + } + + Component { + id: priceAmountBAdjustment + + PriceRatioAdjustment { + amount: root.priceAmountB + enabled: !root.submitting + fieldName: "priceAmountBField" + invalid: root.fieldHasError("initialPrice") + onEdited: function(value) { root.editPrice("B", value) } + } + } + + ColumnLayout { + id: content + + anchors.fill: parent + anchors.margins: root.contentPadding + spacing: 16 + + RowLayout { + Layout.fillWidth: true + spacing: 12 + + ColumnLayout { + Layout.fillWidth: true + spacing: 3 + + Text { + text: root.headingText + color: root.theme.colors.textPrimary + font.pixelSize: 18 + font.weight: Font.DemiBold + font.letterSpacing: 0 + } + + Text { + text: root.headingDetail.length > 0 + ? root.headingDetail : root.contextStatusText() + color: root.theme.colors.textSecondary + font.pixelSize: 12 + elide: Text.ElideRight + Layout.fillWidth: true + } + } + + BusyIndicator { + running: root.contextLoading || root.quoteLoading + visible: running + implicitWidth: 24 + implicitHeight: 24 + } + + LogosIconButton { + iconSource: LogosIcons.refresh + iconColor: root.theme.colors.textSecondary + iconSize: 18 + Layout.preferredWidth: 36 + Layout.preferredHeight: 36 + enabled: !root.contextLoading && !root.submitting + visible: root.showRefreshAction + Accessible.name: qsTr("Refresh position data") + ToolTip.visible: hovered + ToolTip.text: Accessible.name + onClicked: root.refreshRequested() + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: networkMessage.implicitHeight + 20 + radius: 6 + color: root.theme.colors.panelBg + border.color: root.theme.colors.error + visible: root.contextBlocksForm() + + Text { + id: networkMessage + anchors.fill: parent + anchors.margins: 10 + text: root.contextErrorText() + color: root.theme.colors.textPrimary + font.pixelSize: 12 + wrapMode: Text.Wrap + verticalAlignment: Text.AlignVCenter + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + TokenAmountInput { + id: tokenAInput + + objectName: "tokenAAmountInput" + Layout.fillWidth: true + theme: root.theme + text: root.amountA + label: qsTr("Token A amount") + balance: root.contextLoading ? "" : root.balanceText(root.tokenA, root.decimalsA) + helperText: root.missingPool && !root.compact + ? root.minimumAmountText("A") : "" + errorText: root.formErrorText() + invalid: root.fieldHasError("amountA") + readOnly: root.submitting || (!root.activePool && !root.missingPool) + showMaxButton: root.activePool + tokenData: root.tokenA.definitionId ? root.tokenA : null + tokens: root.tokens + selectedTokenId: root.selectedTokenAId + tokenInvalid: root.tokenHasError("A") + tokenSelectionEnabled: !root.contextLoading && !root.submitting + adjustment: root.missingPool ? priceAmountAAdjustment : null + adjustmentWidth: root.missingPool ? (root.compact ? 100 : 142) : 0 + adjustmentHeight: root.missingPool ? 24 : 0 + disabledReasonForCode: function(code) { return root.issueText(code) } + detailForToken: function(token) { return root.tokenBalanceDetail(token) } + onEditingChanged: function(value) { + if (root.activePool) + root.editActiveAmount("A", value) + else + root.editMissingAmount("A", value) + } + onEditingCommitted: function(value) { + if (root.activePool) + root.finishActiveAmount("A", value) + else + root.finishMissingAmount("A", value) + } + onMaxClicked: root.useMaximum() + onTokenSelected: function(tokenId) { root.resolveToken("A", tokenId) } + onTokenEntered: function(value) { root.resolveToken("A", value) } + } + + AmmPairSeparator { + Layout.fillWidth: true + theme: root.theme + enabled: root.hasPair && !root.submitting + onClicked: root.swapTokens() + } + + TokenAmountInput { + id: tokenBInput + + objectName: "tokenBAmountInput" + Layout.fillWidth: true + theme: root.theme + text: root.amountB + label: qsTr("Token B amount") + balance: root.contextLoading ? "" : root.balanceText(root.tokenB, root.decimalsB) + helperText: root.missingPool && !root.compact + ? root.minimumAmountText("B") : "" + invalid: root.fieldHasError("amountB") + readOnly: root.submitting || (!root.activePool && !root.missingPool) + showMaxButton: root.activePool + tokenData: root.tokenB.definitionId ? root.tokenB : null + tokens: root.tokens + selectedTokenId: root.selectedTokenBId + tokenInvalid: root.tokenHasError("B") + tokenSelectionEnabled: !root.contextLoading && !root.submitting + adjustment: root.missingPool ? priceAmountBAdjustment : null + adjustmentWidth: root.missingPool ? (root.compact ? 100 : 142) : 0 + adjustmentHeight: root.missingPool ? 24 : 0 + disabledReasonForCode: function(code) { return root.issueText(code) } + detailForToken: function(token) { return root.tokenBalanceDetail(token) } + onEditingChanged: function(value) { + if (root.activePool) + root.editActiveAmount("B", value) + else + root.editMissingAmount("B", value) + } + onEditingCommitted: function(value) { + if (root.activePool) + root.finishActiveAmount("B", value) + else + root.finishMissingAmount("B", value) + } + onMaxClicked: root.useMaximum() + onTokenSelected: function(tokenId) { root.resolveToken("B", tokenId) } + onTokenEntered: function(value) { root.resolveToken("B", value) } + } + } + + Text { + Layout.fillWidth: true + visible: root.resolvingToken || root.tokenResolutionMessage.length > 0 + text: root.resolvingToken + ? qsTr("Resolving TokenDefinition...") + : root.tokenResolutionMessage + color: root.theme.colors.textSecondary + font.pixelSize: 11 + wrapMode: Text.Wrap + } + + Text { + Layout.fillWidth: true + visible: text.length > 0 + text: root.activePool ? root.activePriceText() + : root.missingPool ? root.depositMultiplierText() : "" + color: root.theme.colors.textSecondary + font.pixelSize: 12 + horizontalAlignment: Text.AlignRight + wrapMode: Text.Wrap + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 1 + color: root.theme.colors.divider + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 8 + visible: !root.contextLoading + + Text { + text: qsTr("Fee tier") + color: root.theme.colors.textPrimary + font.pixelSize: 13 + font.weight: Font.Medium + } + + GridLayout { + Layout.fillWidth: true + columns: root.compact ? 2 : 4 + columnSpacing: 8 + rowSpacing: 8 + + Repeater { + model: root.feeTiers + + Item { + id: feeTierOption + + required property var modelData + readonly property string disabledReason: root.feeDisabledReason(modelData) + readonly property bool invalid: root.fieldHasError("feeBps") + && feeTierButton.checked + Layout.fillWidth: true + implicitHeight: 40 + + Button { + id: feeTierButton + + anchors.fill: parent + text: parent.modelData.label || root.feeLabel(parent.modelData.feeBps) + checkable: true + checked: root.selectedFeeBps === parent.modelData.feeBps + enabled: parent.disabledReason.length === 0 && !root.submitting + onClicked: root.selectFee(parent.modelData.feeBps) + + contentItem: Text { + text: feeTierButton.text + color: feeTierButton.enabled + ? root.theme.colors.textPrimary + : root.theme.colors.textPlaceholder + font.pixelSize: 12 + font.weight: Font.Medium + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + radius: 6 + color: feeTierButton.checked + ? root.theme.colors.selection + : root.theme.colors.inputBg + border.color: feeTierOption.invalid + ? root.theme.colors.error + : feeTierButton.checked + ? root.theme.colors.ctaBg + : root.theme.colors.borderStrong + border.width: 1 + } + } + + MouseArea { + id: disabledFeeHover + anchors.fill: parent + enabled: parent.disabledReason.length > 0 + hoverEnabled: true + acceptedButtons: Qt.NoButton + } + + ToolTip.visible: disabledFeeHover.containsMouse + ToolTip.text: disabledReason + } + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 10 + visible: root.activePool + + Text { + text: qsTr("Slippage") + color: root.theme.colors.textSecondary + font.pixelSize: 12 + Layout.fillWidth: true + } + + Item { + Layout.preferredWidth: slippageControl.implicitWidth + Layout.preferredHeight: slippageControl.implicitHeight + + LogosSpinBox { + id: slippageControl + + anchors.fill: parent + from: 0 + to: 5000 + stepSize: 10 + editable: true + value: root.slippageBps + enabled: !root.submitting + textFromValue: function(value) { return root.formatBps(value) } + valueFromText: function(text) { + var parsed = AmountMath.parseHuman(String(text).replace("%", "").trim(), 2) + return parsed.ok ? AmountMath.toU32(parsed.raw) : root.slippageBps + } + onValueModified: { + root.slippageBps = value + root.noteDraftChanged() + root.requestQuote(true) + } + } + + Rectangle { + anchors.fill: parent + visible: root.fieldHasError("slippageBps") + color: "transparent" + radius: 6 + border.color: root.theme.colors.error + border.width: 1 + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 9 + visible: root.quotePayload.status === "ok" + && root.quoteMatchesPair() + && !root.quoteStale + + Rectangle { + Layout.fillWidth: true + implicitHeight: 1 + color: root.theme.colors.divider + } + + LabelValueRow { + label: root.activePool ? qsTr("Expected spend") : qsTr("Opening deposit") + value: root.depositSummary() + } + + LabelValueRow { + visible: root.missingPool + label: qsTr("Initial price") + value: root.initialPriceText(false) + } + + LabelValueRow { + visible: root.missingPool + label: qsTr("Inverse price") + value: root.initialPriceText(true) + } + + LabelValueRow { + visible: root.missingPool + label: qsTr("Deposit multiplier") + value: root.depositMultiplierValue() + } + + LabelValueRow { + visible: root.missingPool + label: qsTr("Deposit scale") + value: root.depositBasisPointsText() + } + + LabelValueRow { + label: qsTr("Expected LP") + value: root.rawLpText(root.quotePayload.expectedLpRaw) + } + + LabelValueRow { + label: root.activePool ? qsTr("Minimum LP") : qsTr("Locked LP") + value: root.rawLpText(root.activePool + ? root.quotePayload.minimumLpRaw + : root.quotePayload.lockedLpRaw) + } + + LabelValueRow { + label: qsTr("Pool") + value: String(root.quotePayload.poolId || "") + valueWrapAnywhere: true + } + + LogosButton { + id: accountPlanButton + text: qsTr("Account plan (%1)").arg(root.accountPreview().length) + enabled: root.accountPreview().length > 0 + property bool checked: false + implicitWidth: 150 + implicitHeight: 36 + radius: 6 + Layout.alignment: Qt.AlignLeft + onClicked: checked = !checked + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 5 + visible: accountPlanButton.checked + + Repeater { + model: root.accountPreview() + + LabelValueRow { + required property var modelData + label: qsTr("%1. %2 · %3").arg(modelData.order + 1).arg(modelData.role).arg(modelData.action) + value: modelData.accountId ? modelData.accountId : qsTr("Assigned by wallet") + valueWrapAnywhere: true + } + } + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: warningTextItem.implicitHeight + 20 + radius: 6 + color: root.theme.colors.panelBg + border.color: root.theme.colors.ctaBg + visible: root.warningText().length > 0 + + Text { + id: warningTextItem + anchors.fill: parent + anchors.margins: 10 + text: root.warningText() + color: root.theme.colors.textPrimary + font.pixelSize: 12 + wrapMode: Text.Wrap + verticalAlignment: Text.AlignVCenter + } + } + + SubmittedTransaction { + Layout.fillWidth: true + title: qsTr("Position submitted") + transactionId: root.transactionId + visible: root.transactionId.length > 0 + } + + AmmPrimaryButton { + Layout.fillWidth: true + Layout.minimumHeight: 56 + theme: root.theme + text: root.submitting + ? qsTr("Submitting…") + : root.contextLoading ? qsTr("Loading…") + : root.poolCreationPending ? qsTr("Waiting for pool") + : root.missingPool ? qsTr("Create pool") : qsTr("Add liquidity") + enabled: root.canConfirm + onClicked: root.confirmationRequested(root.submissionSnapshot()) + } + } + + component LabelValueRow: RowLayout { + required property string label + required property string value + property bool valueWrapAnywhere: false + Layout.fillWidth: true + spacing: 12 + + Text { + text: parent.label + color: root.theme.colors.textSecondary + font.pixelSize: 12 + Layout.fillWidth: true + wrapMode: Text.Wrap + } + + Text { + text: parent.value + color: root.theme.colors.textPrimary + font.pixelSize: 12 + font.weight: Font.Medium + horizontalAlignment: Text.AlignRight + wrapMode: parent.valueWrapAnywhere ? Text.WrapAnywhere : Text.Wrap + Layout.maximumWidth: root.compact ? 190 : 280 + } + } + + component PriceRatioAdjustment: RowLayout { + id: ratioAdjustment + + required property string amount + required property string fieldName + required property bool invalid + + signal edited(string value) + + implicitWidth: 142 + implicitHeight: 24 + spacing: 6 + + Text { + text: qsTr("Price") + color: root.theme.colors.textSecondary + font.pixelSize: 11 + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 24 + radius: 6 + color: root.theme.colors.panelBg + border.color: ratioAdjustment.invalid + ? root.theme.colors.error + : ratioInput.activeFocus + ? root.theme.colors.ctaBg + : root.theme.colors.borderStrong + border.width: 1 + + TextInput { + id: ratioInput + + objectName: ratioAdjustment.fieldName + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + text: ratioAdjustment.amount + enabled: ratioAdjustment.enabled + color: enabled ? root.theme.colors.textPrimary + : root.theme.colors.textSecondary + font.pixelSize: 12 + selectionColor: root.theme.colors.selection + selectedTextColor: root.theme.colors.textPrimary + horizontalAlignment: Text.AlignRight + verticalAlignment: Text.AlignVCenter + inputMethodHints: Qt.ImhFormattedNumbersOnly + maximumLength: 80 + validator: RegularExpressionValidator { + regularExpression: /[0-9]*([.][0-9]*)?/ + } + Accessible.name: qsTr("Initial price ratio amount") + onTextEdited: ratioAdjustment.edited(text) + } + } + } + + function tokenById(tokenId) { + for (var i = 0; i < root.tokens.length; ++i) { + if (root.tokens[i].definitionId === tokenId) + return root.tokens[i] + } + return root.emptyToken + } + + function selectableTokenIds() { + var result = [] + for (var i = 0; i < root.tokens.length; ++i) { + if (root.tokens[i].selectable === true) + result.push(root.tokens[i].definitionId) + } + return result + } + + function isBase58TokenId(tokenId) { + return /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(String(tokenId || "")) + } + + function resolveToken(side, value) { + var tokenId = String(value || "").trim() + root.tokenResolutionError = "" + root.tokenResolutionErrorSide = "" + root.tokenResolutionMessage = "" + if (!root.isBase58TokenId(tokenId)) { + root.tokenResolutionError = root.issueText("invalid_token_id") + root.tokenResolutionErrorSide = side + return + } + + var current = root.tokenById(tokenId) + if (current.definitionId === tokenId) { + if (current.selectable === true) + root.selectToken(side, tokenId) + else { + root.tokenResolutionError = root.issueText(current.code || current.status) + root.tokenResolutionErrorSide = side + } + return + } + root.resolvingTokenId = tokenId + root.resolvingTokenSide = side + root.tokenResolveRequested(tokenId) + } + + function finishTokenResolution(finalResponse) { + if (!root.resolvingToken) + return + var token = root.tokenById(root.resolvingTokenId) + if (!token || !token.definitionId) { + if (finalResponse === true) { + var currentStatus = String(root.newPositionContext.status || "") + var code = currentStatus !== "ready" && currentStatus !== "no_wallet" + && currentStatus !== "loading" + ? root.newPositionContext.code || currentStatus + : "token_definition_unreadable" + root.failTokenResolution(code) + } + return + } + var status = String(root.newPositionContext.status || "") + if (status === "loading") + return + if (status !== "ready" && status !== "no_wallet") { + root.failTokenResolution(root.newPositionContext.code || status) + return + } + var side = root.resolvingTokenSide + root.resolvingTokenId = "" + root.resolvingTokenSide = "" + if (token.selectable !== true) { + root.tokenResolutionError = root.issueText(token.code || token.status) + root.tokenResolutionErrorSide = side + return + } + + root.tokenResolutionMessage = qsTr("%1 - raw supply %2") + .arg(token.name || root.shortId(token.definitionId)) + .arg(AmountMath.formatRaw(token.totalSupplyRaw || "0", 0)) + root.selectToken(side, token.definitionId) + } + + function failTokenResolution(code) { + if (!root.resolvingToken) + return + var side = root.resolvingTokenSide + root.resolvingTokenId = "" + root.resolvingTokenSide = "" + root.tokenResolutionError = root.issueText(code) + root.tokenResolutionErrorSide = side + } + + function ensurePair() { + var status = String(root.newPositionContext.status || "") + if (status !== "ready" && status !== "no_wallet") + return + var previousA = root.selectedTokenAId + var previousB = root.selectedTokenBId + var selectable = root.selectableTokenIds() + if (selectable.length === 0) { + root.selectedTokenAId = "" + root.selectedTokenBId = "" + if (previousA.length > 0 || previousB.length > 0) + root.resetPairDraft() + return + } + if (selectable.indexOf(root.selectedTokenAId) < 0) + root.selectedTokenAId = selectable[0] + if (selectable.indexOf(root.selectedTokenBId) < 0 + || root.selectedTokenBId === root.selectedTokenAId) { + root.selectedTokenBId = selectable.length > 1 ? selectable[1] : "" + } + if (root.selectedTokenAId !== previousA || root.selectedTokenBId !== previousB) + root.resetPairDraft() + else if (root.hasPair) + root.requestQuote(true) + } + + function selectToken(side, tokenId) { + if (tokenId.length === 0) + return + root.tokenResolutionError = "" + root.tokenResolutionErrorSide = "" + if (side === "A") { + if (tokenId === root.selectedTokenBId) + root.selectedTokenBId = root.selectedTokenAId + root.selectedTokenAId = tokenId + } else { + if (tokenId === root.selectedTokenAId) + root.selectedTokenAId = root.selectedTokenBId + root.selectedTokenBId = tokenId + } + root.resetPairDraft() + } + + function swapTokens() { + var tokenId = root.selectedTokenAId + root.selectedTokenAId = root.selectedTokenBId + root.selectedTokenBId = tokenId + var amount = root.amountA + root.amountA = root.amountB + root.amountB = amount + var minimum = root.minimumAmountARaw + root.minimumAmountARaw = root.minimumAmountBRaw + root.minimumAmountBRaw = minimum + var priceAmount = root.priceAmountA + root.priceAmountA = root.priceAmountB + root.priceAmountB = priceAmount + root.noteDraftChanged() + root.requestQuote(true) + } + + function resetPairDraft() { + root.confirmedPoolStatus = "" + root.activePoolQuote = ({}) + root.amountA = "" + root.amountB = "" + root.priceAmountA = "1" + root.priceAmountB = "1" + root.minimumAmountARaw = "" + root.minimumAmountBRaw = "" + root.localErrors = [] + root.noteDraftChanged() + root.requestQuote(true) + } + + function acceptPoolActivation(quote) { + if (!quote || quote.schema !== "new-position.v1" + || quote.status !== "ok" + || quote.poolStatus !== "active_pool" + || !root.quoteMatchesSelectedPair(quote)) { + return false + } + root.confirmedPoolStatus = "active_pool" + root.activePoolQuote = quote + root.amountA = "" + root.amountB = "" + root.minimumAmountARaw = "" + root.minimumAmountBRaw = "" + root.localErrors = [] + return true + } + + function noteDraftChanged() { + root.draftChanged() + } + + function effectivePoolStatus() { + if (root.quoteStale || !root.quoteMatchesPair()) + return root.confirmedPoolStatus + var status = String(root.quotePayload.poolStatus || "") + if (status === "active_pool" || status === "missing_pool") + return status + if (root.quotePayload.code === "fee_tier_mismatch") + return "active_pool" + return root.confirmedPoolStatus + } + + function rememberPoolStatus() { + if (!root.quoteMatchesPair()) + return + var status = String(root.quotePayload.poolStatus || "") + if (status === "active_pool" || status === "missing_pool") + root.confirmedPoolStatus = status + else if (root.quotePayload.code === "fee_tier_mismatch") + root.confirmedPoolStatus = "active_pool" + } + + function rememberActivePoolQuote() { + if (root.quotePayload.status !== "ok" + || root.quotePayload.poolStatus !== "active_pool" + || !root.quoteMatchesPair()) { + return + } + var reserveA = String(root.quotePayload.reserveARaw || "") + var reserveB = String(root.quotePayload.reserveBRaw || "") + if (AmountMath.isUnsigned(reserveA) && reserveA !== "0" + && AmountMath.isUnsigned(reserveB) && reserveB !== "0") { + root.activePoolQuote = root.quotePayload + } + } + + function knownPoolFeeBps() { + var direct = root.feeBpsFromQuote(root.quotePayload) + if (direct > 0) + return direct + if (root.quoteMatchesSelectedPair(root.activePoolQuote)) + return Number(root.activePoolQuote.poolFeeBps || 0) + return 0 + } + + function feeBpsFromQuote(quote) { + if (!root.quoteMatchesSelectedPair(quote)) + return 0 + var direct = Number(quote.poolFeeBps || 0) + if (direct > 0) + return direct + var errors = quote.errors || [] + for (var i = 0; i < errors.length; ++i) { + var value = Number(errors[i].details ? errors[i].details.poolFeeBps : 0) + if (value > 0) + return value + } + return 0 + } + + function quoteMatchesPair() { + return root.quoteMatchesSelectedPair(root.quotePayload) + } + + function quoteMatchesSelectedPair(quote) { + var tokenAId = String(quote.tokenAId || "") + var tokenBId = String(quote.tokenBId || "") + return root.hasPair + && ((tokenAId === root.selectedTokenAId && tokenBId === root.selectedTokenBId) + || (tokenAId === root.selectedTokenBId && tokenBId === root.selectedTokenAId)) + } + + function selectFee(feeBps) { + root.selectedFeeBps = feeBps + root.noteDraftChanged() + root.requestQuote(true) + } + + function feeDisabledReason(tier) { + if (tier.enabled === false) + return tier.disabledReason || qsTr("This fee tier is unavailable.") + if (root.poolFeeBps > 0 && Number(tier.feeBps) !== root.poolFeeBps) + return qsTr("Existing pool uses %1. Fee tier is fixed for this pair.") + .arg(root.feeLabel(root.poolFeeBps)) + return "" + } + + function feeLabel(feeBps) { + if (feeBps === 1) + return "0.01%" + if (feeBps === 5) + return "0.05%" + if (feeBps === 30) + return "0.30%" + if (feeBps === 100) + return "1.00%" + return root.formatBps(feeBps) + } + + function buildQuoteRequest() { + var errors = [] + if (!root.hasPair) { + errors.push(root.localIssue("token_pair_required", ["tokenAId", "tokenBId"])) + root.localErrors = errors + return { "ok": false, "errors": errors, "request": ({}) } + } + + var canonicalAId = root.displayIsCanonical ? root.selectedTokenAId : root.selectedTokenBId + var canonicalBId = root.displayIsCanonical ? root.selectedTokenBId : root.selectedTokenAId + var request = { + "schema": "new-position.v1", + "tokenAId": canonicalAId, + "tokenBId": canonicalBId, + "feeBps": root.selectedFeeBps + } + + if (root.activePool) { + var parsedA = AmountMath.parseHuman(root.amountA, root.decimalsA) + var parsedB = AmountMath.parseHuman(root.amountB, root.decimalsB) + if (!parsedA.ok) + errors.push(root.localIssue(parsedA.code, ["amountA"])) + if (!parsedB.ok) + errors.push(root.localIssue(parsedB.code, ["amountB"])) + if (errors.length === 0) { + request.maxAmountARaw = root.displayIsCanonical ? parsedA.raw : parsedB.raw + request.maxAmountBRaw = root.displayIsCanonical ? parsedB.raw : parsedA.raw + request.slippageBps = root.slippageBps + } + } else { + var priceFromAmounts = false + var price = AmountMath.ratioToQ64(root.priceAmountA, + root.priceAmountB, + root.canonicalDecimalsA, + root.canonicalDecimalsB, + root.displayIsCanonical) + if (!price.ok) + errors.push(root.localIssue(price.code, ["initialPrice"])) + if (root.missingPool && root.minimumAmountARaw.length > 0) { + var parsedMissingA = AmountMath.parseHuman(root.amountA, root.decimalsA) + var parsedMissingB = AmountMath.parseHuman(root.amountB, root.decimalsB) + if (!parsedMissingA.ok) + errors.push(root.localIssue(parsedMissingA.code, ["amountA"])) + if (!parsedMissingB.ok) + errors.push(root.localIssue(parsedMissingB.code, ["amountB"])) + if (parsedMissingA.ok && parsedMissingB.ok) { + if (AmountMath.compare(parsedMissingA.raw, root.minimumAmountARaw) < 0) + errors.push(root.localIssue("amount_too_low", ["amountA"])) + if (AmountMath.compare(parsedMissingB.raw, root.minimumAmountBRaw) < 0) + errors.push(root.localIssue("amount_too_low", ["amountB"])) + var pairedB = AmountMath.pairAmount(parsedMissingA.raw, + true, + root.decimalsA, + root.decimalsB, + root.priceAmountA, + root.priceAmountB) + var pairedA = AmountMath.pairAmount(parsedMissingB.raw, + false, + root.decimalsA, + root.decimalsB, + root.priceAmountA, + root.priceAmountB) + var matchesA = pairedA.ok + && AmountMath.normalize(pairedA.raw) + === AmountMath.normalize(parsedMissingA.raw) + var matchesB = pairedB.ok + && AmountMath.normalize(pairedB.raw) + === AmountMath.normalize(parsedMissingB.raw) + if (!matchesA && !matchesB) { + errors.push(root.localIssue("deposit_ratio_mismatch", ["amountA", "amountB"])) + } + if (errors.length === 0) { + request.amountARaw = root.displayIsCanonical + ? parsedMissingA.raw : parsedMissingB.raw + request.amountBRaw = root.displayIsCanonical + ? parsedMissingB.raw : parsedMissingA.raw + var actualPrice = AmountMath.ratioToQ64(root.amountA, + root.amountB, + root.canonicalDecimalsA, + root.canonicalDecimalsB, + root.displayIsCanonical) + if (actualPrice.ok) { + request.initialPriceRealRaw = actualPrice.raw + priceFromAmounts = true + } else { + errors.push(root.localIssue(actualPrice.code, ["initialPrice"])) + } + } + } + } + if (price.ok && !priceFromAmounts) + request.initialPriceRealRaw = price.raw + + if (!root.missingPool) { + var probeA = root.probeRaw(root.tokenA, root.decimalsA) + var probeB = root.probeRaw(root.tokenB, root.decimalsB) + request.maxAmountARaw = root.displayIsCanonical ? probeA : probeB + request.maxAmountBRaw = root.displayIsCanonical ? probeB : probeA + request.slippageBps = root.slippageBps + } + } + + root.localErrors = errors + return { "ok": errors.length === 0, "errors": errors, "request": request } + } + + function requestQuote(immediate) { + if (root.activePool && root.amountA.length === 0 && root.amountB.length === 0) { + root.localErrors = [] + return + } + root.quoteRequested(immediate, root.buildQuoteRequest()) + } + + function probeRaw(token, decimals) { + var balance = String(token.balanceRaw || "0") + var simulated = AmountMath.multiply(AmountMath.pow10(decimals), "1000") + if (AmountMath.isUnsigned(balance) && AmountMath.compare(balance, simulated) > 0) + return balance + return simulated + } + + function poolProbeRequest(request) { + var probe = {} + for (var field in request) + probe[field] = request[field] + var amountA = root.probeRaw(root.tokenA, root.decimalsA) + var amountB = root.probeRaw(root.tokenB, root.decimalsB) + probe.maxAmountARaw = root.displayIsCanonical ? amountA : amountB + probe.maxAmountBRaw = root.displayIsCanonical ? amountB : amountA + probe.slippageBps = root.slippageBps + return probe + } + + function formatBps(value) { + return qsTr("%1%").arg(AmountMath.formatRaw(String(value), 2)) + } + + function localIssue(code, fields) { + return { "code": code, "blockingFields": fields, "details": ({}) } + } + + function canonicalFieldToDisplay(field) { + if (field === "tokenAId") + return root.displayIsCanonical ? "tokenAId" : "tokenBId" + if (field === "tokenBId") + return root.displayIsCanonical ? "tokenBId" : "tokenAId" + if (field === "maxAmountARaw") + return root.displayIsCanonical ? "amountA" : "amountB" + if (field === "maxAmountBRaw") + return root.displayIsCanonical ? "amountB" : "amountA" + if (field === "amountARaw") + return root.displayIsCanonical ? "amountA" : "amountB" + if (field === "amountBRaw") + return root.displayIsCanonical ? "amountB" : "amountA" + if (field === "initialPriceRealRaw") + return "initialPrice" + return field + } + + function fieldError(field) { + var collections = [root.localErrors, root.currentQuoteErrors()] + for (var c = 0; c < collections.length; ++c) { + for (var i = 0; i < collections[c].length; ++i) { + var fields = collections[c][i].blockingFields || [] + for (var f = 0; f < fields.length; ++f) { + if (root.canonicalFieldToDisplay(fields[f]) === field) + return root.issueText(collections[c][i].code) + } + } + } + return "" + } + + function fieldHasError(field) { + return root.fieldError(field).length > 0 + } + + function tokenHasError(side) { + if (root.tokenResolutionError.length > 0 + && root.tokenResolutionErrorSide === side) { + return true + } + return root.fieldHasError(side === "A" ? "tokenAId" : "tokenBId") + } + + function formErrorText() { + if (root.tokenResolutionError.length > 0) + return root.tokenResolutionError + if (root.submitError.length > 0) + return root.submitError + var collections = [root.localErrors, root.currentQuoteErrors()] + for (var c = 0; c < collections.length; ++c) { + for (var i = 0; i < collections[c].length; ++i) { + var code = String(collections[c][i].code || "") + if (code.length > 0) + return root.issueText(code) + } + } + return root.quoteError() + } + + function currentQuoteErrors() { + return !root.quoteStale && root.quoteMatchesPair() + ? root.quotePayload.errors || [] : [] + } + + function issueText(code) { + var messages = { + "amount_required": qsTr("Enter a value."), + "amount_must_be_positive": qsTr("Value must be greater than zero."), + "invalid_amount_format": qsTr("Use plain dot-decimal format."), + "invalid_amount_precision": qsTr("Token amounts must use whole raw units."), + "invalid_raw_amount": qsTr("Value is outside the supported range."), + "amount_exceeds_balance": qsTr("Amount exceeds the selected holding balance."), + "amount_too_low": qsTr("Value is too low for this pool."), + "invalid_token_id": qsTr("Enter a valid base58 TokenDefinition ID."), + "deposit_ratio_mismatch": qsTr("Deposit amounts must match the initial price."), + "minimum_lp_zero": qsTr("Slippage leaves no minimum LP output."), + "invalid_slippage": qsTr("Slippage must be between 0% and 50%."), + "fee_tier_mismatch": qsTr("Select the existing pool fee tier."), + "no_wallet": qsTr("Connect a wallet to submit this position."), + "wallet_unavailable": qsTr("Wallet is unavailable."), + "wallet_submission_failed": qsTr("Wallet submission failed. Review and retry manually."), + "signature_rejected": qsTr("Wallet approval was rejected."), + "quote_changed": qsTr("Pool or wallet state changed. Review the refreshed quote."), + "quote_not_submittable": qsTr("Current quote cannot be submitted."), + "submit_in_progress": qsTr("A submission is already in progress."), + "transaction_deadline_expired": qsTr("Wallet approval expired. Retry to create a fresh request."), + "high_slippage": qsTr("High slippage tolerance."), + "token_definition_unreadable": qsTr("A token definition could not be read."), + "token_program_mismatch": qsTr("Token belongs to a different TokenProgram."), + "token_not_fungible": qsTr("Token is not a public fungible token."), + "backend_error": qsTr("Position backend failed. Refresh and retry."), + "network_unknown": qsTr("Network identity could not be verified. Refresh and retry."), + "network_mismatch": qsTr("Connected wallet uses a different network."), + "config_missing": qsTr("Network configuration is missing."), + "account_read_failed": qsTr("Required on-chain state could not be read."), + "pool_unavailable": qsTr("Pool state is unavailable."), + "config_unavailable": qsTr("AMM configuration is unavailable."), + "same_token_pair": qsTr("Select two different tokens."), + "token_pair_required": qsTr("Select two tokens.") + } + return messages[code] || qsTr("Position quote is unavailable (%1).").arg(code || "unknown") + } + + function editActiveAmount(side, value) { + if (side === "A") + root.amountA = value + else + root.amountB = value + + root.localErrors = [] + root.noteDraftChanged() + } + + function finishActiveAmount(side, value) { + var decimals = side === "A" ? root.decimalsA : root.decimalsB + if (side === "A") + root.amountA = value + else + root.amountB = value + + var reserveA = root.poolReserve("A") + var reserveB = root.poolReserve("B") + var parsed = AmountMath.parseHuman(value, decimals) + if (parsed.ok && reserveA !== "" && reserveB !== "" + && reserveA !== "0" && reserveB !== "0") { + if (side === "A") { + var rawB = AmountMath.mulDivFloor(parsed.raw, reserveB, reserveA) + root.amountB = AmountMath.formatRaw(rawB, root.decimalsB) + } else { + var rawA = AmountMath.mulDivFloor(parsed.raw, reserveA, reserveB) + root.amountA = AmountMath.formatRaw(rawA, root.decimalsA) + } + } + root.requestQuote(true) + } + + function useMaximum() { + var reserveA = root.poolReserve("A") + var reserveB = root.poolReserve("B") + if (!reserveA || !reserveB || reserveA === "0" || reserveB === "0") + return + var balanceA = String(root.tokenA.balanceRaw || "0") + var balanceB = String(root.tokenB.balanceRaw || "0") + var fitA = AmountMath.mulDivFloor(balanceB, reserveA, reserveB) + var rawA = AmountMath.compare(balanceA, fitA) < 0 ? balanceA : fitA + var rawB = AmountMath.mulDivFloor(rawA, reserveB, reserveA) + root.amountA = AmountMath.formatRaw(rawA, root.decimalsA) + root.amountB = AmountMath.formatRaw(rawB, root.decimalsB) + root.noteDraftChanged() + root.requestQuote(true) + } + + function editPrice(side, value) { + if (side === "A") + root.priceAmountA = value + else + root.priceAmountB = value + root.minimumAmountARaw = "" + root.minimumAmountBRaw = "" + root.amountA = "" + root.amountB = "" + root.noteDraftChanged() + root.requestQuote(false) + } + + function editMissingAmount(side, value) { + if (side === "A") + root.amountA = value + else + root.amountB = value + + root.localErrors = [] + root.noteDraftChanged() + } + + function finishMissingAmount(side, value) { + var decimals = side === "A" ? root.decimalsA : root.decimalsB + if (side === "A") + root.amountA = value + else + root.amountB = value + + var parsed = AmountMath.parseHuman(value, decimals) + if (parsed.ok) { + var paired = AmountMath.pairAmount(parsed.raw, + side === "A", + root.decimalsA, + root.decimalsB, + root.priceAmountA, + root.priceAmountB) + if (paired.ok) { + if (side === "A") + root.amountB = AmountMath.formatRaw(paired.raw, root.decimalsB) + else + root.amountA = AmountMath.formatRaw(paired.raw, root.decimalsA) + } + } + root.requestQuote(true) + } + + function applyQuoteSideEffects() { + if (root.quoteStale) + return + if (root.poolFeeBps > 0 && root.selectedFeeBps !== root.poolFeeBps) { + root.selectedFeeBps = root.poolFeeBps + if (root.amountA.length === 0 && root.amountB.length === 0) { + root.amountA = AmountMath.formatRaw( + root.probeRaw(root.tokenA, root.decimalsA), root.decimalsA) + root.amountB = AmountMath.formatRaw( + root.probeRaw(root.tokenB, root.decimalsB), root.decimalsB) + } + root.requestQuote(true) + return + } + + if (root.quotePayload.status !== "ok") + return + + if (root.activePool && root.amountA.length === 0 && root.amountB.length === 0) { + root.amountA = AmountMath.formatRaw(root.displayRaw("maxAmountARaw", "maxAmountBRaw", "A"), root.decimalsA) + root.amountB = AmountMath.formatRaw(root.displayRaw("maxAmountARaw", "maxAmountBRaw", "B"), root.decimalsB) + } + + if (root.missingPool) { + var rawA = root.displayRaw("actualAmountARaw", "actualAmountBRaw", "A") + var rawB = root.displayRaw("actualAmountARaw", "actualAmountBRaw", "B") + var minimumA = root.displayRaw("minimumAmountARaw", "minimumAmountBRaw", "A") + var minimumB = root.displayRaw("minimumAmountARaw", "minimumAmountBRaw", "B") + root.minimumAmountARaw = minimumA.length > 0 ? minimumA : rawA + root.minimumAmountBRaw = minimumB.length > 0 ? minimumB : rawB + if (rawA.length > 0 && rawB.length > 0) { + root.amountA = AmountMath.formatRaw(rawA, root.decimalsA) + root.amountB = AmountMath.formatRaw(rawB, root.decimalsB) + } + } + } + + function displayRaw(canonicalAField, canonicalBField, side) { + return root.displayQuoteRaw(root.quotePayload, canonicalAField, canonicalBField, side) + } + + function displayQuoteRaw(quote, canonicalAField, canonicalBField, side) { + if (!root.quoteMatchesSelectedPair(quote)) + return "" + if (side === "A") + return String(quote[root.displayIsCanonical ? canonicalAField : canonicalBField] || "") + return String(quote[root.displayIsCanonical ? canonicalBField : canonicalAField] || "") + } + + function poolReserve(side) { + var reserve = root.displayRaw("reserveARaw", "reserveBRaw", side) + if (AmountMath.isUnsigned(reserve) && reserve !== "0") + return reserve + if (!root.quoteMatchesSelectedPair(root.activePoolQuote)) + return "" + return root.displayQuoteRaw(root.activePoolQuote, "reserveARaw", "reserveBRaw", side) + } + + function quoteAmount(canonicalAField, canonicalBField, side) { + var token = side === "A" ? root.tokenA : root.tokenB + var decimals = side === "A" ? root.decimalsA : root.decimalsB + var raw = root.displayRaw(canonicalAField, canonicalBField, side) + return raw.length > 0 + ? qsTr("%1 %2").arg(AmountMath.formatRaw(raw, decimals)).arg(root.shortTokenName(token)) + : "—" + } + + function depositSummary() { + var amountA = root.quoteAmount("actualAmountARaw", "actualAmountBRaw", "A") + var amountB = root.quoteAmount("actualAmountARaw", "actualAmountBRaw", "B") + return amountA + " + " + amountB + } + + function initialPriceText(inverse) { + var price = inverse ? root.inverseInitialPrice : root.initialPrice + if (price.length === 0) + return "—" + var from = inverse ? root.tokenB : root.tokenA + var to = inverse ? root.tokenA : root.tokenB + return qsTr("1 %1 = %2 %3") + .arg(root.shortTokenName(from)) + .arg(price) + .arg(root.shortTokenName(to)) + } + + function rawLpText(raw) { + return raw !== undefined && raw !== null && String(raw).length > 0 + ? qsTr("%1 raw LP").arg(String(raw)) : "—" + } + + function activePriceValue() { + var priceRaw = String(root.quotePayload.initialPriceRealRaw || "") + if (priceRaw.length === 0 && root.quoteMatchesSelectedPair(root.activePoolQuote)) + priceRaw = String(root.activePoolQuote.initialPriceRealRaw || "") + return AmountMath.priceFromQ64(priceRaw, + root.canonicalDecimalsA, + root.canonicalDecimalsB, + root.displayIsCanonical) + } + + function activePriceText() { + var price = root.activePriceValue() + if (price.length === 0) + return "" + return qsTr("1 %1 = %2 %3") + .arg(root.shortTokenName(root.tokenA)) + .arg(price) + .arg(root.shortTokenName(root.tokenB)) + } + + function accountPreview() { + return !root.quoteStale && root.quoteMatchesPair() + ? root.quotePayload.accountPreview || [] : [] + } + + function quoteError() { + if (root.quoteLoading || root.quoteStale) + return "" + if (root.quotePayload.status === "error") + return root.issueText(root.quotePayload.code) + return "" + } + + function warningText() { + var warnings = !root.quoteStale && root.quoteMatchesPair() + ? root.quotePayload.warnings || [] : [] + if (warnings.length === 0) + warnings = root.newPositionContext.warnings || [] + return warnings.length > 0 ? root.issueText(warnings[0].code) : "" + } + + function submissionSnapshot() { + var built = root.buildQuoteRequest() + return { + "request": built.request, + "poolProbeRequest": root.poolProbeRequest(built.request), + "quoteHash": String(root.quotePayload.quoteHash || ""), + "pairText": qsTr("%1 / %2").arg(root.shortTokenName(root.tokenA)).arg(root.shortTokenName(root.tokenB)), + "feeText": root.feeLabel(root.selectedFeeBps), + "depositAText": root.quoteAmount("actualAmountARaw", "actualAmountBRaw", "A"), + "depositBText": root.quoteAmount("actualAmountARaw", "actualAmountBRaw", "B"), + "expectedLpText": root.rawLpText(root.quotePayload.expectedLpRaw), + "instruction": String(root.quotePayload.instruction || "") + } + } + + function shortTokenName(token) { + if (token && token.name && token.name.length > 0) + return token.name + return token && token.definitionId ? root.shortId(token.definitionId) : "—" + } + + function balanceText(token, decimals) { + return AmountMath.formatRaw(String(token.balanceRaw || "0"), decimals) + } + + function tokenBalanceDetail(token) { + return qsTr("Available %1").arg(root.balanceText(token, 0)) + } + + function shortId(value) { + var text = String(value || "") + return text.length > 14 ? text.slice(0, 7) + "…" + text.slice(-5) : text + } + + function depositMultiplierText() { + var multiplier = root.depositMultiplierValue() + var basisPoints = root.depositBasisPointsText() + return multiplier.length > 0 ? qsTr("%1 · %2").arg(multiplier).arg(basisPoints) : "" + } + + function depositMultiplierValue() { + var scale = root.depositScaleValue() + return scale.length > 0 + ? qsTr("%1x minimum").arg(AmountMath.formatRaw(scale, 4)) : "" + } + + function depositBasisPointsText() { + var scale = root.depositScaleValue() + return scale.length > 0 ? qsTr("%1 basis points").arg(scale) : "" + } + + function depositScaleValue() { + var parsed = AmountMath.parseHuman(root.amountA, root.decimalsA) + if (!parsed.ok || !AmountMath.isUnsigned(root.minimumAmountARaw) + || root.minimumAmountARaw === "0" + || AmountMath.compare(parsed.raw, root.minimumAmountARaw) < 0) { + return "" + } + return AmountMath.divide(AmountMath.multiply(parsed.raw, "10000"), + root.minimumAmountARaw).quotient + } + + function minimumAmountText(side) { + var raw = side === "A" ? root.minimumAmountARaw : root.minimumAmountBRaw + var decimals = side === "A" ? root.decimalsA : root.decimalsB + return raw.length > 0 + ? qsTr("Min %1").arg(AmountMath.formatRaw(raw, decimals)) : "" + } + + function contextStatusText() { + var network = String(root.newPositionContext.networkId || "") + if (root.newPositionContext.status === "no_wallet") + return qsTr("%1 · simulation only").arg(network || qsTr("Wallet disconnected")) + if (root.newPositionContext.status === "ready") + return qsTr("%1 · wallet ready").arg(network) + return network.length > 0 ? network : qsTr("Loading network") + } + + function contextBlocksForm() { + var status = String(root.newPositionContext.status || "") + return status !== "" && status !== "ready" && status !== "no_wallet" && status !== "loading" + } + + function contextErrorText() { + return root.issueText(root.newPositionContext.code || root.newPositionContext.status) + } +} diff --git a/apps/amm/qml/components/liquidity/PoolPositionSummary.qml b/apps/amm/qml/components/liquidity/PoolPositionSummary.qml deleted file mode 100644 index 2466ae7a..00000000 --- a/apps/amm/qml/components/liquidity/PoolPositionSummary.qml +++ /dev/null @@ -1,98 +0,0 @@ -import QtQuick 2.15 -import QtQuick.Layouts 1.15 -import "../../state" - -Rectangle { - id: root - - required property DummyPoolState poolState - readonly property string estimateHelp: qsTr("This value is an estimate from the current dummy reserves and your share of total LP supply.") - - color: "#151515" - implicitHeight: content.implicitHeight + 20 - radius: 8 - border.color: "#303030" - border.width: 1 - - ColumnLayout { - id: content - - anchors.fill: parent - anchors.margins: 10 - spacing: 6 - - RowLayout { - spacing: 10 - - Layout.fillWidth: true - - ColumnLayout { - spacing: 2 - - Layout.fillWidth: true - - Text { - color: "#E7E1D8" - font.bold: true - font.pixelSize: 13 - text: root.poolState.userLpBalance > 0 ? qsTr("Your position") : qsTr("No position") - - Layout.fillWidth: true - } - - Text { - color: "#8E8780" - font.pixelSize: 11 - text: qsTr("%1 LP tokens").arg(root.poolState.formatInteger(root.poolState.userLpBalance)) - visible: root.poolState.userLpBalance > 0 - - Layout.fillWidth: true - } - } - - Rectangle { - color: "#211914" - radius: 10 - border.color: "#49301F" - border.width: 1 - - Layout.preferredHeight: 24 - Layout.preferredWidth: shareText.implicitWidth + 18 - - Text { - id: shareText - - anchors.centerIn: parent - color: "#F2D8C7" - font.bold: true - font.pixelSize: 11 - text: root.poolState.userLpBalance > 0 ? root.poolState.formatPoolShare(root.poolState.poolShare) : root.poolState.feeTier - } - } - } - - SummaryRow { - estimated: true - estimateHelp: root.estimateHelp - label: qsTr("Owned") - value: qsTr("%1 + %2").arg(root.poolState.formatCompactTokenAmount(root.poolState.userOwnedA, root.poolState.tokenA)).arg(root.poolState.formatCompactTokenAmount(root.poolState.userOwnedB, root.poolState.tokenB)) - visible: root.poolState.userLpBalance > 0 - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Pool") - value: qsTr("%1 / %2").arg(root.poolState.formatCompactTokenAmount(root.poolState.reserveA, root.poolState.tokenA)).arg(root.poolState.formatCompactTokenAmount(root.poolState.reserveB, root.poolState.tokenB)) - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Fee") - value: root.poolState.feeTier - - Layout.fillWidth: true - } - } -} diff --git a/apps/amm/qml/components/liquidity/RemoveLiquidityForm.qml b/apps/amm/qml/components/liquidity/RemoveLiquidityForm.qml deleted file mode 100644 index b916a744..00000000 --- a/apps/amm/qml/components/liquidity/RemoveLiquidityForm.qml +++ /dev/null @@ -1,492 +0,0 @@ -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Layouts 1.15 -import "../shared" -import "../../state" - -Rectangle { - id: root - - required property DummyPoolState poolState - - property real slippageTolerancePercent: 0.5 - property int burnAmount: 0 - readonly property int maxBurnAmount: root.poolState.clampBurnAmount(root.poolState.userLpBalance) - readonly property bool hasLpTokens: root.maxBurnAmount > 0 - readonly property int preset25Amount: root.poolState.burnAmountForPercent(25) - readonly property int preset50Amount: root.poolState.burnAmountForPercent(50) - readonly property int preset75Amount: root.poolState.burnAmountForPercent(75) - readonly property real removePercent: root.maxBurnAmount > 0 ? root.burnAmount * 100 / root.maxBurnAmount : 0 - readonly property var preview: root.poolState.removeLiquidityPreview(root.burnAmount) - readonly property int minTokenAReceived: root.poolState.minReceivedAmount(root.preview.withdrawA, root.slippageTolerancePercent) - readonly property int minTokenBReceived: root.poolState.minReceivedAmount(root.preview.withdrawB, root.slippageTolerancePercent) - readonly property bool minReceivedIsZero: root.burnAmount > 0 && (root.minTokenAReceived === 0 || root.minTokenBReceived === 0) - readonly property bool canSubmit: root.hasLpTokens && root.burnAmount > 0 && !root.minReceivedIsZero - readonly property string estimateHelp: qsTr("Estimated with the same integer floor math used by the remove-liquidity contract path.") - readonly property string submitButtonText: !root.hasLpTokens ? qsTr("No LP balance") : root.burnAmount === 0 ? qsTr("Enter an amount") : root.minReceivedIsZero ? qsTr("Minimum received is 0") : qsTr("Remove Liquidity") - - signal slippageToleranceChangeRequested(real tolerancePercent) - signal removeLiquidityRequested(var snapshot) - - color: "#00000000" - implicitHeight: content.implicitHeight - radius: 0 - border.width: 0 - - onMaxBurnAmountChanged: { - if (root.burnAmount > root.maxBurnAmount) { - root.setBurnAmount(root.maxBurnAmount); - } - } - - ColumnLayout { - id: content - - anchors.fill: parent - spacing: 10 - - Text { - color: "#F26A21" - font.pixelSize: 12 - text: qsTr("No LP tokens") - visible: !root.hasLpTokens - - Layout.fillWidth: true - } - - Text { - color: "#A9A098" - font.pixelSize: 12 - lineHeight: 1.25 - text: qsTr("Add liquidity first to receive LP tokens before removing from this pool.") - visible: !root.hasLpTokens - wrapMode: Text.WordWrap - - Layout.fillWidth: true - } - - Rectangle { - color: root.hasLpTokens ? "#151515" : "#121212" - radius: 8 - border.color: burnField.activeFocus ? "#F26A21" : "#343434" - border.width: 1 - - Layout.fillWidth: true - Layout.preferredHeight: inputContent.implicitHeight + 20 - - ColumnLayout { - id: inputContent - - anchors.fill: parent - anchors.margins: 10 - spacing: 8 - - RowLayout { - spacing: 8 - - Layout.fillWidth: true - - Text { - color: "#A9A098" - elide: Text.ElideRight - font.pixelSize: 12 - text: qsTr("LP tokens to burn") - - Layout.fillWidth: true - } - - Text { - color: "#A9A098" - elide: Text.ElideRight - font.pixelSize: 11 - horizontalAlignment: Text.AlignRight - text: qsTr("Available LP: %1").arg(root.poolState.formatInteger(root.poolState.userLpBalance)) - - Layout.maximumWidth: 170 - } - } - - TextField { - id: burnField - - activeFocusOnTab: root.hasLpTokens - color: "#E7E1D8" - enabled: root.hasLpTokens - font.bold: true - font.pixelSize: 18 - inputMethodHints: Qt.ImhDigitsOnly - placeholderText: qsTr("0") - selectByMouse: true - selectedTextColor: "#151515" - selectionColor: "#F26A21" - text: root.burnAmount > 0 ? String(root.burnAmount) : "" - validator: RegularExpressionValidator { - regularExpression: /[0-9]*/ - } - - Accessible.name: qsTr("LP tokens to burn") - - Layout.fillWidth: true - Layout.minimumHeight: 44 - - onTextEdited: root.setBurnAmount(text) - - background: Rectangle { - border.color: burnField.activeFocus ? "#F26A21" : "#343434" - border.width: 1 - color: burnField.activeFocus ? "#1F1B18" : "#101010" - radius: 6 - } - } - } - } - - RowLayout { - spacing: 6 - - Layout.fillWidth: true - - Button { - id: preset25 - - activeFocusOnTab: root.hasLpTokens - enabled: root.hasLpTokens - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: qsTr("25%") - - Accessible.name: qsTr("Remove 25 percent") - - Layout.fillWidth: true - Layout.minimumHeight: 44 - - onClicked: root.setBurnPercent(25) - - contentItem: Text { - color: preset25.enabled && (preset25.hovered || preset25.activeFocus || root.preset25Amount === root.burnAmount) ? "#151515" : "#A9A098" - font.bold: true - font.pixelSize: 11 - horizontalAlignment: Text.AlignHCenter - text: preset25.text - verticalAlignment: Text.AlignVCenter - } - - background: Rectangle { - border.color: preset25.activeFocus || root.preset25Amount === root.burnAmount ? "#F26A21" : "#343434" - border.width: 1 - color: preset25.pressed ? "#D95C1E" : root.preset25Amount === root.burnAmount ? "#F26A21" : preset25.hovered || preset25.activeFocus ? "#E7E1D8" : "#151515" - radius: 6 - } - } - - Button { - id: preset50 - - activeFocusOnTab: root.hasLpTokens - enabled: root.hasLpTokens - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: qsTr("50%") - - Accessible.name: qsTr("Remove 50 percent") - - Layout.fillWidth: true - Layout.minimumHeight: 44 - - onClicked: root.setBurnPercent(50) - - contentItem: Text { - color: preset50.enabled && (preset50.hovered || preset50.activeFocus || root.preset50Amount === root.burnAmount) ? "#151515" : "#A9A098" - font.bold: true - font.pixelSize: 11 - horizontalAlignment: Text.AlignHCenter - text: preset50.text - verticalAlignment: Text.AlignVCenter - } - - background: Rectangle { - border.color: preset50.activeFocus || root.preset50Amount === root.burnAmount ? "#F26A21" : "#343434" - border.width: 1 - color: preset50.pressed ? "#D95C1E" : root.preset50Amount === root.burnAmount ? "#F26A21" : preset50.hovered || preset50.activeFocus ? "#E7E1D8" : "#151515" - radius: 6 - } - } - - Button { - id: preset75 - - activeFocusOnTab: root.hasLpTokens - enabled: root.hasLpTokens - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: qsTr("75%") - - Accessible.name: qsTr("Remove 75 percent") - - Layout.fillWidth: true - Layout.minimumHeight: 44 - - onClicked: root.setBurnPercent(75) - - contentItem: Text { - color: preset75.enabled && (preset75.hovered || preset75.activeFocus || root.preset75Amount === root.burnAmount) ? "#151515" : "#A9A098" - font.bold: true - font.pixelSize: 11 - horizontalAlignment: Text.AlignHCenter - text: preset75.text - verticalAlignment: Text.AlignVCenter - } - - background: Rectangle { - border.color: preset75.activeFocus || root.preset75Amount === root.burnAmount ? "#F26A21" : "#343434" - border.width: 1 - color: preset75.pressed ? "#D95C1E" : root.preset75Amount === root.burnAmount ? "#F26A21" : preset75.hovered || preset75.activeFocus ? "#E7E1D8" : "#151515" - radius: 6 - } - } - - Button { - id: presetMax - - activeFocusOnTab: root.hasLpTokens - enabled: root.hasLpTokens - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: qsTr("MAX") - - Accessible.name: qsTr("Remove maximum LP balance") - - Layout.fillWidth: true - Layout.minimumHeight: 44 - - onClicked: root.setBurnAmount(root.maxBurnAmount) - - contentItem: Text { - color: presetMax.enabled && (presetMax.hovered || presetMax.activeFocus || root.burnAmount === root.maxBurnAmount) ? "#151515" : "#A9A098" - font.bold: true - font.pixelSize: 11 - horizontalAlignment: Text.AlignHCenter - text: presetMax.text - verticalAlignment: Text.AlignVCenter - } - - background: Rectangle { - border.color: presetMax.activeFocus || root.burnAmount === root.maxBurnAmount ? "#F26A21" : "#343434" - border.width: 1 - color: presetMax.pressed ? "#D95C1E" : root.burnAmount === root.maxBurnAmount ? "#F26A21" : presetMax.hovered || presetMax.activeFocus ? "#E7E1D8" : "#151515" - radius: 6 - } - } - } - - ColumnLayout { - spacing: 6 - - Layout.fillWidth: true - - RowLayout { - spacing: 8 - - Layout.fillWidth: true - - Text { - color: "#A9A098" - font.pixelSize: 12 - text: qsTr("Pool share to remove") - - Layout.fillWidth: true - } - - Text { - color: "#E7E1D8" - font.bold: true - font.pixelSize: 12 - horizontalAlignment: Text.AlignRight - text: root.poolState.formatPercent(root.removePercent) - - Layout.maximumWidth: 72 - } - } - - Slider { - id: burnSlider - - activeFocusOnTab: root.hasLpTokens - enabled: root.hasLpTokens - from: 0 - stepSize: 1 - to: 100 - value: root.removePercent - - Accessible.name: qsTr("Pool share to remove") - Accessible.role: Accessible.Slider - - Layout.fillWidth: true - Layout.minimumHeight: 44 - - onMoved: root.setBurnPercent(Math.round(value)) - - background: Rectangle { - color: "#343434" - implicitHeight: 4 - radius: 2 - x: burnSlider.leftPadding - y: burnSlider.topPadding + burnSlider.availableHeight / 2 - height / 2 - - width: burnSlider.availableWidth - - Rectangle { - color: burnSlider.enabled ? "#F26A21" : "#56504A" - height: parent.height - radius: 2 - width: burnSlider.visualPosition * parent.width - } - } - - handle: Rectangle { - border.color: burnSlider.activeFocus ? "#E7E1D8" : "#F26A21" - border.width: 1 - color: burnSlider.enabled ? "#F26A21" : "#56504A" - height: 18 - radius: 9 - width: 18 - x: burnSlider.leftPadding + burnSlider.visualPosition * (burnSlider.availableWidth - width) - y: burnSlider.topPadding + burnSlider.availableHeight / 2 - height / 2 - } - } - } - - SummaryRow { - estimated: true - estimateHelp: root.estimateHelp - label: qsTr("Withdraw %1").arg(root.poolState.tokenA) - value: root.poolState.formatTokenAmount(root.preview.withdrawA, root.poolState.tokenA) - visible: root.burnAmount > 0 - - Layout.fillWidth: true - } - - SummaryRow { - estimated: true - estimateHelp: root.estimateHelp - label: qsTr("Withdraw %1").arg(root.poolState.tokenB) - value: root.poolState.formatTokenAmount(root.preview.withdrawB, root.poolState.tokenB) - visible: root.burnAmount > 0 - - Layout.fillWidth: true - } - - SlippageToleranceControl { - tolerancePercent: root.slippageTolerancePercent - - Layout.fillWidth: true - - onToleranceChangeRequested: function (tolerancePercent) { - root.slippageToleranceChangeRequested(tolerancePercent); - } - } - - SummaryRow { - label: qsTr("Min %1 received").arg(root.poolState.tokenA) - value: root.poolState.formatTokenAmount(root.minTokenAReceived, root.poolState.tokenA) - visible: root.burnAmount > 0 - - Layout.fillWidth: true - } - - SummaryRow { - label: qsTr("Min %1 received").arg(root.poolState.tokenB) - value: root.poolState.formatTokenAmount(root.minTokenBReceived, root.poolState.tokenB) - visible: root.burnAmount > 0 - - Layout.fillWidth: true - } - - Text { - color: "#F08A76" - font.pixelSize: 12 - lineHeight: 1.25 - text: qsTr("Minimum received is 0. Increase amount or lower slippage.") - visible: root.minReceivedIsZero - wrapMode: Text.WordWrap - - Layout.fillWidth: true - } - - SummaryRow { - estimated: true - estimateHelp: root.estimateHelp - label: qsTr("Position after") - value: root.poolState.formatPoolShare(root.preview.newUserShare) - visible: root.burnAmount > 0 - - Layout.fillWidth: true - } - - Button { - id: submitButton - - activeFocusOnTab: root.hasLpTokens - enabled: root.canSubmit - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: root.submitButtonText - - Accessible.name: submitButton.text - - Layout.fillWidth: true - Layout.minimumHeight: 44 - Layout.preferredHeight: 44 - - onClicked: root.removeLiquidityRequested(root.submitSnapshot()) - - contentItem: Text { - color: submitButton.enabled ? "#151515" : "#7D756E" - elide: Text.ElideRight - font.bold: true - font.pixelSize: 13 - horizontalAlignment: Text.AlignHCenter - text: submitButton.text - verticalAlignment: Text.AlignVCenter - } - - background: Rectangle { - border.color: submitButton.enabled ? "#F26A21" : "#343434" - border.width: 1 - color: submitButton.enabled ? submitButton.pressed ? "#D95C1E" : submitButton.hovered || submitButton.activeFocus ? "#FF8A3D" : "#F26A21" : "#181818" - radius: 6 - } - } - } - - function setBurnAmount(value) { - root.burnAmount = root.poolState.clampBurnAmount(value); - } - - function setBurnPercent(percent) { - root.setBurnAmount(root.poolState.burnAmountForPercent(percent)); - } - - function resetForm() { - root.setBurnAmount(0); - } - - function submitSnapshot() { - return { - "action": "remove", - "burnAmount": root.preview.burnedLp, - "burnPercent": root.poolState.formatPercent(root.removePercent), - "burnText": root.poolState.formatLpAmount(root.preview.burnedLp), - "minTokenAReceived": root.poolState.formatTokenAmount(root.minTokenAReceived, root.poolState.tokenA), - "minTokenBReceived": root.poolState.formatTokenAmount(root.minTokenBReceived, root.poolState.tokenB), - "postRemovalShare": root.poolState.formatPoolShare(root.preview.newUserShare), - "slippageTolerance": root.poolState.formatPercent(root.slippageTolerancePercent), - "tokenA": root.poolState.tokenA, - "tokenB": root.poolState.tokenB, - "withdrawA": root.preview.withdrawA, - "withdrawB": root.preview.withdrawB, - "withdrawAText": root.poolState.formatTokenAmount(root.preview.withdrawA, root.poolState.tokenA), - "withdrawBText": root.poolState.formatTokenAmount(root.preview.withdrawB, root.poolState.tokenB) - }; - } -} diff --git a/apps/amm/qml/components/liquidity/SummaryRow.qml b/apps/amm/qml/components/liquidity/SummaryRow.qml index 0c340465..849ecb62 100644 --- a/apps/amm/qml/components/liquidity/SummaryRow.qml +++ b/apps/amm/qml/components/liquidity/SummaryRow.qml @@ -6,6 +6,7 @@ Item { property string label: "" property string value: "" + property bool valueWrapAnywhere: false property bool estimated: false property string estimateHelp: qsTr("This value is derived from your LP token balance, total LP supply, and current pool reserves.") @@ -36,14 +37,16 @@ Item { Text { color: "#E7E1D8" - elide: Text.ElideRight + elide: root.valueWrapAnywhere ? Text.ElideNone : Text.ElideRight font.bold: true font.pixelSize: 12 horizontalAlignment: Text.AlignRight text: root.value verticalAlignment: Text.AlignVCenter + wrapMode: root.valueWrapAnywhere ? Text.WrapAtWordBoundaryOrAnywhere : Text.NoWrap Layout.maximumWidth: Math.max(178, root.width * 0.55) + Layout.preferredWidth: Math.min(implicitWidth, Math.max(178, root.width * 0.55)) } EstimateInfoButton { diff --git a/apps/amm/qml/components/liquidity/TokenAmountInput.qml b/apps/amm/qml/components/liquidity/TokenAmountInput.qml index 120be579..311276cf 100644 --- a/apps/amm/qml/components/liquidity/TokenAmountInput.qml +++ b/apps/amm/qml/components/liquidity/TokenAmountInput.qml @@ -1,156 +1,135 @@ -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Layouts 1.15 +pragma ComponentBehavior: Bound -Rectangle { +import QtQuick + +import "../shared" + +AmmTokenAmountSurface { id: root - property alias text: amountField.text + property string text: "" property string balance: "" - property string errorText: "" property string helperText: "" - property string label: "" - property string token: "" + property bool showMaxButton: true + property var tokenData: null + property var tokens: [] + property string selectedTokenId: "" + property bool tokenInvalid: false + property bool tokenSelectionEnabled: true + property bool editPending: false + property string pendingValue: "" + property var disabledReasonForCode: function(code) { + return qsTr("This token is unavailable (%1).").arg(code || "unknown") + } + property var detailForToken: function(token) { return "" } + property alias popup: tokenModal + property alias query: tokenModal.searchText + readonly property var rows: tokenModal.rows signal editingChanged(string value) + signal editingCommitted(string value) signal maxClicked + signal tokenSelected(string tokenId) + signal tokenEntered(string value) + + amount: root.text + supportingText: root.helperText + supportingActionText: root.showMaxButton ? qsTr("MAX") : "" + accessory: tokenActions + accessoryWidth: width < 360 ? 132 : 180 + accessoryHeight: root.balance.length > 0 ? 58 : 40 + + onAmountEdited: function(value) { + root.pendingValue = value + root.editPending = true + root.editingChanged(value) + commitTimer.restart() + } + onAmountEditingFinished: function(value) { + root.pendingValue = value + root.commitPendingEdit() + } + onSupportingActionClicked: root.maxClicked() + onTextChanged: { + if (root.editPending && root.text !== root.pendingValue) { + commitTimer.stop() + root.editPending = false + } + } - color: "#151515" - implicitHeight: content.implicitHeight + 20 - radius: 8 - border.color: root.errorText.length > 0 ? "#D85F4B" : amountField.activeFocus ? "#F26A21" : "#343434" - border.width: 1 - - Accessible.name: root.label - Accessible.role: Accessible.EditableText - - ColumnLayout { - id: content - - anchors.fill: parent - anchors.margins: 10 - spacing: 8 - - RowLayout { - spacing: 8 - - Layout.fillWidth: true - - Text { - color: "#A9A098" - elide: Text.ElideRight - font.pixelSize: 12 - text: root.label - - Layout.fillWidth: true - } + Timer { + id: commitTimer - Text { - color: "#E7E1D8" - elide: Text.ElideRight - font.bold: true - font.pixelSize: 12 - horizontalAlignment: Text.AlignRight - text: root.token + interval: 250 + repeat: false + onTriggered: root.commitPendingEdit() + } - Layout.maximumWidth: 76 - } + Component { + id: tokenActions + + AmmTokenAccessory { + theme: root.theme + enabled: root.tokenSelectionEnabled + invalid: root.tokenInvalid + hasToken: root.tokenData !== null + tokenColor: root.tokenColor(root.tokenData) + tokenLetter: root.tokenLetter(root.tokenData) + tokenText: root.tokenText(root.tokenData) + balance: root.balance + accessibleName: qsTr("Select %1").arg(root.label) + onClicked: tokenModal.open() } + } - RowLayout { - spacing: 8 - - Layout.fillWidth: true - - TextField { - id: amountField - - activeFocusOnTab: true - color: "#E7E1D8" - font.bold: true - font.pixelSize: 18 - inputMethodHints: Qt.ImhFormattedNumbersOnly - placeholderText: qsTr("0") - selectByMouse: true - selectedTextColor: "#151515" - selectionColor: "#F26A21" - validator: RegularExpressionValidator { - regularExpression: /[0-9]*([.][0-9]*)?/ - } - - Accessible.name: root.label - - Layout.fillWidth: true - Layout.minimumHeight: 44 - - onTextEdited: root.editingChanged(text) - - background: Rectangle { - border.color: amountField.activeFocus ? "#F26A21" : "#343434" - border.width: 1 - color: amountField.activeFocus ? "#1F1B18" : "#101010" - radius: 6 - } - } - - Button { - id: maxButton - - activeFocusOnTab: true - focusPolicy: Qt.StrongFocus - hoverEnabled: true - text: qsTr("MAX") - - Accessible.name: qsTr("Use maximum %1 balance").arg(root.token) - - Layout.minimumHeight: 44 - Layout.preferredWidth: 58 - - onClicked: root.maxClicked() - - contentItem: Text { - color: maxButton.activeFocus || maxButton.hovered ? "#151515" : "#F26A21" - font.bold: true - font.pixelSize: 11 - horizontalAlignment: Text.AlignHCenter - text: maxButton.text - verticalAlignment: Text.AlignVCenter - } - - background: Rectangle { - border.color: "#F26A21" - border.width: 1 - color: maxButton.pressed ? "#D95C1E" : maxButton.hovered || maxButton.activeFocus ? "#F26A21" : "#201712" - radius: 6 - } - } + TokenSelectorModal { + id: tokenModal + + theme: root.theme + tokens: root.tokens + title: qsTr("Select a token") + searchPlaceholder: qsTr("Search name or address") + popularTitle: qsTr("Quick select") + listTitle: qsTr("All tokens") + allowCustomEntry: true + disabledReasonForCode: root.disabledReasonForCode + detailForToken: root.detailForToken + + onTokenSelected: function(token) { + root.tokenSelected(String(token.definitionId || token.address || "")) } + onTokenEntered: function(value) { root.tokenEntered(value) } + } - RowLayout { - spacing: 8 + function acceptInput(value) { + tokenModal.acceptInput(value) + } - Layout.fillWidth: true + function commitPendingEdit() { + if (!root.editPending) + return + commitTimer.stop() + root.editPending = false + root.editingCommitted(root.pendingValue) + } - Text { - color: root.errorText.length > 0 ? "#F08A76" : root.helperText.length > 0 ? "#F26A21" : "#A9A098" - elide: Text.ElideRight - font.pixelSize: 11 - text: root.errorText.length > 0 ? root.errorText : root.helperText - visible: text.length > 0 + function tokenText(token) { + if (!token) + return qsTr("Select token") + return String(token.symbol || token.name || root.shortId(root.selectedTokenId)) + } - Layout.fillWidth: true - } + function tokenLetter(token) { + var text = root.tokenText(token) + return token ? String(token.letter || text.charAt(0).toUpperCase()) : "" + } - Text { - color: "#A9A098" - elide: Text.ElideRight - font.pixelSize: 11 - horizontalAlignment: Text.AlignRight - text: qsTr("Balance %1").arg(root.balance) + function tokenColor(token) { + return token && token.color ? token.color : root.theme.colors.noTokenCircle + } - Layout.alignment: Qt.AlignRight - Layout.maximumWidth: 150 - } - } + function shortId(value) { + var text = String(value || "") + return text.length > 14 ? text.slice(0, 7) + "..." + text.slice(-5) : text } } diff --git a/apps/amm/qml/components/liquidity/TokenSelectorModal.qml b/apps/amm/qml/components/liquidity/TokenSelectorModal.qml new file mode 100644 index 00000000..c07b7253 --- /dev/null +++ b/apps/amm/qml/components/liquidity/TokenSelectorModal.qml @@ -0,0 +1,489 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Popup { + id: root + + required property var theme + property var tokens: [] + property string searchText: "" + property string title: qsTr("Select a token") + property string searchPlaceholder: qsTr("Search tokens") + property string popularTitle: qsTr("Popular tokens") + property string listTitle: qsTr("Tokens by 24H volume") + property bool allowCustomEntry: false + property var disabledReasonForCode: function(code) { + return qsTr("This token is unavailable (%1).").arg(code || "unknown") + } + property var detailForToken: function(token) { + return token && token.balance !== undefined + ? qsTr("Balance %1").arg(token.balance) : "" + } + readonly property var rows: root.filteredTokens() + readonly property var popularTokens: root.selectableTokens().slice(0, 5) + readonly property bool compact: root.height < 360 + readonly property bool showPopular: root.popularTokens.length > 0 && root.height >= 440 + + signal tokenSelected(var token) + signal tokenEntered(string value) + + parent: Overlay.overlay + x: parent ? Math.round((parent.width - width) / 2) : 0 + y: parent ? Math.round((parent.height - height) / 2) : 0 + width: parent && parent.width > 32 + ? Math.max(0, Math.min(480, parent.width - 32)) : 280 + height: parent && parent.height > 32 + ? Math.max(0, Math.min(600, parent.height - 32)) : 360 + padding: root.compact ? 8 : 20 + modal: true + focus: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + onOpened: { + root.searchText = "" + searchField.text = "" + Qt.callLater(searchField.forceActiveFocus) + } + + Overlay.modal: Rectangle { + color: Qt.rgba(0, 0, 0, 0.4) + } + + background: Rectangle { + radius: 24 + color: root.theme.colors.cardBg + border.color: root.theme.colors.border + border.width: 1 + + Behavior on color { + ColorAnimation { duration: 180 } + } + } + + contentItem: ColumnLayout { + spacing: root.compact ? 8 : 16 + + RowLayout { + Layout.fillWidth: true + + Text { + Layout.fillWidth: true + text: root.title + color: root.theme.colors.textPrimary + font.pixelSize: 18 + font.weight: Font.Bold + } + + Button { + id: closeButton + + Layout.preferredWidth: 32 + Layout.preferredHeight: 32 + text: "\u00D7" + hoverEnabled: true + Accessible.name: qsTr("Close token selector") + ToolTip.visible: hovered + ToolTip.text: Accessible.name + onClicked: root.close() + + contentItem: Text { + text: closeButton.text + color: root.theme.colors.textSecondary + font.pixelSize: 16 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + radius: 16 + color: closeButton.hovered || closeButton.activeFocus + ? root.theme.colors.panelHoverBg + : root.theme.colors.panelBg + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: root.compact ? 40 : 48 + radius: 16 + color: root.theme.colors.inputBg + border.color: searchField.activeFocus + ? root.theme.colors.ctaBg + : root.theme.colors.border + border.width: 1 + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 14 + anchors.rightMargin: 14 + spacing: 8 + + Text { + text: "\u2315" + color: root.theme.colors.textSecondary + font.pixelSize: 20 + } + + TextField { + id: searchField + + objectName: "tokenSearchField" + + Layout.fillWidth: true + color: root.theme.colors.textPrimary + placeholderText: root.searchPlaceholder + placeholderTextColor: root.theme.colors.textPlaceholder + selectionColor: root.theme.colors.selection + selectedTextColor: root.theme.colors.textPrimary + font.pixelSize: 15 + selectByMouse: true + background: null + + onTextEdited: root.searchText = text + onAccepted: root.acceptInput(text) + } + } + } + + Text { + Layout.fillWidth: true + visible: root.showPopular + text: root.popularTitle + color: root.theme.colors.textSecondary + font.pixelSize: 13 + } + + Flow { + Layout.fillWidth: true + visible: root.showPopular + spacing: 8 + + Repeater { + model: root.popularTokens + + delegate: AmmTokenSelectButton { + required property var modelData + + theme: root.theme + hasToken: true + tokenColor: root.tokenColor(modelData) + tokenLetter: root.tokenLetter(modelData) + showIndicator: false + text: root.tokenSymbol(modelData).length > 0 + ? root.tokenSymbol(modelData) : root.tokenName(modelData) + maximumTextWidth: 84 + width: Math.min(140, implicitWidth) + onClicked: root.chooseToken(modelData) + } + } + } + + Text { + Layout.fillWidth: true + visible: !root.compact + text: root.listTitle + color: root.theme.colors.textSecondary + font.pixelSize: 13 + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + ListView { + id: tokenList + + objectName: "tokenList" + + anchors.fill: parent + clip: true + spacing: 2 + model: root.rows + boundsBehavior: Flickable.StopAtBounds + ScrollIndicator.vertical: ScrollIndicator { } + + delegate: Item { + id: tokenRow + + required property var modelData + readonly property bool selectable: root.isSelectable(modelData) + readonly property string disabledReason: root.disabledReasonForCode( + modelData.code + || modelData.status) + readonly property bool pointerHovered: rowHover.containsMouse + readonly property bool disabledReasonVisible: (pointerHovered || activeFocus) + && !selectable + + width: ListView.view.width + height: 56 + activeFocusOnTab: true + Accessible.role: Accessible.Button + Accessible.name: root.tokenName(modelData) + Accessible.description: selectable + ? root.detailForToken(modelData) + : disabledReason + Accessible.onPressAction: tokenRow.activate() + Keys.onReturnPressed: function(event) { + tokenRow.activate() + event.accepted = true + } + Keys.onEnterPressed: function(event) { + tokenRow.activate() + event.accepted = true + } + Keys.onSpacePressed: function(event) { + tokenRow.activate() + event.accepted = true + } + + Rectangle { + anchors.fill: parent + radius: 12 + color: tokenRow.pointerHovered || tokenRow.activeFocus + ? root.theme.colors.panelBg : "transparent" + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 12 + + Rectangle { + Layout.preferredWidth: 36 + Layout.preferredHeight: 36 + radius: 18 + color: root.tokenColor(tokenRow.modelData) + + Text { + anchors.centerIn: parent + text: root.tokenLetter(tokenRow.modelData) + color: "#FFFFFF" + font.pixelSize: 14 + font.weight: Font.Bold + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Text { + Layout.fillWidth: true + text: root.tokenName(tokenRow.modelData) + color: tokenRow.selectable + ? root.theme.colors.textPrimary + : root.theme.colors.textPlaceholder + font.pixelSize: 15 + elide: Text.ElideRight + } + + RowLayout { + Layout.fillWidth: true + spacing: 6 + + Text { + visible: text.length > 0 + text: root.tokenSymbol(tokenRow.modelData) + color: root.theme.colors.textSecondary + font.pixelSize: 12 + } + + Text { + Layout.fillWidth: true + text: root.shortAddress(root.tokenAddress(tokenRow.modelData)) + color: root.theme.colors.textPlaceholder + font.family: "monospace" + font.pixelSize: 11 + elide: Text.ElideMiddle + } + } + } + + Text { + Layout.maximumWidth: 118 + text: root.detailForToken(tokenRow.modelData) + color: root.theme.colors.textSecondary + font.pixelSize: 11 + horizontalAlignment: Text.AlignRight + elide: Text.ElideRight + } + } + } + + MouseArea { + id: rowHover + + anchors.fill: parent + hoverEnabled: true + cursorShape: tokenRow.selectable + ? Qt.PointingHandCursor : Qt.ForbiddenCursor + onClicked: tokenRow.activate() + } + + ToolTip.visible: tokenRow.disabledReasonVisible + ToolTip.text: tokenRow.disabledReason + + function activate() { + if (tokenRow.selectable) + root.chooseToken(tokenRow.modelData) + } + } + } + + Button { + id: customEntryButton + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + visible: root.rows.length === 0 + && root.allowCustomEntry + && root.searchText.trim().length > 0 + implicitHeight: 56 + text: qsTr("Use TokenDefinition address") + onClicked: root.acceptInput(root.searchText) + + contentItem: Column { + leftPadding: 8 + spacing: 2 + + Text { + width: parent.width - 16 + text: qsTr("Use TokenDefinition address") + color: root.theme.colors.textPrimary + font.pixelSize: 14 + font.weight: Font.Medium + elide: Text.ElideRight + } + + Text { + width: parent.width - 16 + text: root.searchText + color: root.theme.colors.textSecondary + font.family: "monospace" + font.pixelSize: 11 + elide: Text.ElideMiddle + } + } + + background: Rectangle { + radius: 12 + color: customEntryButton.hovered || customEntryButton.activeFocus + ? root.theme.colors.panelBg : "transparent" + } + } + + Text { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 16 + visible: root.rows.length === 0 + && (!root.allowCustomEntry + || root.searchText.trim().length === 0) + text: qsTr("No tokens found") + color: root.theme.colors.textSecondary + font.pixelSize: 13 + horizontalAlignment: Text.AlignHCenter + } + } + } + + function filteredTokens() { + var needle = root.searchText.trim().toLowerCase() + if (needle.length === 0) + return root.tokens + var result = [] + for (var i = 0; i < root.tokens.length; ++i) { + var token = root.tokens[i] + if (root.tokenName(token).toLowerCase().indexOf(needle) >= 0 + || root.tokenSymbol(token).toLowerCase().indexOf(needle) >= 0 + || root.tokenAddress(token).toLowerCase().indexOf(needle) >= 0) + result.push(token) + } + return result + } + + function selectableTokens() { + var result = [] + for (var i = 0; i < root.tokens.length; ++i) { + if (root.isSelectable(root.tokens[i])) + result.push(root.tokens[i]) + } + return result + } + + function acceptInput(value) { + var entered = String(value || "").trim() + if (entered.length === 0) + return + var lowered = entered.toLowerCase() + for (var i = 0; i < root.tokens.length; ++i) { + var token = root.tokens[i] + if (root.tokenAddress(token).toLowerCase() === lowered + || root.tokenName(token).toLowerCase() === lowered + || root.tokenSymbol(token).toLowerCase() === lowered) { + if (root.isSelectable(token)) + root.chooseToken(token) + else { + root.tokenEntered(root.tokenAddress(token)) + root.close() + } + return + } + } + if (root.rows.length === 1 && root.isSelectable(root.rows[0])) { + root.chooseToken(root.rows[0]) + return + } + if (root.allowCustomEntry) { + root.tokenEntered(entered) + root.close() + } + } + + function chooseToken(token) { + if (!root.isSelectable(token)) + return + root.tokenSelected(token) + root.close() + } + + function isSelectable(token) { + return token && token.selectable !== false + } + + function tokenName(token) { + if (!token) + return qsTr("Unknown token") + return String(token.name || token.symbol || qsTr("Unknown token")) + } + + function tokenSymbol(token) { + return token ? String(token.symbol || "") : "" + } + + function tokenAddress(token) { + return token ? String(token.definitionId || token.address || "") : "" + } + + function tokenColor(token) { + return token && token.color ? token.color : root.theme.colors.noTokenCircle + } + + function tokenLetter(token) { + if (token && token.letter) + return String(token.letter) + var label = root.tokenSymbol(token) || root.tokenName(token) + return label.length > 0 ? label.charAt(0).toUpperCase() : "" + } + + function shortAddress(value) { + var text = String(value || "") + return text.length > 14 ? text.slice(0, 7) + "..." + text.slice(-5) : text + } +} diff --git a/apps/amm/qml/pages/LiquidityPage.qml b/apps/amm/qml/pages/LiquidityPage.qml index 9dde13a6..297b6d9a 100644 --- a/apps/amm/qml/pages/LiquidityPage.qml +++ b/apps/amm/qml/pages/LiquidityPage.qml @@ -1,31 +1,43 @@ -import QtQuick 2.15 -import QtQuick.Layouts 1.15 +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import QtQml + +import Logos.Controls +import Logos.Icons import Logos.Wallet -import "../components/shared" + import "../components/liquidity" import "../state" Item { id: root - property int activeLiquidityTab: 0 - property real slippageTolerancePercent: 0.5 - readonly property int pageMargin: 16 - readonly property int preferredCardWidth: 492 - readonly property int pageCardY: pageCard.implicitHeight + root.pageMargin * 2 <= scroll.height ? Math.round((scroll.height - pageCard.implicitHeight) / 2) : root.pageMargin + property var backend: null + property var runtime: null + readonly property NewPositionFlow flow: newPositionFlow + + readonly property int pageMargin: width < 640 ? 16 : 24 + readonly property int contentMaxWidth: 1200 + readonly property bool wideLayout: width >= 760 + readonly property int stepRailWidth: Math.max(210, Math.min(330, + (width - pageMargin * 2) * 0.30)) + + AmmTheme { + id: theme + } - width: parent ? parent.width : implicitWidth - height: parent ? parent.height : implicitHeight - implicitWidth: root.preferredCardWidth + root.pageMargin * 2 - implicitHeight: pageCard.implicitHeight + root.pageMargin * 2 + NewPositionFlow { + id: newPositionFlow - DummyPoolState { - id: poolState + backend: root.backend + runtime: root.runtime + active: root.visible } Rectangle { anchors.fill: parent - color: "#151515" + color: theme.colors.background } Flickable { @@ -33,133 +45,215 @@ Item { anchors.fill: parent clip: true - contentHeight: Math.max(height, pageCard.y + pageCard.implicitHeight + root.pageMargin) contentWidth: width - enabled: !confirmationDialog.visible + contentHeight: Math.max(height, pageLayout.y + pageLayout.implicitHeight + root.pageMargin) + enabled: !confirmationDialog.opened flickableDirection: Flickable.VerticalFlick + boundsBehavior: Flickable.StopAtBounds - Rectangle { - id: pageCard - - color: "#1B1B1B" - implicitHeight: shellContent.implicitHeight + 24 - radius: 16 - border.color: "#303030" - border.width: 1 - width: Math.max(0, Math.min(scroll.width - root.pageMargin * 2, root.preferredCardWidth)) - x: Math.max(root.pageMargin, (scroll.width - width) / 2) - y: root.pageCardY + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } - ColumnLayout { - id: shellContent + ColumnLayout { + id: pageLayout - anchors.fill: parent - anchors.margins: 12 - spacing: 10 + x: Math.max(root.pageMargin, (scroll.width - width) / 2) + y: root.wideLayout ? 32 : 16 + width: Math.max(0, Math.min(root.contentMaxWidth, + scroll.width - root.pageMargin * 2)) + spacing: 24 - RowLayout { - spacing: 10 + RowLayout { + Layout.fillWidth: true + spacing: 16 + ColumnLayout { Layout.fillWidth: true + spacing: 4 Text { - color: "#E7E1D8" - font.bold: true - font.pixelSize: 18 - text: qsTr("Liquidity") - - Layout.fillWidth: true + text: qsTr("New position") + color: theme.colors.textPrimary + font.pixelSize: 30 + font.weight: Font.Bold + font.letterSpacing: 0 } - Rectangle { - color: "#211914" - radius: 12 - border.color: "#49301F" - border.width: 1 - - Layout.preferredHeight: 26 - Layout.preferredWidth: pairText.implicitWidth + 20 - - Text { - id: pairText - - anchors.centerIn: parent - color: "#F2D8C7" - font.bold: true - font.pixelSize: 12 - text: qsTr("%1 / %2").arg(poolState.tokenA).arg(poolState.tokenB) - } + Text { + Layout.fillWidth: true + text: form.contextStatusText() + color: theme.colors.textSecondary + font.pixelSize: 12 + elide: Text.ElideRight } } - LiquidityActionTabs { - currentIndex: root.activeLiquidityTab - - Layout.fillWidth: true - Layout.preferredHeight: implicitHeight - - onTabRequested: function (index) { - root.activeLiquidityTab = index; - } + LogosIconButton { + objectName: "refreshPositionButton" + iconSource: LogosIcons.refresh + iconColor: theme.colors.textSecondary + iconSize: 18 + Layout.preferredWidth: 40 + Layout.preferredHeight: 40 + enabled: !newPositionFlow.contextLoading && !newPositionFlow.submitting + Accessible.name: qsTr("Refresh position data") + ToolTip.visible: hovered + ToolTip.text: Accessible.name + onClicked: newPositionFlow.refreshContext(true) } + } - PoolPositionSummary { - poolState: poolState + Rectangle { + id: compactSteps - Layout.fillWidth: true - Layout.preferredHeight: implicitHeight - } + objectName: "compactPositionSteps" + Layout.fillWidth: true + implicitHeight: compactStepRow.implicitHeight + 32 + visible: !root.wideLayout + radius: 16 + color: theme.colors.cardBg + border.color: theme.colors.border + border.width: 1 - AddLiquidityForm { - id: addLiquidityForm + RowLayout { + id: compactStepRow - poolState: poolState - slippageTolerancePercent: root.slippageTolerancePercent - visible: root.activeLiquidityTab === 0 + anchors.fill: parent + anchors.margins: 16 + spacing: 12 - Layout.fillWidth: true - Layout.preferredHeight: visible ? implicitHeight : 0 + StepMarker { + Layout.fillWidth: true + colors: theme.colors + stepNumber: 1 + label: qsTr("Select pair and fees") + active: !form.hasPair + complete: form.hasPair + } - onSlippageToleranceChangeRequested: function (tolerancePercent) { - root.slippageTolerancePercent = poolState.clampSlippageTolerancePercent(tolerancePercent); + Rectangle { + Layout.preferredWidth: 20 + Layout.preferredHeight: 1 + color: form.hasPair ? theme.colors.ctaBg : theme.colors.divider } - onAddLiquidityRequested: function (snapshot) { - confirmationDialog.openWithSnapshot(snapshot); + StepMarker { + Layout.fillWidth: true + colors: theme.colors + stepNumber: 2 + label: form.missingPool + ? qsTr("Set price and deposit") + : qsTr("Enter deposit amounts") + active: form.hasPair } } + } + + RowLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop + spacing: root.wideLayout ? 40 : 0 + + Rectangle { + id: positionStepRail + + objectName: "positionStepRail" + Layout.preferredWidth: root.stepRailWidth + Layout.alignment: Qt.AlignTop + implicitHeight: verticalSteps.implicitHeight + 40 + visible: root.wideLayout + radius: 16 + color: theme.colors.cardBg + border.color: theme.colors.border + border.width: 1 + + ColumnLayout { + id: verticalSteps + + anchors.fill: parent + anchors.margins: 20 + spacing: 0 + + StepMarker { + Layout.fillWidth: true + colors: theme.colors + stepNumber: 1 + label: qsTr("Select token pair and fees") + active: !form.hasPair + complete: form.hasPair + } - RemoveLiquidityForm { - id: removeLiquidityForm + Rectangle { + Layout.leftMargin: 17 + Layout.preferredWidth: 1 + Layout.preferredHeight: 28 + color: form.hasPair ? theme.colors.ctaBg : theme.colors.divider + } - poolState: poolState - slippageTolerancePercent: root.slippageTolerancePercent - visible: root.activeLiquidityTab === 1 + StepMarker { + Layout.fillWidth: true + colors: theme.colors + stepNumber: 2 + label: form.missingPool + ? qsTr("Set price and deposit amounts") + : qsTr("Enter deposit amounts") + active: form.hasPair + } + } + } + NewPositionForm { + id: form + + objectName: "newPositionForm" Layout.fillWidth: true - Layout.preferredHeight: visible ? implicitHeight : 0 + Layout.alignment: Qt.AlignTop + theme: theme + headingText: form.hasPair ? qsTr("Deposit tokens") : qsTr("Select pair") + headingDetail: form.hasPair + ? qsTr("Specify the token amounts for your liquidity contribution.") + : qsTr("Choose two tokens and a fee tier for this position.") + showRefreshAction: false + newPositionContext: newPositionFlow.newPositionContext + flowState: newPositionFlow.viewState + + onQuoteRequested: function(immediate, quoteRequest) { + newPositionFlow.scheduleQuote(immediate, quoteRequest) + } - onSlippageToleranceChangeRequested: function (tolerancePercent) { - root.slippageTolerancePercent = poolState.clampSlippageTolerancePercent(tolerancePercent); + onConfirmationRequested: function(snapshot) { + confirmationDialog.openWithSnapshot(snapshot) } - onRemoveLiquidityRequested: function (snapshot) { - confirmationDialog.openWithSnapshot(snapshot); + onTokenResolveRequested: function(tokenId) { + newPositionFlow.resolveToken(tokenId) } + + onDraftChanged: newPositionFlow.draftChanged() + onRefreshRequested: newPositionFlow.refreshContext(true) } } + } + } - SuccessToast { - id: successToast + Connections { + target: newPositionFlow - width: Math.max(0, Math.min(380, parent.width - 24)) + function onTokenResolutionFinished(finalResponse) { + form.finishTokenResolution(finalResponse) + } - anchors { - bottom: parent.bottom - bottomMargin: 14 - horizontalCenter: parent.horizontalCenter - } - } + function onTokenResolutionFailed(code) { + form.failTokenResolution(code) + } + + function onPoolActivated(quote) { + form.acceptPoolActivation(quote) + } + + function onQuoteRefreshRequested(immediate) { + form.requestQuote(immediate) } } @@ -171,28 +265,73 @@ Item { TransactionConfirmationDialog { id: confirmationDialog - title: snapshot.action === "add" - ? qsTr("Confirm add liquidity") - : qsTr("Confirm remove liquidity") + + title: qsTr("Confirm new position") + confirmText: qsTr("Submit") + busy: newPositionFlow.submitting summary: liquidityConfirmationSummary - onConfirmed: function (snapshot) { - root.confirmLiquidityAction(snapshot); + onConfirmed: function(snapshot) { + newPositionFlow.confirm(snapshot) } } - function confirmLiquidityAction(snapshot) { - if (snapshot.action === "add") { - poolState.applyAddLiquidity(snapshot.actualA, snapshot.actualB, snapshot.deltaLp); - addLiquidityForm.resetForm(); - successToast.show(qsTr("Liquidity added"), qsTr("Position updated")); - return; + component StepMarker: RowLayout { + id: marker + + required property var colors + required property int stepNumber + required property string label + property bool active: false + property bool complete: false + + spacing: 12 + + Rectangle { + Layout.preferredWidth: 36 + Layout.preferredHeight: 36 + radius: 18 + color: marker.active + ? marker.colors.ctaBg + : marker.complete ? marker.colors.selection : marker.colors.inputBg + border.color: marker.active || marker.complete + ? marker.colors.ctaBg : marker.colors.borderStrong + border.width: 1 + Accessible.ignored: true + + Text { + anchors.centerIn: parent + text: marker.stepNumber + color: marker.active + ? marker.colors.background + : marker.complete ? marker.colors.ctaBg : marker.colors.textPlaceholder + font.pixelSize: 13 + font.weight: Font.DemiBold + } } - if (snapshot.action === "remove") { - poolState.applyRemoveLiquidity(snapshot.withdrawA, snapshot.withdrawB, snapshot.burnAmount); - removeLiquidityForm.resetForm(); - successToast.show(qsTr("Liquidity removed"), qsTr("Position updated")); + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Text { + Layout.fillWidth: true + text: qsTr("Step %1").arg(marker.stepNumber) + color: marker.active || marker.complete + ? marker.colors.textSecondary : marker.colors.textPlaceholder + font.pixelSize: 11 + elide: Text.ElideRight + } + + Text { + Layout.fillWidth: true + text: marker.label + color: marker.active || marker.complete + ? marker.colors.textPrimary : marker.colors.textSecondary + font.pixelSize: 13 + font.weight: marker.active ? Font.DemiBold : Font.Normal + wrapMode: Text.Wrap + } } } } diff --git a/apps/amm/qml/state/DummyPoolState.qml b/apps/amm/qml/state/DummyPoolState.qml deleted file mode 100644 index 7e4d573d..00000000 --- a/apps/amm/qml/state/DummyPoolState.qml +++ /dev/null @@ -1,205 +0,0 @@ -import QtQuick 2.15 - -QtObject { - id: root - - property string tokenA: "USDC" - property string tokenB: "ETH" - property string feeTier: "0.30%" - property real userLpBalance: 1118033 - property real reserveA: 1000000 - property real reserveB: 500 - property real totalLpSupply: 22360679 - property real walletBalanceA: 60000 - property real walletBalanceB: 20 - readonly property real minimumLiquidity: 1000 - - readonly property real poolShare: totalLpSupply > 0 ? userLpBalance / totalLpSupply : 0 - readonly property real userOwnedA: reserveA * poolShare - readonly property real userOwnedB: reserveB * poolShare - readonly property real tokenAPerTokenB: reserveB > 0 ? Math.floor(reserveA / reserveB) : 0 - - function applyAddLiquidity(actualA, actualB, mintedLp) { - const safeA = Math.max(0, Number(actualA) || 0); - const safeB = Math.max(0, Number(actualB) || 0); - const safeLp = Math.max(0, Number(mintedLp) || 0); - - reserveA += safeA; - reserveB += safeB; - totalLpSupply += safeLp; - userLpBalance += safeLp; - } - - function applyRemoveLiquidity(withdrawA, withdrawB, burnedLp) { - const safeA = Math.max(0, Number(withdrawA) || 0); - const safeB = Math.max(0, Number(withdrawB) || 0); - const safeLp = Math.max(0, Number(burnedLp) || 0); - - reserveA = Math.max(0, reserveA - safeA); - reserveB = Math.max(0, reserveB - safeB); - totalLpSupply = Math.max(0, totalLpSupply - safeLp); - userLpBalance = Math.max(0, userLpBalance - safeLp); - } - - function resetDummyState() { - tokenA = "USDC"; - tokenB = "ETH"; - feeTier = "0.30%"; - userLpBalance = 1118033; - reserveA = 1000000; - reserveB = 500; - totalLpSupply = 22360679; - walletBalanceA = 60000; - walletBalanceB = 20; - } - - function parseAmount(value) { - return Math.max(0, Number(value) || 0); - } - - function floorAmount(value) { - return Math.floor(parseAmount(value)); - } - - function amountBForA(amountA) { - if (reserveA <= 0) { - return 0; - } - - return reserveB * parseAmount(amountA) / reserveA; - } - - function amountAForB(amountB) { - if (reserveB <= 0) { - return 0; - } - - return reserveA * parseAmount(amountB) / reserveB; - } - - function addLiquidityPreview(maxA, maxB) { - const safeMaxA = parseAmount(maxA); - const safeMaxB = parseAmount(maxB); - const idealA = reserveB > 0 ? reserveA * safeMaxB / reserveB : 0; - const idealB = reserveA > 0 ? reserveB * safeMaxA / reserveA : 0; - const actualA = Math.min(idealA, safeMaxA); - const actualB = Math.min(idealB, safeMaxB); - const lpFromA = reserveA > 0 ? Math.floor(totalLpSupply * actualA / reserveA) : 0; - const lpFromB = reserveB > 0 ? Math.floor(totalLpSupply * actualB / reserveB) : 0; - - return { - "actualA": actualA, - "actualB": actualB, - "deltaLp": Math.min(lpFromA, lpFromB), - "idealA": idealA, - "idealB": idealB - }; - } - - function maxAddLiquidityForBalances() { - return addLiquidityPreview(walletBalanceA, walletBalanceB); - } - - function clampBurnAmount(value) { - return Math.min(floorAmount(value), Math.max(0, floorAmount(userLpBalance))); - } - - function clampSlippageTolerancePercent(value) { - return Math.max(0.01, Math.min(50, Number(value) || 0)); - } - - function minReceivedAmount(previewAmount, slippageTolerancePercent) { - const safeAmount = floorAmount(previewAmount); - const safeSlippage = clampSlippageTolerancePercent(slippageTolerancePercent); - - return Math.floor(safeAmount * (1 - safeSlippage / 100)); - } - - function burnAmountForPercent(percent) { - const safePercent = Math.max(0, Math.min(100, Number(percent) || 0)); - - if (safePercent === 100) { - return clampBurnAmount(userLpBalance); - } - - return clampBurnAmount(Math.floor(userLpBalance * safePercent / 100)); - } - - function removeLiquidityPreview(burnedLp) { - const safeBurnedLp = totalLpSupply > 0 ? Math.min(clampBurnAmount(burnedLp), floorAmount(totalLpSupply)) : 0; - const withdrawA = totalLpSupply > 0 ? Math.floor(reserveA * safeBurnedLp / totalLpSupply) : 0; - const withdrawB = totalLpSupply > 0 ? Math.floor(reserveB * safeBurnedLp / totalLpSupply) : 0; - const newTotalLpSupply = Math.max(0, floorAmount(totalLpSupply) - safeBurnedLp); - const newUserLpBalance = Math.max(0, floorAmount(userLpBalance) - safeBurnedLp); - - return { - "burnedLp": safeBurnedLp, - "newReserveA": Math.max(0, reserveA - withdrawA), - "newReserveB": Math.max(0, reserveB - withdrawB), - "newTotalLpSupply": newTotalLpSupply, - "newUserLpBalance": newUserLpBalance, - "newUserShare": newTotalLpSupply > 0 ? newUserLpBalance / newTotalLpSupply : 0, - "withdrawA": withdrawA, - "withdrawB": withdrawB - }; - } - - function formatInteger(value) { - const rounded = Math.round(Number(value) || 0); - return rounded.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); - } - - function formatDecimal(value) { - const amount = Number(value) || 0; - - if (Math.abs(amount - Math.round(amount)) < 0.000001) { - return formatInteger(amount); - } - - return amount.toFixed(6).replace(/0+$/, "").replace(/[.]$/, ""); - } - - function formatCompactDecimal(value) { - const amount = Number(value) || 0; - - if (Math.abs(amount) >= 1000 || Math.abs(amount - Math.round(amount)) < 0.000001) { - return formatInteger(amount); - } - - if (Math.abs(amount) >= 1) { - return amount.toFixed(2).replace(/0+$/, "").replace(/[.]$/, ""); - } - - return amount.toFixed(6).replace(/0+$/, "").replace(/[.]$/, ""); - } - - function formatInputAmount(value) { - return formatDecimal(value); - } - - function formatTokenAmount(value, token) { - return formatDecimal(value) + " " + token; - } - - function formatCompactTokenAmount(value, token) { - return formatCompactDecimal(value) + " " + token; - } - - function formatLpAmount(value) { - return formatInteger(value) + " LP"; - } - - function formatPoolShare(value) { - return "\u2248 " + (Math.max(0, Number(value) || 0) * 100).toFixed(2) + "%"; - } - - function formatPercent(value) { - const amount = Math.max(0, Number(value) || 0); - - if (Math.abs(amount - Math.round(amount)) < 0.000001) { - return Math.round(amount).toString() + "%"; - } - - return amount.toFixed(2).replace(/0+$/, "").replace(/[.]$/, "") + "%"; - } -} diff --git a/apps/amm/qml/state/NewPositionFlow.qml b/apps/amm/qml/state/NewPositionFlow.qml new file mode 100644 index 00000000..6edba5cc --- /dev/null +++ b/apps/amm/qml/state/NewPositionFlow.qml @@ -0,0 +1,375 @@ +import QtQml + +QtObject { + id: root + + property var backend: null + property var runtime: null + property bool active: false + + readonly property bool walletStateReady: root.backend !== null + && root.backend.walletStateReady === true + readonly property var newPositionContext: root.walletStateReady + && root.backend.newPositionContext + ? root.backend.newPositionContext + : root.loadingContext() + readonly property var viewState: ({ + "quote": root.newPositionQuote, + "contextLoading": root.contextLoading || !root.walletStateReady + || root.newPositionContext.status === "loading", + "quoteLoading": root.quoteLoading, + "quoteStale": root.quoteStale, + "submitting": root.submitting, + "poolCreationPending": root.selectedPoolCreationPending(), + "transactionId": root.transactionId, + "errorCode": root.flowErrorCode || root.contextErrorCode + || root.quoteErrorCode + }) + + property var newPositionQuote: ({}) + property var resolvedTokenIds: [] + property int contextSerial: 0 + property int quoteSerial: 0 + property bool contextLoading: false + property bool quoteLoading: false + property bool quoteStale: true + property bool submitting: false + property string transactionId: "" + property string flowErrorCode: "" + property string contextErrorCode: "" + property string quoteErrorCode: "" + property var pendingQuoteRequest: ({ "ok": false, "request": ({}) }) + property var pendingPoolProbes: [] + property bool poolProbeInFlight: false + + signal tokenResolutionFinished(bool finalResponse) + signal tokenResolutionFailed(string code) + signal poolActivated(var quote) + signal quoteRefreshRequested(bool immediate) + signal submitSucceeded + signal submitFailed + + objectName: "newPositionFlow" + + property Timer quoteDebounce: Timer { + interval: 250 + repeat: false + onTriggered: root.requestQuoteNow(root.quoteSerial) + } + + property Timer poolPoller: Timer { + interval: 5000 + repeat: true + running: root.pendingPoolProbes.length > 0 + onTriggered: root.pollPendingPool() + } + + onNewPositionContextChanged: root.invalidateQuote() + + onWalletStateReadyChanged: { + ++root.contextSerial + if (!root.walletStateReady) + root.contextLoading = false + root.invalidateQuote() + } + + onActiveChanged: { + if (!root.active) + return + Qt.callLater(function() { + if (root.active && root.walletStateReady) + root.quoteRefreshRequested(true) + }) + } + + function contextHints(refreshWalletAccounts) { + const request = root.pendingQuoteRequest.request || {} + const recent = [] + if (request.tokenAId) + recent.push(request.tokenAId) + if (request.tokenBId && request.tokenBId !== request.tokenAId) + recent.push(request.tokenBId) + return { + "recentTokenIds": recent, + "resolvedTokenIds": root.resolvedTokenIds, + "refreshWalletAccounts": refreshWalletAccounts === true + } + } + + function refreshContext(refreshWalletAccounts, completed) { + const serial = ++root.contextSerial + root.contextLoading = true + if (!root.walletStateReady || root.runtime === null) { + root.contextLoading = false + return + } + + root.runtime.watch(root.backend.refreshNewPositionContext( + root.contextHints(refreshWalletAccounts)), + function() { + root.finishContextRefresh(serial, completed) + }, + function(error) { + root.failContextRefresh(serial) + }) + } + + function finishContextRefresh(serial, completed) { + if (serial !== root.contextSerial) + return + root.contextLoading = false + root.contextErrorCode = "" + Qt.callLater(function() { + if (serial !== root.contextSerial) + return + root.tokenResolutionFinished(true) + if (completed) + completed() + }) + } + + function failContextRefresh(serial) { + if (serial !== root.contextSerial) + return + root.contextLoading = false + root.contextErrorCode = "backend_error" + root.tokenResolutionFailed("backend_error") + } + + function resolveToken(tokenId) { + const value = String(tokenId || "").trim() + if (value.length === 0) + return + if (root.resolvedTokenIds.indexOf(value) < 0) { + const next = root.resolvedTokenIds.slice(0) + next.push(value) + root.resolvedTokenIds = next + } + root.refreshContext(false) + } + + function scheduleQuote(immediate, quoteRequest) { + ++root.quoteSerial + root.pendingQuoteRequest = quoteRequest + root.quoteStale = true + root.quoteLoading = root.walletStateReady && root.active + root.quoteDebounce.stop() + if (!root.walletStateReady || !root.active) + return + if (immediate) + root.requestQuoteNow(root.quoteSerial) + else + root.quoteDebounce.restart() + } + + function requestQuoteNow(serial) { + if (serial !== root.quoteSerial) + return + const built = root.pendingQuoteRequest + if (!built.ok) { + root.quoteLoading = false + return + } + if (!root.walletStateReady || !root.active || root.runtime === null) { + root.quoteLoading = false + return + } + + root.runtime.watch(root.backend.quoteNewPosition(built.request), + function(quote) { + if (serial !== root.quoteSerial) + return + root.quoteLoading = false + root.quoteStale = false + root.quoteErrorCode = "" + if (!quote || quote.schema !== "new-position.v1") + root.newPositionQuote = root.quoteError("unsupported_schema") + else + root.newPositionQuote = quote + }, + function(error) { + if (serial !== root.quoteSerial) + return + root.quoteLoading = false + root.quoteStale = true + root.quoteErrorCode = "backend_error" + }) + } + + function confirm(snapshot) { + if (root.submitting) + return + root.submitting = true + root.flowErrorCode = "" + + if (!root.backend || root.runtime === null) { + root.finishSubmitFailure(root.quoteError("wallet_unavailable")) + return + } + + root.runtime.watch(root.backend.submitNewPosition(snapshot.request, snapshot.quoteHash), + function(result) { + if (result && result.schema === "new-position.v1" + && result.status === "submitted" + && /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test( + String(result.transactionId || ""))) { + if (snapshot.request.initialPriceRealRaw !== undefined) + root.watchPoolCreation(snapshot.poolProbeRequest, result.deadlineMs) + root.submitting = false + root.transactionId = result.transactionId + root.flowErrorCode = "" + root.contextErrorCode = "" + root.quoteErrorCode = "" + root.invalidateQuote() + root.submitSucceeded() + return + } + root.finishSubmitFailure(result || root.quoteError("wallet_submission_failed")) + }, + function(error) { + root.finishSubmitFailure(root.quoteError("wallet_submission_failed")) + }) + } + + function finishSubmitFailure(result) { + root.submitting = false + const hasFreshQuote = result && result.quote + && result.quote.schema === "new-position.v1" + if (hasFreshQuote) { + root.newPositionQuote = result.quote + root.quoteLoading = false + root.quoteStale = false + } + const code = result && result.code ? result.code : "wallet_submission_failed" + root.flowErrorCode = code + root.submitFailed() + if (hasFreshQuote) + return + root.scheduleQuote(true, root.pendingQuoteRequest) + } + + function watchPoolCreation(request, deadlineMs) { + const key = root.pairKey(request) + let deadline = Number(deadlineMs) + if (!isFinite(deadline) || deadline <= 0) + deadline = 0 + const pending = root.pendingPoolProbes.filter(function(item) { + return item.key !== key + }) + pending.push({ + "key": key, + "request": request, + "deadlineMs": deadline + }) + root.pendingPoolProbes = pending + Qt.callLater(root.pollPendingPool) + } + + function pollPendingPool() { + if (root.poolProbeInFlight || root.pendingPoolProbes.length === 0 + || !root.walletStateReady || root.runtime === null) { + return + } + const pending = root.pendingPoolProbes[0] + root.poolProbeInFlight = true + root.runtime.watch(root.backend.quoteNewPosition(pending.request), + function(quote) { + root.finishPoolProbe(pending, quote) + }, + function(error) { + root.finishPoolProbe(pending, null) + }) + } + + function finishPoolProbe(pending, quote) { + root.poolProbeInFlight = false + if (quote && quote.schema === "new-position.v1" + && quote.poolStatus === "active_pool") { + root.removePendingPool(pending.key) + if (root.matchesSelectedPair(pending.request)) { + root.poolActivated(quote) + root.invalidateQuote() + root.refreshContext(true) + } + return + } + if (pending.deadlineMs > 0 && Date.now() >= pending.deadlineMs) { + root.removePendingPool(pending.key) + return + } + root.rotatePendingPool() + } + + function pairKey(request) { + return String(request.tokenAId || "") + ":" + String(request.tokenBId || "") + } + + function matchesSelectedPair(request) { + return root.pairKey(root.pendingQuoteRequest.request || {}) === root.pairKey(request) + } + + function selectedPoolCreationPending() { + const request = root.pendingQuoteRequest.request || {} + if (!request.tokenAId || !request.tokenBId) + return false + const selected = root.pairKey(request) + return root.pendingPoolProbes.some(function(item) { + return item.key === selected + }) + } + + function removePendingPool(key) { + root.pendingPoolProbes = root.pendingPoolProbes.filter(function(item) { + return item.key !== key + }) + } + + function rotatePendingPool() { + if (root.pendingPoolProbes.length < 2) + return + const pending = root.pendingPoolProbes.slice(1) + pending.push(root.pendingPoolProbes[0]) + root.pendingPoolProbes = pending + } + + function draftChanged() { + root.invalidateQuote() + root.transactionId = "" + root.flowErrorCode = "" + root.contextErrorCode = "" + root.quoteErrorCode = "" + } + + function invalidateQuote() { + ++root.quoteSerial + root.quoteDebounce.stop() + root.quoteLoading = false + root.quoteStale = true + } + + function loadingContext() { + return { + "schema": "new-position.v1", + "status": "loading", + "tokens": [], + "feeTiers": [] + } + } + + function quoteError(code) { + return { + "schema": "new-position.v1", + "status": "error", + "canSubmit": false, + "code": code, + "poolStatus": "unavailable_pool", + "errors": [{ + "code": code, + "blockingFields": [], + "details": ({}) + }], + "warnings": [], + "accountPreview": [] + } + } +} diff --git a/apps/amm/src/ActiveNetwork.cpp b/apps/amm/src/ActiveNetwork.cpp new file mode 100644 index 00000000..c92bc73b --- /dev/null +++ b/apps/amm/src/ActiveNetwork.cpp @@ -0,0 +1,141 @@ +#include "ActiveNetwork.h" + +#include +#include +#include +#include + +namespace { + const char NETWORK_ENV[] = "AMM_UI_NETWORK"; + const char DEVNET_FILE_ENV[] = "AMM_UI_DEVNET_FILE"; + + bool isLowerHex(const QString& value, int size) + { + if (value.size() != size) + return false; + for (const QChar character : value) { + const bool isAsciiDigit = character >= QLatin1Char('0') + && character <= QLatin1Char('9'); + if (!isAsciiDigit + && (character < QLatin1Char('a') || character > QLatin1Char('f'))) { + return false; + } + } + return true; + } +} + +bool ActiveNetwork::load() +{ + m_network = {}; + m_network.status = QStringLiteral("config_missing"); + m_expectedIdentity.clear(); + + const QByteArray selected = qgetenv(NETWORK_ENV); + m_network.id = selected.isEmpty() + ? QStringLiteral("testnet") + : QString::fromLocal8Bit(selected).trimmed(); + + QJsonObject entry; + if (isDevnet()) { + const QString path = QString::fromLocal8Bit(qgetenv(DEVNET_FILE_ENV)); + QFile file(path); + if (path.isEmpty() || !file.open(QIODevice::ReadOnly)) + return false; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll()); + if (!document.isObject()) + return false; + entry = document.object(); + m_expectedIdentity = entry.value(QStringLiteral("channelId")).toString(); + } else { + QFile file(QStringLiteral(":/amm/config/networks.json")); + if (!file.open(QIODevice::ReadOnly)) + return false; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll()); + if (!document.isObject()) + return false; + const QJsonObject networks = document.object(); + entry = networks.value(m_network.id).toObject(); + m_expectedIdentity = entry.value(QStringLiteral("checkpointHash")).toString(); + } + + m_network.ammProgramId = entry.value(QStringLiteral("ammProgramId")).toString(); + if (!isValidIdentity(m_expectedIdentity) + || !isLowerHex(m_network.ammProgramId, 64)) { + return false; + } + + for (const QJsonValue& value : entry.value(QStringLiteral("tokenDefinitionIds")).toArray()) { + const QString id = value.toString(); + if (!isLowerHex(id, 64)) { + m_network.tokenIds.clear(); + return false; + } + m_network.tokenIds.append(id); + } + m_network.status = QStringLiteral("network_unknown"); + return true; +} + +bool ActiveNetwork::isConfigured() const +{ + return m_network.status != QStringLiteral("config_missing"); +} + +bool ActiveNetwork::isDevnet() const +{ + return m_network.id == QStringLiteral("devnet"); +} + +bool ActiveNetwork::needsIdentityProbe() const +{ + return m_network.status == QStringLiteral("loading") + || m_network.status == QStringLiteral("network_unknown"); +} + +void ActiveNetwork::sequencerChanged(bool available) +{ + if (isConfigured()) + clearIdentity(available ? QStringLiteral("loading") : QStringLiteral("network_unknown")); +} + +void ActiveNetwork::reachabilityChanged(bool reachable, bool wasReachable) +{ + if (!isConfigured()) + return; + if (!reachable) + clearIdentity(QStringLiteral("network_unknown")); + else if (!wasReachable) + clearIdentity(QStringLiteral("loading")); +} + +void ActiveNetwork::beginIdentityProbe() +{ + if (isConfigured()) + clearIdentity(QStringLiteral("loading")); +} + +void ActiveNetwork::finishIdentityProbe(const QString& identity) +{ + if (identity.isEmpty()) { + clearIdentity(QStringLiteral("network_unknown")); + } else if (identity != m_expectedIdentity) { + clearIdentity(QStringLiteral("network_mismatch")); + } else { + m_network.status = QStringLiteral("ready"); + m_network.fingerprint = (isDevnet() ? QStringLiteral("channel:") + : QStringLiteral("block10:")) + + identity; + } +} + +bool ActiveNetwork::isValidIdentity(const QString& value) +{ + return isLowerHex(value, 64); +} + +void ActiveNetwork::clearIdentity(const QString& status) +{ + m_network.status = status; + m_network.fingerprint.clear(); +} diff --git a/apps/amm/src/ActiveNetwork.h b/apps/amm/src/ActiveNetwork.h new file mode 100644 index 00000000..9b1db9b7 --- /dev/null +++ b/apps/amm/src/ActiveNetwork.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +struct ActiveNetworkSnapshot { + QString id; + QString status; + QString fingerprint; + QString ammProgramId; + QStringList tokenIds; +}; + +class ActiveNetwork final { +public: + bool load(); + + const QString& status() const { return m_network.status; } + bool isConfigured() const; + bool isDevnet() const; + bool needsIdentityProbe() const; + ActiveNetworkSnapshot snapshot() const { return m_network; } + + void sequencerChanged(bool available); + void reachabilityChanged(bool reachable, bool wasReachable); + void beginIdentityProbe(); + void finishIdentityProbe(const QString& identity); + + static bool isValidIdentity(const QString& value); + +private: + void clearIdentity(const QString& status); + + ActiveNetworkSnapshot m_network; + QString m_expectedIdentity; +}; diff --git a/apps/amm/src/AmmClient.cpp b/apps/amm/src/AmmClient.cpp new file mode 100644 index 00000000..17d6d61b --- /dev/null +++ b/apps/amm/src/AmmClient.cpp @@ -0,0 +1,71 @@ +#include "AmmClient.h" + +#include +#include +#include + +#include "amm_client.h" + +namespace { + using Operation = char* (*)(const char*); + + AmmClientResult call(Operation operation, const QJsonObject& request) + { + const QByteArray payload = QJsonDocument(request).toJson(QJsonDocument::Compact); + char* raw = operation(payload.constData()); + if (!raw) { + qWarning() << "AmmClient: bundled client returned a null response"; + return {}; + } + const QByteArray response(raw); + amm_free(raw); + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(response, &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + qWarning() << "AmmClient: bundled client returned invalid JSON"; + return {}; + } + const QJsonObject envelope = document.object(); + if (!envelope.value(QStringLiteral("ok")).toBool()) { + qWarning() << "AmmClient: bundled client failure:" + << envelope.value(QStringLiteral("error")).toString(); + return {}; + } + if (!envelope.value(QStringLiteral("value")).isObject()) { + qWarning() << "AmmClient: bundled client value is not an object"; + return {}; + } + return { true, envelope.value(QStringLiteral("value")).toObject() }; + } +} + +AmmClientResult BundledAmmClient::configId(const QJsonObject& request) const +{ + return call(amm_config_id, request); +} + +AmmClientResult BundledAmmClient::tokenIds(const QJsonObject& request) const +{ + return call(amm_token_ids, request); +} + +AmmClientResult BundledAmmClient::pairIds(const QJsonObject& request) const +{ + return call(amm_pair_ids, request); +} + +AmmClientResult BundledAmmClient::context(const QJsonObject& request) const +{ + return call(amm_context, request); +} + +AmmClientResult BundledAmmClient::quote(const QJsonObject& request) const +{ + return call(amm_quote, request); +} + +AmmClientResult BundledAmmClient::plan(const QJsonObject& request) const +{ + return call(amm_plan, request); +} diff --git a/apps/amm/src/AmmClient.h b/apps/amm/src/AmmClient.h new file mode 100644 index 00000000..6c69f48a --- /dev/null +++ b/apps/amm/src/AmmClient.h @@ -0,0 +1,30 @@ +#pragma once + +#include + +struct AmmClientResult { + bool ok = false; + QJsonObject value; +}; + +class AmmClient { +public: + virtual ~AmmClient() = default; + + virtual AmmClientResult configId(const QJsonObject& request) const = 0; + virtual AmmClientResult tokenIds(const QJsonObject& request) const = 0; + virtual AmmClientResult pairIds(const QJsonObject& request) const = 0; + virtual AmmClientResult context(const QJsonObject& request) const = 0; + virtual AmmClientResult quote(const QJsonObject& request) const = 0; + virtual AmmClientResult plan(const QJsonObject& request) const = 0; +}; + +class BundledAmmClient final : public AmmClient { +public: + AmmClientResult configId(const QJsonObject& request) const override; + AmmClientResult tokenIds(const QJsonObject& request) const override; + AmmClientResult pairIds(const QJsonObject& request) const override; + AmmClientResult context(const QJsonObject& request) const override; + AmmClientResult quote(const QJsonObject& request) const override; + AmmClientResult plan(const QJsonObject& request) const override; +}; diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index 6b30cd13..353c4a3a 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -20,7 +21,9 @@ #include #include +#include "AmmClient.h" #include "LogosWalletProvider.h" +#include "NewPositionRuntime.h" #include "WalletController.h" #include "logos_api.h" #include "logos_sdk.h" @@ -141,18 +144,70 @@ namespace { } } +namespace { + const int CHECKPOINT_BLOCK_ID = 10; + const int BLOCK_HASH_OFFSET = 40; + const int BLOCK_HASH_SIZE = 32; + + QByteArray jsonRpcBody(const QString& method, const QJsonArray& params) + { + return QJsonDocument(QJsonObject { + { QStringLiteral("jsonrpc"), QStringLiteral("2.0") }, + { QStringLiteral("id"), 1 }, + { QStringLiteral("method"), method }, + { QStringLiteral("params"), params }, + }).toJson(QJsonDocument::Compact); + } + + QString blockHashFromResponse(const QByteArray& payload) + { + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(payload, &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + return {}; + const QByteArray block = + QByteArray::fromBase64(document.object().value(QStringLiteral("result")).toString().toLatin1()); + if (block.size() < BLOCK_HASH_OFFSET + BLOCK_HASH_SIZE) + return {}; + return QString::fromLatin1(block.mid(BLOCK_HASH_OFFSET, BLOCK_HASH_SIZE).toHex()); + } + + QString channelIdFromResponse(const QByteArray& payload) + { + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(payload, &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + return {}; + const QString channel = document.object().value(QStringLiteral("result")).toString(); + return ActiveNetwork::isValidIdentity(channel) ? channel : QString(); + } + +} + AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent) : AmmUiBackendSimpleSource(parent), m_logosAPI(logosAPI ? logosAPI : new LogosAPI("amm_ui", this)), m_logos(std::make_unique(m_logosAPI)), m_wallet(std::make_unique(m_logosAPI)), m_walletController(std::make_unique( - *m_wallet, QStringLiteral("AmmUI"))) + *m_wallet, QStringLiteral("AmmUI"))), + m_ammClient(std::make_unique()), + m_newPosition(std::make_unique(m_wallet.get(), m_ammClient.get())), + m_net(new QNetworkAccessManager(this)) { + setWalletStateReady(false); + m_network.load(); + setNewPositionContext(m_newPosition->context( + QVariantMap(), m_network.snapshot(), false, false)); + connect(m_walletController.get(), &WalletController::stateChanged, this, &AmmUiBackend::syncWalletState); syncWalletState(); m_walletController->start(); + QTimer::singleShot(0, this, [this]() { + setWalletStateReady(true); + syncWalletState(); + }); } AmmUiBackend::~AmmUiBackend() = default; @@ -162,6 +217,42 @@ WalletAccountModel* AmmUiBackend::accountModel() const return m_walletController->accountModel(); } +QString AmmUiBackend::createNewDefault(QString password) +{ + setWalletStateReady(false); + const QString mnemonic = m_walletController->createDefaultWallet(password); + setWalletStateReady(true); + syncWalletState(); + return mnemonic; +} + +QString AmmUiBackend::createNew(QString configPath, QString storagePath, QString password) +{ + setWalletStateReady(false); + const QString mnemonic = + m_walletController->createWallet(configPath, storagePath, password); + setWalletStateReady(true); + syncWalletState(); + return mnemonic; +} + +bool AmmUiBackend::openExisting() +{ + setWalletStateReady(false); + const bool opened = m_walletController->open(); + setWalletStateReady(true); + syncWalletState(); + return opened; +} + +void AmmUiBackend::disconnectWallet() +{ + m_walletController->disconnect(); + setWalletStateReady(true); + m_newPosition->clearWalletAccounts(); + refreshNewPositionContext(QVariantMap()); +} + QString AmmUiBackend::createAccountPublic() { return m_walletController->createAccount(true); @@ -187,31 +278,41 @@ QString AmmUiBackend::getBalance(QString accountIdHex, bool isPublic) return m_walletController->balance(accountIdHex, isPublic); } -QString AmmUiBackend::createNewDefault(QString password) -{ - return m_walletController->createDefaultWallet(password); -} - -QString AmmUiBackend::createNew(QString configPath, - QString storagePath, - QString password) +void AmmUiBackend::refreshNewPositionContext(QVariantMap request) { - return m_walletController->createWallet(configPath, storagePath, password); + const bool refreshWalletAccounts = + request.take(QStringLiteral("refreshWalletAccounts")).toBool(); + if (request.contains(QStringLiteral("recentTokenIds")) + || request.contains(QStringLiteral("resolvedTokenIds"))) { + m_newPositionHints = request; + } + else { + request = m_newPositionHints; + } + if (m_network.status() == QStringLiteral("network_unknown")) + probeNetworkIdentity(); + setNewPositionContext(m_newPosition->context( + request, m_network.snapshot(), isWalletOpen(), refreshWalletAccounts)); } -bool AmmUiBackend::openExisting() +QVariantMap AmmUiBackend::quoteNewPosition(QVariantMap request) { - return m_walletController->open(); + return m_newPosition->quote(request, m_network.snapshot(), isWalletOpen()); } -void AmmUiBackend::disconnectWallet() +QVariantMap AmmUiBackend::submitNewPosition(QVariantMap request, QString quoteHash) { - m_walletController->disconnect(); + return m_newPosition->submit( + request, quoteHash, m_network.snapshot(), isWalletOpen()); } void AmmUiBackend::syncWalletState() { const WalletUiState& state = m_walletController->state(); + const bool walletWasOpen = isWalletOpen(); + const bool wasReachable = sequencerReachable(); + const QString previousAddress = sequencerAddr(); + setIsWalletOpen(state.isWalletOpen); setWalletExists(state.walletExists); setConfigPath(state.configPath); @@ -221,6 +322,70 @@ void AmmUiBackend::syncWalletState() setCurrentBlockHeight(state.currentBlockHeight); setSequencerAddr(state.sequencerAddress); setSequencerReachable(state.sequencerReachable); + + const bool addressChanged = previousAddress != state.sequencerAddress; + if (addressChanged) + m_network.sequencerChanged(!state.sequencerAddress.isEmpty()); + if (addressChanged || wasReachable != state.sequencerReachable) { + m_network.reachabilityChanged(state.sequencerReachable, wasReachable); + } + if (walletWasOpen && !state.isWalletOpen) + m_newPosition->clearWalletAccounts(); + + publishNetworkContext(); + if (state.sequencerReachable && m_network.needsIdentityProbe()) + probeNetworkIdentity(); +} + +void AmmUiBackend::probeNetworkIdentity() +{ + if (m_identityProbeInFlight + || !m_network.isConfigured() + || sequencerAddr().isEmpty()) { + return; + } + m_identityProbeInFlight = true; + m_network.beginIdentityProbe(); + publishNetworkContext(); + const QString address = sequencerAddr(); + const bool devnet = m_network.isDevnet(); + const QString method = devnet + ? QStringLiteral("getChannelId") + : QStringLiteral("getBlock"); + const QJsonArray params = devnet + ? QJsonArray() + : QJsonArray { CHECKPOINT_BLOCK_ID }; + + QNetworkRequest request{QUrl(address)}; + request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); + request.setTransferTimeout(4000); + QNetworkReply* reply = m_net->post(request, jsonRpcBody(method, params)); + connect(reply, &QNetworkReply::finished, this, [this, reply, address, devnet]() { + m_identityProbeInFlight = false; + if (address != sequencerAddr()) { + reply->deleteLater(); + probeNetworkIdentity(); + return; + } + if (!sequencerReachable()) { + reply->deleteLater(); + return; + } + + const QByteArray payload = reply->readAll(); + const QString actual = devnet + ? channelIdFromResponse(payload) + : blockHashFromResponse(payload); + m_network.finishIdentityProbe(actual); + reply->deleteLater(); + publishNetworkContext(); + }); +} + +void AmmUiBackend::publishNetworkContext() +{ + setNewPositionContext(m_newPosition->context( + m_newPositionHints, m_network.snapshot(), isWalletOpen(), false)); } QString AmmUiBackend::normalizeAccountId(const QString& id) diff --git a/apps/amm/src/AmmUiBackend.h b/apps/amm/src/AmmUiBackend.h index 244ccc6d..df694e6b 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -3,12 +3,15 @@ #include +#include #include +#include #include #include #include "rep_AmmUiBackend_source.h" +#include "ActiveNetwork.h" #include "WalletAccountModel.h" extern "C" { @@ -17,9 +20,15 @@ extern "C" { class LogosAPI; struct LogosModules; +class AmmClient; class LogosWalletProvider; +class NewPositionRuntime; +class QNetworkAccessManager; class WalletController; +// Source-side implementation of the AmmUiBackend .rep interface. +// Inheriting from AmmUiBackendSimpleSource gives us the generated PROPs and +// SLOTs from AmmUiBackend.rep — all the simple ones flow over QtRO. class AmmUiBackend : public AmmUiBackendSimpleSource { Q_OBJECT Q_PROPERTY(WalletAccountModel* accountModel READ accountModel CONSTANT) @@ -31,11 +40,17 @@ class AmmUiBackend : public AmmUiBackendSimpleSource { WalletAccountModel* accountModel() const; public slots: + // Overrides of the pure-virtual slots generated from the .rep. QString createAccountPublic() override; QString createAccountPrivate() override; void refreshAccounts() override; void refreshBalances() override; QString getBalance(QString accountIdHex, bool isPublic) override; + void refreshNewPositionContext(QVariantMap request) override; + QVariantMap quoteNewPosition(QVariantMap request) override; + QVariantMap submitNewPosition(QVariantMap request, QString quoteHash) override; + // Return the new wallet's BIP39 mnemonic (empty string on failure) so the + // UI can force a one-time seed-phrase backup step. QString createNewDefault(QString password) override; QString createNew(QString configPath, QString storagePath, QString password) override; bool openExisting() override; @@ -52,6 +67,8 @@ public slots: private: void syncWalletState(); + void probeNetworkIdentity(); + void publishNetworkContext(); // Normalizes an account id given as either 64-char lowercase/uppercase hex // or base58 to lowercase hex. Returns an empty QString if `id` is neither @@ -72,6 +89,14 @@ public slots: std::unique_ptr m_logos; std::unique_ptr m_wallet; std::unique_ptr m_walletController; + std::unique_ptr m_ammClient; + std::unique_ptr m_newPosition; + + QNetworkAccessManager* m_net; + + ActiveNetwork m_network; + QVariantMap m_newPositionHints; + bool m_identityProbeInFlight = false; }; #endif // AMM_UI_BACKEND_H diff --git a/apps/amm/src/AmmUiBackend.rep b/apps/amm/src/AmmUiBackend.rep index f389b9dc..447ca286 100644 --- a/apps/amm/src/AmmUiBackend.rep +++ b/apps/amm/src/AmmUiBackend.rep @@ -5,6 +5,9 @@ class AmmUiBackend { PROP(bool isWalletOpen READONLY) + // False while startup or reconnect is still resolving wallet state. This + // stays distinct from isWalletOpen because a disconnected wallet is ready. + PROP(bool walletStateReady READONLY) PROP(bool walletExists READONLY) PROP(QString configPath READONLY) PROP(QString storagePath READONLY) @@ -23,8 +26,16 @@ class AmmUiBackend SLOT(void refreshBalances()) SLOT(QString getBalance(QString accountIdHex, bool isPublic)) + // New Position backend surface. QML calls these through logos.watch(...). + // The QVariant payloads are stable maps/lists so the UI never assembles AMM + // transactions or duplicates quote state. + PROP(QVariantMap newPositionContext READONLY) + SLOT(void refreshNewPositionContext(QVariantMap request)) + SLOT(QVariantMap quoteNewPosition(QVariantMap request)) + SLOT(QVariantMap submitNewPosition(QVariantMap request, QString quoteHash)) + // Wallet lifecycle. createNewDefault() is the happy path: it creates a - // fresh per-app wallet at walletHome with no path picking. createNew() + // fresh wallet at the canonical walletHome with no path picking. createNew() // keeps explicit paths for an "advanced" flow. Both return the new wallet's // BIP39 mnemonic (empty on failure) so the UI can force a seed-phrase backup // before the wallet is usable — this is the only chance to record it. diff --git a/apps/amm/src/NewPositionRuntime.cpp b/apps/amm/src/NewPositionRuntime.cpp new file mode 100644 index 00000000..3f016ef6 --- /dev/null +++ b/apps/amm/src/NewPositionRuntime.cpp @@ -0,0 +1,390 @@ +#include "NewPositionRuntime.h" + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "AmmClient.h" +#include "WalletProvider.h" + +namespace { + const char SCHEMA[] = "new-position.v1"; + constexpr qsizetype HASH_BYTES = 32; + constexpr std::size_t BASE58_BUFFER_SIZE = 45; + + QString base58TransactionId(const QString& transactionHash) + { + const QByteArray hex = transactionHash.toLatin1(); + const QByteArray bytes = QByteArray::fromHex(hex); + if (bytes.size() != HASH_BYTES || bytes.toHex() != hex) + return {}; + + std::array encoded {}; + std::size_t size = encoded.size(); + if (!b58enc(encoded.data(), &size, bytes.constData(), + static_cast(bytes.size()))) { + return {}; + } + return QString::fromLatin1( + encoded.data(), static_cast(size - 1)); + } + + QJsonObject issue(const QString& code, + const QJsonArray& blockingFields = {}) + { + return { + { QStringLiteral("code"), code }, + { QStringLiteral("recoverable"), true }, + { QStringLiteral("blockingFields"), blockingFields }, + { QStringLiteral("details"), QJsonObject() }, + }; + } + + QJsonObject publicError(const QString& code, + const QJsonArray& blockingFields = {}, + const QJsonObject& details = {}) + { + QJsonObject error = issue(code, blockingFields); + error.insert(QStringLiteral("details"), details); + return { + { QStringLiteral("schema"), QString::fromLatin1(SCHEMA) }, + { QStringLiteral("status"), QStringLiteral("error") }, + { QStringLiteral("canSubmit"), false }, + { QStringLiteral("code"), code }, + { QStringLiteral("errors"), QJsonArray { error } }, + { QStringLiteral("warnings"), QJsonArray() }, + { QStringLiteral("accountPreview"), QJsonArray() }, + }; + } + + QJsonObject contextState(const QString& status, + const ActiveNetworkSnapshot& network, + const QString& code = {}) + { + QJsonObject state { + { QStringLiteral("schema"), QString::fromLatin1(SCHEMA) }, + { QStringLiteral("status"), status }, + { QStringLiteral("networkId"), network.id }, + { QStringLiteral("networkFingerprint"), network.fingerprint }, + { QStringLiteral("tokens"), QJsonArray() }, + { QStringLiteral("feeTiers"), QJsonArray() }, + { QStringLiteral("warnings"), QJsonArray() }, + }; + if (!code.isEmpty()) + state.insert(QStringLiteral("code"), code); + return state; + } + + QJsonArray variantStringArray(const QVariant& value) + { + QJsonArray result; + for (const QVariant& item : value.toList()) + result.append(item.toString()); + return result; + } + + QStringList jsonStringList(const QJsonArray& values) + { + QStringList result; + result.reserve(values.size()); + for (const QJsonValue& value : values) + result.append(value.toString()); + return result; + } + + QVector jsonBoolList(const QJsonArray& values) + { + QVector result; + result.reserve(values.size()); + for (const QJsonValue& value : values) + result.append(value.toBool()); + return result; + } + + QVector jsonUIntList(const QJsonArray& values) + { + QVector result; + result.reserve(values.size()); + for (const QJsonValue& value : values) + result.append(static_cast(value.toInteger())); + return result; + } + + QJsonObject accountReadJson(const WalletAccountRead& read) + { + QJsonObject result { + { QStringLiteral("id"), read.accountId }, + { QStringLiteral("status"), read.status }, + }; + if (read.ok()) { + result.insert(QStringLiteral("account"), QJsonObject { + { QStringLiteral("program_owner"), read.programOwner }, + { QStringLiteral("balance"), read.balanceHex }, + { QStringLiteral("nonce"), read.nonceHex }, + { QStringLiteral("data"), read.dataHex }, + }); + } + return result; + } + + QJsonArray accountReadsJson(const QVector& reads) + { + QJsonArray result; + for (const WalletAccountRead& read : reads) + result.append(accountReadJson(read)); + return result; + } +} + +NewPositionRuntime::NewPositionRuntime(WalletProvider* wallet, AmmClient* client) + : m_wallet(wallet), + m_client(client) +{ +} + +void NewPositionRuntime::clearWalletAccounts() +{ + m_wallet->clearSnapshot(); +} + +QJsonArray NewPositionRuntime::walletAccountReads(bool walletOpen, bool refresh) const +{ + if (!walletOpen) + return {}; + return accountReadsJson(m_wallet->snapshot(refresh).publicAccountReads); +} + +QJsonObject NewPositionRuntime::buildQuoteInput(const QVariantMap& request, + const ActiveNetworkSnapshot& network, + bool walletOpen, + bool freshWalletAccounts, + QJsonObject* error) const +{ + if (network.status != QStringLiteral("ready")) { + *error = publicError(network.status); + return {}; + } + const AmmClientResult configResult = m_client->configId( + QJsonObject { { QStringLiteral("ammProgramId"), network.ammProgramId } }); + if (!configResult.ok) { + *error = publicError(QStringLiteral("backend_error")); + return {}; + } + const QJsonObject configManifest = configResult.value; + const QJsonObject config = accountReadJson(m_wallet->readPublicAccount( + configManifest.value(QStringLiteral("configId")).toString())); + const QJsonObject requestObject = QJsonObject::fromVariantMap(request); + const AmmClientResult pairResult = m_client->pairIds( + QJsonObject { + { QStringLiteral("ammProgramId"), network.ammProgramId }, + { QStringLiteral("config"), config }, + { QStringLiteral("tokenAId"), requestObject.value(QStringLiteral("tokenAId")) }, + { QStringLiteral("tokenBId"), requestObject.value(QStringLiteral("tokenBId")) }, + }); + if (!pairResult.ok) { + *error = publicError(QStringLiteral("backend_error")); + return {}; + } + const QJsonObject pairManifest = pairResult.value; + if (pairManifest.value(QStringLiteral("status")).toString() != QStringLiteral("ok")) { + *error = publicError(pairManifest.value(QStringLiteral("code")).toString()); + return {}; + } + + const QJsonArray walletAccounts = walletAccountReads(walletOpen, freshWalletAccounts); + const QJsonObject snapshot { + { QStringLiteral("config"), config }, + { QStringLiteral("tokenA"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("tokenAId")).toString())) }, + { QStringLiteral("tokenB"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("tokenBId")).toString())) }, + { QStringLiteral("pool"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("poolId")).toString())) }, + { QStringLiteral("vaultA"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("vaultAId")).toString())) }, + { QStringLiteral("vaultB"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("vaultBId")).toString())) }, + { QStringLiteral("lpDefinition"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("lpDefinitionId")).toString())) }, + { QStringLiteral("lpLockHolding"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("lpLockHoldingId")).toString())) }, + { QStringLiteral("currentTick"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("currentTickId")).toString())) }, + { QStringLiteral("clock"), accountReadJson(m_wallet->readPublicAccount(pairManifest.value(QStringLiteral("clockId")).toString())) }, + { QStringLiteral("walletAvailable"), walletOpen }, + { QStringLiteral("walletAccounts"), walletAccounts }, + }; + return { + { QStringLiteral("networkId"), network.id }, + { QStringLiteral("networkFingerprint"), network.fingerprint }, + { QStringLiteral("ammProgramId"), network.ammProgramId }, + { QStringLiteral("request"), requestObject }, + { QStringLiteral("snapshot"), snapshot }, + }; +} + +QVariantMap NewPositionRuntime::context(const QVariantMap& request, + const ActiveNetworkSnapshot& network, + bool walletOpen, + bool refreshWalletAccounts) +{ + if (network.status != QStringLiteral("ready")) + return contextState(network.status, network).toVariantMap(); + + const QJsonArray walletAccounts = walletAccountReads(walletOpen, refreshWalletAccounts); + + const QJsonObject hints = QJsonObject::fromVariantMap(request); + const AmmClientResult configResult = m_client->configId( + QJsonObject { { QStringLiteral("ammProgramId"), network.ammProgramId } }); + if (!configResult.ok) + return contextState( + QStringLiteral("error"), network, QStringLiteral("backend_error")).toVariantMap(); + + const QJsonObject configManifest = configResult.value; + const QJsonObject config = accountReadJson(m_wallet->readPublicAccount( + configManifest.value(QStringLiteral("configId")).toString())); + QJsonArray configured; + for (const QString& id : network.tokenIds) + configured.append(id); + const QJsonArray recent = variantStringArray(hints.value(QStringLiteral("recentTokenIds")).toVariant()); + const QJsonArray resolved = variantStringArray(hints.value(QStringLiteral("resolvedTokenIds")).toVariant()); + + const AmmClientResult tokenResult = m_client->tokenIds( + QJsonObject { + { QStringLiteral("ammProgramId"), network.ammProgramId }, + { QStringLiteral("config"), config }, + { QStringLiteral("walletAccounts"), walletAccounts }, + { QStringLiteral("configuredTokenIds"), configured }, + { QStringLiteral("recentTokenIds"), recent }, + { QStringLiteral("resolvedTokenIds"), resolved }, + }); + const QJsonObject tokenManifest = tokenResult.value; + if (!tokenResult.ok + || tokenManifest.value(QStringLiteral("status")).toString() != QStringLiteral("ok")) { + const QString code = tokenResult.ok + ? tokenManifest.value(QStringLiteral("code")).toString() + : QStringLiteral("backend_error"); + return contextState( + QStringLiteral("error"), + network, + code.isEmpty() ? QStringLiteral("backend_error") : code).toVariantMap(); + } + + QJsonArray definitions; + for (const QJsonValue& id : tokenManifest.value(QStringLiteral("tokenIds")).toArray()) + definitions.append(accountReadJson(m_wallet->readPublicAccount(id.toString()))); + + const AmmClientResult contextResult = m_client->context( + QJsonObject { + { QStringLiteral("networkId"), network.id }, + { QStringLiteral("networkFingerprint"), network.fingerprint }, + { QStringLiteral("ammProgramId"), network.ammProgramId }, + { QStringLiteral("walletAvailable"), walletOpen }, + { QStringLiteral("config"), config }, + { QStringLiteral("walletAccounts"), walletAccounts }, + { QStringLiteral("tokenDefinitions"), definitions }, + { QStringLiteral("configuredTokenIds"), configured }, + { QStringLiteral("recentTokenIds"), recent }, + { QStringLiteral("resolvedTokenIds"), resolved }, + }); + return (contextResult.ok + ? contextResult.value + : contextState( + QStringLiteral("error"), network, QStringLiteral("backend_error"))).toVariantMap(); +} + +QVariantMap NewPositionRuntime::quote(const QVariantMap& request, + const ActiveNetworkSnapshot& network, + bool walletOpen) +{ + QJsonObject error; + const QJsonObject input = buildQuoteInput(request, network, walletOpen, false, &error); + if (!error.isEmpty()) + return error.toVariantMap(); + + const AmmClientResult result = m_client->quote(input); + return (result.ok ? result.value : publicError(QStringLiteral("backend_error"))).toVariantMap(); +} + +QVariantMap NewPositionRuntime::submit(const QVariantMap& request, + const QString& quoteHash, + const ActiveNetworkSnapshot& network, + bool walletOpen) +{ + if (m_submitInFlight) + return publicError(QStringLiteral("submit_in_progress")).toVariantMap(); + if (!walletOpen) + return publicError(QStringLiteral("wallet_unavailable")).toVariantMap(); + QScopedValueRollback submitGuard(m_submitInFlight, true); + + QJsonObject error; + const QJsonObject input = buildQuoteInput(request, network, walletOpen, true, &error); + if (!error.isEmpty()) + return error.toVariantMap(); + + const AmmClientResult quoteResult = m_client->quote(input); + if (!quoteResult.ok) + return publicError(QStringLiteral("backend_error")).toVariantMap(); + const QJsonObject quote = quoteResult.value; + if (quote.value(QStringLiteral("quoteHash")).toString() != quoteHash) { + QJsonObject result = publicError(QStringLiteral("quote_changed")); + result.insert(QStringLiteral("quote"), quote); + return result.toVariantMap(); + } + if (!quote.value(QStringLiteral("canSubmit")).toBool(false)) { + QJsonObject result = publicError(QStringLiteral("quote_not_submittable")); + result.insert(QStringLiteral("quote"), quote); + return result.toVariantMap(); + } + + QJsonValue freshLp; + if (quote.value(QStringLiteral("requiresFreshLp")).toBool(false)) { + const WalletAccountCreation creation = m_wallet->createAccount(true); + if (!creation.ok() || !creation.publicAccount.ok()) + return publicError(QStringLiteral("wallet_submission_failed")).toVariantMap(); + freshLp = accountReadJson(creation.publicAccount); + } + + QJsonObject planInput = input; + planInput.insert(QStringLiteral("quoteHash"), quoteHash); + planInput.insert(QStringLiteral("nowMs"), QDateTime::currentMSecsSinceEpoch()); + if (!freshLp.isUndefined()) + planInput.insert(QStringLiteral("freshLp"), freshLp); + + const AmmClientResult planResult = m_client->plan(planInput); + if (!planResult.ok) + return publicError(QStringLiteral("backend_error")).toVariantMap(); + const QJsonObject plan = planResult.value; + if (plan.value(QStringLiteral("status")).toString() != QStringLiteral("ready")) { + const QString code = plan.value(QStringLiteral("code")).toString(); + return publicError(code.isEmpty() ? QStringLiteral("wallet_submission_failed") : code) + .toVariantMap(); + } + + const QStringList accountIds = jsonStringList(plan.value(QStringLiteral("accountIds")).toArray()); + const QVector signingRequirements = jsonBoolList(plan.value(QStringLiteral("signingRequirements")).toArray()); + const QVector instruction = jsonUIntList(plan.value(QStringLiteral("instruction")).toArray()); + const QString programId = plan.value(QStringLiteral("programId")).toString(); + bool deadlineValid = false; + const qulonglong deadline = plan.value(QStringLiteral("deadlineMs")).toString().toULongLong(&deadlineValid); + if (!deadlineValid + || static_cast(QDateTime::currentMSecsSinceEpoch()) >= deadline) { + return publicError(QStringLiteral("transaction_deadline_expired")).toVariantMap(); + } + const WalletSubmission submission = m_wallet->submitPublicTransaction({ + programId, + accountIds, + signingRequirements, + instruction, + }); + if (!submission.accepted()) + return publicError(QStringLiteral("wallet_submission_failed")).toVariantMap(); + const QString transactionId = base58TransactionId(submission.nativeHash); + if (transactionId.isEmpty()) + return publicError(QStringLiteral("wallet_submission_failed")).toVariantMap(); + + return QJsonObject { + { QStringLiteral("schema"), QString::fromLatin1(SCHEMA) }, + { QStringLiteral("status"), QStringLiteral("submitted") }, + { QStringLiteral("transactionId"), transactionId }, + { QStringLiteral("deadlineMs"), plan.value(QStringLiteral("deadlineMs")) }, + }.toVariantMap(); +} diff --git a/apps/amm/src/NewPositionRuntime.h b/apps/amm/src/NewPositionRuntime.h new file mode 100644 index 00000000..99e190e9 --- /dev/null +++ b/apps/amm/src/NewPositionRuntime.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include +#include +#include + +#include "ActiveNetwork.h" + +class AmmClient; +class WalletProvider; + +class NewPositionRuntime { +public: + NewPositionRuntime(WalletProvider* wallet, AmmClient* client); + + void clearWalletAccounts(); + + QVariantMap context(const QVariantMap& request, + const ActiveNetworkSnapshot& network, + bool walletOpen, + bool refreshWalletAccounts); + QVariantMap quote(const QVariantMap& request, + const ActiveNetworkSnapshot& network, + bool walletOpen); + QVariantMap submit(const QVariantMap& request, + const QString& quoteHash, + const ActiveNetworkSnapshot& network, + bool walletOpen); + +private: + QJsonArray walletAccountReads(bool walletOpen, bool refresh) const; + QJsonObject buildQuoteInput(const QVariantMap& request, + const ActiveNetworkSnapshot& network, + bool walletOpen, + bool freshWalletAccounts, + QJsonObject* error) const; + + WalletProvider* m_wallet; + AmmClient* m_client; + bool m_submitInFlight = false; +}; diff --git a/apps/amm/tests/cpp/ActiveNetworkTest.cpp b/apps/amm/tests/cpp/ActiveNetworkTest.cpp new file mode 100644 index 00000000..239f3e8d --- /dev/null +++ b/apps/amm/tests/cpp/ActiveNetworkTest.cpp @@ -0,0 +1,65 @@ +#include "ActiveNetwork.h" + +#include +#include +#include +#include + +namespace { + bool expect(bool condition, const char* message) + { + if (!condition) + qCritical("%s", message); + return condition; + } +} + +int main() +{ + const QString identity(64, QLatin1Char('a')); + const QString programId(64, QLatin1Char('b')); + const QString tokenId(64, QLatin1Char('c')); + + QTemporaryFile config; + if (!config.open()) + return 1; + config.write(QJsonDocument(QJsonObject { + { QStringLiteral("channelId"), identity }, + { QStringLiteral("ammProgramId"), programId }, + { QStringLiteral("tokenDefinitionIds"), QJsonArray { tokenId } }, + }).toJson(QJsonDocument::Compact)); + config.flush(); + + qputenv("AMM_UI_NETWORK", "devnet"); + qputenv("AMM_UI_DEVNET_FILE", config.fileName().toLocal8Bit()); + + ActiveNetwork network; + if (!expect(network.load(), "devnet config should load")) + return 1; + if (!expect(network.snapshot().status == QStringLiteral("network_unknown"), + "loaded network should await identity")) + return 1; + + network.sequencerChanged(true); + network.finishIdentityProbe(QString(64, QLatin1Char('d'))); + if (!expect(network.status() == QStringLiteral("network_mismatch"), + "wrong identity should block the network")) + return 1; + + network.reachabilityChanged(false, true); + network.reachabilityChanged(true, false); + network.finishIdentityProbe(identity); + const ActiveNetworkSnapshot snapshot = network.snapshot(); + if (!expect(snapshot.status == QStringLiteral("ready"), + "matching identity should ready the network")) + return 1; + if (!expect(snapshot.fingerprint == QStringLiteral("channel:") + identity, + "devnet fingerprint should use channel identity")) + return 1; + if (!expect(snapshot.ammProgramId == programId + && snapshot.tokenIds == QStringList { tokenId }, + "network snapshot should preserve configured program and tokens")) + return 1; + + return 0; +} diff --git a/apps/amm/tests/cpp/NewPositionRuntimeTest.cpp b/apps/amm/tests/cpp/NewPositionRuntimeTest.cpp new file mode 100644 index 00000000..38f174f9 --- /dev/null +++ b/apps/amm/tests/cpp/NewPositionRuntimeTest.cpp @@ -0,0 +1,230 @@ +#include "AmmClient.h" +#include "NewPositionRuntime.h" +#include "WalletProvider.h" + +#include + +namespace { + bool expect(bool condition, const char* message) + { + if (!condition) + qCritical("%s", message); + return condition; + } + + class FakeWallet final : public WalletProvider { + public: + WalletSession connect(const WalletPaths&) override + { + return {}; + } + + WalletCreation createWallet(const WalletPaths&, const QString&) override + { + return {}; + } + + WalletSnapshot snapshot(bool) override + { + return {}; + } + + void clearSnapshot() override {} + + WalletAccountCreation createAccount(bool isPublic) override + { + ++createdAccounts; + WalletAccountCreation creation; + creation.accountId = QStringLiteral("fresh-lp"); + if (isPublic) + creation.publicAccount = readPublicAccount(creation.accountId); + return creation; + } + + WalletAccountRead readPublicAccount(const QString& accountId) const override + { + WalletAccountRead read; + read.accountId = accountId; + read.status = QStringLiteral("ok"); + return read; + } + + WalletSubmission submitPublicTransaction(const WalletTransaction& transaction) override + { + ++submissions; + submitted = transaction; + return { WalletFailure::None, transactionHash }; + } + + void disconnect() override {} + + QString transactionHash = QStringLiteral( + "000102030405060708090a0b0c0d0e0f" + "101112131415161718191a1b1c1d1e1f"); + int createdAccounts = 0; + int submissions = 0; + WalletTransaction submitted; + }; + + class FakeAmmClient final : public AmmClient { + public: + AmmClientResult configId(const QJsonObject&) const override + { + return success({ { QStringLiteral("configId"), QStringLiteral("config") } }); + } + + AmmClientResult tokenIds(const QJsonObject&) const override + { + return success({ { QStringLiteral("status"), QStringLiteral("ok") } }); + } + + AmmClientResult pairIds(const QJsonObject&) const override + { + return success({ + { QStringLiteral("status"), QStringLiteral("ok") }, + { QStringLiteral("tokenAId"), QStringLiteral("token-a") }, + { QStringLiteral("tokenBId"), QStringLiteral("token-b") }, + { QStringLiteral("poolId"), QStringLiteral("pool") }, + { QStringLiteral("vaultAId"), QStringLiteral("vault-a") }, + { QStringLiteral("vaultBId"), QStringLiteral("vault-b") }, + { QStringLiteral("lpDefinitionId"), QStringLiteral("lp") }, + { QStringLiteral("lpLockHoldingId"), QStringLiteral("lp-lock") }, + { QStringLiteral("currentTickId"), QStringLiteral("tick") }, + { QStringLiteral("clockId"), QStringLiteral("clock") }, + }); + } + + AmmClientResult context(const QJsonObject&) const override + { + return success({}); + } + + AmmClientResult quote(const QJsonObject&) const override + { + return success({ + { QStringLiteral("schema"), QStringLiteral("new-position.v1") }, + { QStringLiteral("status"), QStringLiteral("ok") }, + { QStringLiteral("canSubmit"), true }, + { QStringLiteral("quoteHash"), quoteHash }, + { QStringLiteral("requiresFreshLp"), requiresFreshLp }, + }); + } + + AmmClientResult plan(const QJsonObject& request) const override + { + sawFreshLp = request.contains(QStringLiteral("freshLp")); + return success({ + { QStringLiteral("status"), QStringLiteral("ready") }, + { QStringLiteral("accountIds"), QJsonArray { QStringLiteral("account") } }, + { QStringLiteral("signingRequirements"), QJsonArray { true } }, + { QStringLiteral("instruction"), QJsonArray { 1 } }, + { QStringLiteral("programId"), QStringLiteral("program") }, + { QStringLiteral("deadlineMs"), + QString::number(QDateTime::currentMSecsSinceEpoch() + 60'000) }, + }); + } + + static AmmClientResult success(const QJsonObject& value) + { + return { true, value }; + } + + QString quoteHash = QStringLiteral("sha256:expected"); + bool requiresFreshLp = true; + mutable bool sawFreshLp = false; + }; + + ActiveNetworkSnapshot readyNetwork() + { + return { + QStringLiteral("testnet"), + QStringLiteral("ready"), + QStringLiteral("block10:identity"), + QStringLiteral("program"), + {}, + }; + } +} + +int main() +{ + const QVariantMap request { + { QStringLiteral("schema"), QStringLiteral("new-position.v1") }, + { QStringLiteral("tokenAId"), QStringLiteral("token-a") }, + { QStringLiteral("tokenBId"), QStringLiteral("token-b") }, + { QStringLiteral("feeBps"), 30 }, + }; + + FakeWallet wallet; + FakeAmmClient client; + NewPositionRuntime runtime(&wallet, &client); + const QVariantMap result = runtime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + if (!expect(result.value(QStringLiteral("status")).toString() + == QStringLiteral("submitted"), + "valid plan should submit")) + return 1; + if (!expect(result.value(QStringLiteral("transactionId")).toString() + == QStringLiteral("1thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE"), + "native hash should become the expected base58 transaction ID")) + return 1; + if (!expect(wallet.createdAccounts == 1 && client.sawFreshLp, + "fresh LP account should enter the plan")) + return 1; + if (!expect(wallet.submissions == 1 + && wallet.submitted.accountIds + == QStringList { QStringLiteral("account") } + && wallet.submitted.signingRequirements.size() == 1 + && wallet.submitted.signingRequirements.constFirst() + && wallet.submitted.instruction.size() == 1 + && wallet.submitted.instruction.constFirst() == 1 + && wallet.submitted.programId == QStringLiteral("program"), + "runtime should dispatch the unchanged plan once")) + return 1; + + FakeWallet orderedBytesWallet; + orderedBytesWallet.transactionHash = QStringLiteral( + "0102030405060708090a0b0c0d0e0f10" + "1112131415161718191a1b1c1d1e1f20"); + FakeAmmClient orderedBytesClient; + orderedBytesClient.requiresFreshLp = false; + NewPositionRuntime orderedBytesRuntime(&orderedBytesWallet, &orderedBytesClient); + const QVariantMap orderedBytes = orderedBytesRuntime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + if (!expect(orderedBytes.value(QStringLiteral("transactionId")).toString() + == QStringLiteral("4wBqpZM9xaSheZzJSMawUKKwhdpChKbZ5eu5ky4Vigw"), + "ordered native hash bytes should preserve byte order")) + return 1; + + FakeWallet invalidHashWallet; + invalidHashWallet.transactionHash = QStringLiteral("not-a-hash"); + FakeAmmClient invalidHashClient; + invalidHashClient.requiresFreshLp = false; + NewPositionRuntime invalidHashRuntime(&invalidHashWallet, &invalidHashClient); + const QVariantMap invalidHash = invalidHashRuntime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + if (!expect(invalidHash.value(QStringLiteral("code")).toString() + == QStringLiteral("wallet_submission_failed") + && !invalidHash.contains(QStringLiteral("transactionId")), + "invalid native hash should fail without a hex fallback")) + return 1; + if (!expect(invalidHashWallet.submissions == 1, + "hash conversion should happen after wallet submission")) + return 1; + + FakeWallet staleWallet; + FakeAmmClient staleClient; + staleClient.quoteHash = QStringLiteral("sha256:changed"); + NewPositionRuntime staleRuntime(&staleWallet, &staleClient); + const QVariantMap stale = staleRuntime.submit( + request, QStringLiteral("sha256:expected"), readyNetwork(), true); + if (!expect(stale.value(QStringLiteral("code")).toString() + == QStringLiteral("quote_changed"), + "changed quote should stop submission")) + return 1; + if (!expect(staleWallet.createdAccounts == 0 && staleWallet.submissions == 0, + "stale quote should have no wallet side effects")) + return 1; + + return 0; +} diff --git a/apps/amm/tests/qml/tst_LiquidityConfirmationDialog.qml b/apps/amm/tests/qml/tst_LiquidityConfirmationDialog.qml new file mode 100644 index 00000000..11ca0268 --- /dev/null +++ b/apps/amm/tests/qml/tst_LiquidityConfirmationDialog.qml @@ -0,0 +1,29 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtTest + +import "../../qml/components/liquidity" as Liquidity + +TestCase { + id: testCase + + name: "LiquidityConfirmationSummary" + + Component { + id: summaryComponent + + Liquidity.LiquidityConfirmationSummary { + width: 480 + } + } + + function test_protocolActionsUseDisplayLabels() { + var summary = createTemporaryObject(summaryComponent, testCase) + verify(summary) + + compare(summary.actionText("NewDefinition"), "Create pool") + compare(summary.actionText("AddLiquidity"), "Add liquidity") + compare(summary.actionText(""), "-") + } +} diff --git a/apps/amm/tests/qml/tst_LiquidityPage.qml b/apps/amm/tests/qml/tst_LiquidityPage.qml new file mode 100644 index 00000000..afc9b4a3 --- /dev/null +++ b/apps/amm/tests/qml/tst_LiquidityPage.qml @@ -0,0 +1,335 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtTest + +import "../../qml/pages" as Pages + +TestCase { + id: testCase + + name: "LiquidityPage" + readonly property string submittedTransactionId: + "1thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE" + + Component { + id: backendComponent + + QtObject { + property bool walletStateReady: false + property var submitResult: ({}) + property var quoteResult: ({ + "schema": "new-position.v1", + "status": "ok", + "poolStatus": "missing_pool" + }) + property var newPositionContext: ({ + "schema": "new-position.v1", + "status": "ready", + "tokens": [], + "feeTiers": [] + }) + + function submitNewPosition(request, quoteHash) { + return submitResult + } + + function quoteNewPosition(request) { + return quoteResult + } + } + } + + function test_positionLayoutUsesDesktopRailAndCompactProgress() { + var page = createTemporaryObject(pageComponent, testCase) + verify(page) + page.visible = true + wait(0) + + var rail = findChild(page, "positionStepRail") + var compactSteps = findChild(page, "compactPositionSteps") + var form = findChild(page, "newPositionForm") + verify(rail) + verify(compactSteps) + verify(form) + + compare(page.wideLayout, true) + verify(rail.width > 0) + verify(form.width > rail.width) + + page.width = 600 + wait(0) + + compare(page.wideLayout, false) + verify(compactSteps.implicitHeight > 0) + verify(form.width > 0) + verify(form.width <= page.width - 32) + } + + Component { + id: runtimeComponent + + QtObject { + function watch(value, succeeded, failed) { + succeeded(value) + } + } + } + + Component { + id: pageComponent + + Pages.LiquidityPage { + visible: false + width: 800 + height: 600 + } + } + + function test_contextWaitsForWalletState() { + var backend = createTemporaryObject(backendComponent, testCase) + var page = createTemporaryObject(pageComponent, testCase, { + "backend": backend + }) + verify(backend) + verify(page) + + compare(page.flow.newPositionContext.status, "loading") + + backend.walletStateReady = true + tryCompare(page.flow.newPositionContext, "status", "ready") + } + + function test_contextRefreshControlsWalletScan() { + var backend = createTemporaryObject(backendComponent, testCase, { + "walletStateReady": true + }) + var page = createTemporaryObject(pageComponent, testCase, { + "backend": backend + }) + verify(backend) + verify(page) + + compare(page.flow.contextHints(false).refreshWalletAccounts, false) + compare(page.flow.contextHints(true).refreshWalletAccounts, true) + } + + function test_staleContextCompletionCannotFinishNewerRefresh() { + var backend = createTemporaryObject(backendComponent, testCase, { + "walletStateReady": true + }) + var page = createTemporaryObject(pageComponent, testCase, { + "backend": backend + }) + verify(backend) + verify(page) + + page.flow.contextSerial = 2 + page.flow.contextLoading = true + page.flow.contextErrorCode = "newer_request_pending" + + page.flow.finishContextRefresh(1, null) + page.flow.failContextRefresh(1) + + compare(page.flow.contextLoading, true) + compare(page.flow.contextErrorCode, "newer_request_pending") + + page.flow.finishContextRefresh(2, null) + compare(page.flow.contextLoading, false) + compare(page.flow.contextErrorCode, "") + } + + function test_submitFailureKeepsReturnedFreshQuoteWithoutRequery() { + var backend = createTemporaryObject(backendComponent, testCase, { + "walletStateReady": true + }) + var page = createTemporaryObject(pageComponent, testCase, { + "backend": backend + }) + verify(backend) + verify(page) + + page.flow.quoteSerial = 7 + page.flow.finishSubmitFailure({ + "schema": "new-position.v1", + "status": "error", + "code": "quote_not_submittable", + "quote": { + "schema": "new-position.v1", + "status": "ok", + "canSubmit": false, + "quoteHash": "sha256:fresh" + } + }) + + compare(page.flow.quoteSerial, 7) + compare(page.flow.newPositionQuote.quoteHash, "sha256:fresh") + compare(page.flow.quoteStale, false) + } + + function test_base58SubmittedResultEntersSuccessState() { + var backend = createTemporaryObject(backendComponent, testCase, { + "walletStateReady": true, + "submitResult": { + "schema": "new-position.v1", + "status": "submitted", + "transactionId": submittedTransactionId, + "deadlineMs": String(Date.now() + 60000) + } + }) + var runtime = createTemporaryObject(runtimeComponent, testCase) + var page = createTemporaryObject(pageComponent, testCase, { + "backend": backend, + "runtime": runtime + }) + verify(backend) + verify(runtime) + verify(page) + + page.flow.confirm({ + "request": ({}), + "quoteHash": "sha256:expected" + }) + + compare(page.flow.transactionId, submittedTransactionId) + compare(page.flow.flowErrorCode, "") + compare(page.flow.submitting, false) + } + + function test_base58MissingPoolSubmissionStartsPoolWatch() { + var backend = createTemporaryObject(backendComponent, testCase, { + "walletStateReady": true, + "submitResult": { + "schema": "new-position.v1", + "status": "submitted", + "transactionId": submittedTransactionId, + "deadlineMs": String(Date.now() + 60000) + } + }) + var runtime = createTemporaryObject(runtimeComponent, testCase) + var page = createTemporaryObject(pageComponent, testCase, { + "backend": backend, + "runtime": runtime + }) + verify(backend) + verify(runtime) + verify(page) + + var probe = { + "tokenAId": "22222222222222222222222222222222", + "tokenBId": "33333333333333333333333333333333" + } + page.flow.pendingQuoteRequest = { "ok": true, "request": probe } + page.flow.confirm({ + "request": { + "initialPriceRealRaw": "18446744073709551616" + }, + "poolProbeRequest": probe, + "quoteHash": "sha256:expected" + }) + wait(0) + + compare(page.flow.transactionId, submittedTransactionId) + compare(page.flow.pendingPoolProbes.length, 1) + compare(page.flow.selectedPoolCreationPending(), true) + compare(page.flow.poolProbeInFlight, false) + } + + function test_nativeHexSubmittedResultDoesNotEnterSuccessState() { + var backend = createTemporaryObject(backendComponent, testCase, { + "walletStateReady": true, + "submitResult": { + "schema": "new-position.v1", + "status": "submitted", + "transactionId": "000102030405060708090a0b0c0d0e0f" + + "101112131415161718191a1b1c1d1e1f" + } + }) + var runtime = createTemporaryObject(runtimeComponent, testCase) + var page = createTemporaryObject(pageComponent, testCase, { + "backend": backend, + "runtime": runtime + }) + verify(backend) + verify(runtime) + verify(page) + + page.flow.confirm({ + "request": ({}), + "quoteHash": "sha256:expected" + }) + + compare(page.flow.transactionId, "") + compare(page.flow.flowErrorCode, "wallet_submission_failed") + compare(page.flow.submitting, false) + } + + function test_poolProbeDoesNotPublishProbeAmountsAsCurrentQuote() { + var backend = createTemporaryObject(backendComponent, testCase, { + "walletStateReady": true + }) + var page = createTemporaryObject(pageComponent, testCase, { + "backend": backend + }) + verify(backend) + verify(page) + + var request = { + "tokenAId": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + "tokenBId": "22222222222222222222222222222222" + } + var pending = { "key": page.flow.pairKey(request), "request": request } + page.flow.pendingQuoteRequest = { "ok": true, "request": request } + page.flow.pendingPoolProbes = [pending] + page.flow.newPositionQuote = { + "schema": "new-position.v1", + "status": "ok", + "poolStatus": "missing_pool", + "tokenAId": request.tokenAId, + "tokenBId": request.tokenBId + } + page.flow.quoteStale = false + + page.flow.finishPoolProbe(pending, { + "schema": "new-position.v1", + "status": "ok", + "poolStatus": "active_pool", + "tokenAId": request.tokenAId, + "tokenBId": request.tokenBId + }) + + compare(page.flow.pendingPoolProbes.length, 0) + compare(page.flow.newPositionQuote.poolStatus, "missing_pool") + verify(page.flow.quoteStale) + } + + function test_poolProbeStopsBlockingAfterTransactionDeadline() { + var backend = createTemporaryObject(backendComponent, testCase, { + "walletStateReady": true + }) + var page = createTemporaryObject(pageComponent, testCase, { + "backend": backend + }) + verify(backend) + verify(page) + + var request = { + "tokenAId": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + "tokenBId": "22222222222222222222222222222222" + } + var pending = { + "key": page.flow.pairKey(request), + "request": request, + "deadlineMs": Date.now() - 1 + } + page.flow.pendingQuoteRequest = { "ok": true, "request": request } + page.flow.pendingPoolProbes = [pending] + page.flow.poolProbeInFlight = true + + page.flow.finishPoolProbe(pending, null) + + compare(page.flow.pendingPoolProbes.length, 0) + compare(page.flow.poolProbeInFlight, false) + verify(!page.flow.selectedPoolCreationPending()) + } +} diff --git a/apps/amm/tests/qml/tst_NewPositionForm.qml b/apps/amm/tests/qml/tst_NewPositionForm.qml new file mode 100644 index 00000000..083be1fa --- /dev/null +++ b/apps/amm/tests/qml/tst_NewPositionForm.qml @@ -0,0 +1,633 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtTest + +import "../../qml/components/liquidity" as Liquidity + +TestCase { + id: testCase + + name: "NewPositionForm" + + readonly property string tokenLow: "22222222222222222222222222222222" + readonly property string tokenHigh: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" + readonly property string tokenThird: "33333333333333333333333333333333" + readonly property string submittedTransactionId: + "1thX6LZfHDZZKUs92febYZhYRcXddmzfzF2NvTkPNE" + + Component { + id: formComponent + + Liquidity.NewPositionForm { + visible: false + width: 760 + newPositionContext: testCase.readyContext() + } + } + + Component { + id: clipboardSinkComponent + + TextEdit {} + } + + SignalSpy { + id: quoteRequestedSpy + signalName: "quoteRequested" + } + + function readyContext() { + return { + "status": "ready", + "tokens": [ + { + "definitionId": tokenLow, + "name": "Low", + "totalSupplyRaw": "1000000", + "balanceRaw": "1000", + "selectable": true + }, + { + "definitionId": tokenHigh, + "name": "High", + "totalSupplyRaw": "1000000000000", + "balanceRaw": "5000000000", + "selectable": true + } + ] + } + } + + function rawAmountsContext() { + return { + "status": "ready", + "tokens": [ + { + "definitionId": tokenLow, + "name": "Sir Mints-a-Lot", + "totalSupplyRaw": "1000000000000", + "balanceRaw": "1000000000", + "selectable": true + }, + { + "definitionId": tokenHigh, + "name": "Aurora", + "totalSupplyRaw": "1000000000000", + "balanceRaw": "1000000000", + "selectable": true + } + ] + } + } + + function flowState(quote) { + return { + "quote": quote || ({}), + "contextLoading": false, + "quoteLoading": false, + "quoteStale": false, + "submitting": false + } + } + + function createForm() { + var form = createTemporaryObject(formComponent, testCase, { + "flowState": flowState(({})) + }) + verify(form) + wait(0) + compare(form.selectedTokenAId, tokenLow) + compare(form.selectedTokenBId, tokenHigh) + return form + } + + function test_displayOrderMapsToCanonicalRequestAfterSwap() { + var form = createForm() + var built = form.buildQuoteRequest() + verify(built.ok) + compare(built.request.tokenAId, tokenHigh) + compare(built.request.tokenBId, tokenLow) + + form.swapTokens() + compare(form.selectedTokenAId, tokenHigh) + compare(form.selectedTokenBId, tokenLow) + built = form.buildQuoteRequest() + verify(built.ok) + compare(built.request.tokenAId, tokenHigh) + compare(built.request.tokenBId, tokenLow) + } + + function test_tokenAmountsUseRawUnits() { + var form = createForm() + compare(form.decimalsA, 0) + compare(form.decimalsB, 0) + compare(form.balanceText(form.tokenA, form.decimalsA), "1000") + compare(form.balanceText(form.tokenB, form.decimalsB), "5000000000") + } + + function test_missingPoolKeepsMinimumRatioWhileEditingDeposit() { + var quote = { + "status": "ok", + "tokenAId": tokenHigh, + "tokenBId": tokenLow, + "poolStatus": "missing_pool", + "minimumAmountARaw": "2", + "minimumAmountBRaw": "3", + "actualAmountARaw": "2", + "actualAmountBRaw": "3" + } + var form = createForm() + form.priceAmountA = "3" + form.priceAmountB = "2" + form.flowState = flowState(quote) + wait(0) + compare(form.amountA, "3") + compare(form.amountB, "2") + + form.editMissingAmount("A", "6") + compare(form.amountA, "6") + compare(form.amountB, "2") + + form.finishMissingAmount("A", "6") + compare(form.amountB, "4") + + var built = form.buildQuoteRequest() + verify(built.ok) + compare(built.request.amountARaw, "4") + compare(built.request.amountBRaw, "6") + } + + function test_missingPoolAcceptsLargeDirectAmountsFromEitherSide() { + var form = createTemporaryObject(formComponent, testCase, { + "flowState": flowState(({})), + "newPositionContext": rawAmountsContext() + }) + verify(form) + wait(0) + form.priceAmountA = "15" + form.priceAmountB = "10" + form.flowState = flowState({ + "status": "ok", + "tokenAId": tokenHigh, + "tokenBId": tokenLow, + "poolStatus": "missing_pool", + "minimumAmountARaw": "26", + "minimumAmountBRaw": "39", + "actualAmountARaw": "26", + "actualAmountBRaw": "39" + }) + wait(0) + + form.finishMissingAmount("A", "150") + compare(form.amountA, "150") + compare(form.amountB, "100") + var built = form.buildQuoteRequest() + verify(built.ok) + compare(built.request.amountARaw, "100") + compare(built.request.amountBRaw, "150") + compare(built.request.initialPriceRealRaw, "27670116110564327424") + verify(!built.request.hasOwnProperty("depositScaleBps")) + + form.finishMissingAmount("B", "200") + compare(form.amountA, "300") + compare(form.amountB, "200") + built = form.buildQuoteRequest() + verify(built.ok) + compare(built.request.amountARaw, "200") + compare(built.request.amountBRaw, "300") + } + + function test_missingPoolRoundsPairedRawAmounts() { + var form = createTemporaryObject(formComponent, testCase, { + "flowState": flowState(({})), + "newPositionContext": rawAmountsContext() + }) + verify(form) + wait(0) + form.priceAmountA = "15" + form.priceAmountB = "10" + form.flowState = flowState({ + "status": "ok", + "tokenAId": tokenHigh, + "tokenBId": tokenLow, + "poolStatus": "missing_pool", + "minimumAmountARaw": "1", + "minimumAmountBRaw": "1", + "actualAmountARaw": "1", + "actualAmountBRaw": "1" + }) + wait(0) + + var cases = [ + { "input": "1", "paired": "1", "rawA": "1", "rawB": "1" }, + { "input": "2", "paired": "1", "rawA": "1", "rawB": "2" }, + { "input": "3", "paired": "2", "rawA": "2", "rawB": "3" }, + { "input": "4", "paired": "3", "rawA": "3", "rawB": "4" } + ] + for (var i = 0; i < cases.length; ++i) { + form.finishMissingAmount("A", cases[i].input) + compare(form.amountB, cases[i].paired) + var built = form.buildQuoteRequest() + verify(built.ok) + compare(built.request.amountARaw, cases[i].rawA) + compare(built.request.amountBRaw, cases[i].rawB) + } + + form.finishMissingAmount("A", "1.1234567") + compare(form.amountA, "1.1234567") + verify(!form.buildQuoteRequest().ok) + compare(form.fieldError("amountA"), form.issueText("invalid_amount_precision")) + } + + function test_missingPoolUsesTradeStyleInputsWithInlinePrice() { + var form = createForm() + form.flowState = flowState({ + "status": "ok", + "tokenAId": tokenHigh, + "tokenBId": tokenLow, + "poolStatus": "missing_pool", + "minimumAmountARaw": "2000000", + "minimumAmountBRaw": "3", + "actualAmountARaw": "2000000", + "actualAmountBRaw": "3" + }) + wait(0) + + var amountAInput = findChild(form, "tokenAAmountInput") + var amountBInput = findChild(form, "tokenBAmountInput") + verify(amountAInput) + verify(amountBInput) + compare(amountAInput.selectedTokenId, tokenLow) + compare(amountBInput.selectedTokenId, tokenHigh) + verify(amountAInput.height <= 114) + verify(amountBInput.height <= 114) + verify(findChild(form, "priceAmountAField")) + verify(findChild(form, "priceAmountBField")) + + form.width = 328 + wait(0) + compare(amountAInput.adjustmentWidth, 100) + compare(amountBInput.adjustmentWidth, 100) + } + + function test_errorMessageStaysAbovePairAndMarksCausingControl() { + var form = createForm() + form.flowState = flowState({ + "status": "ok", + "tokenAId": tokenHigh, + "tokenBId": tokenLow, + "poolStatus": "missing_pool", + "minimumAmountARaw": "2000000", + "minimumAmountBRaw": "3", + "actualAmountARaw": "2000000", + "actualAmountBRaw": "3" + }) + wait(0) + var amountAInput = findChild(form, "tokenAAmountInput") + var amountBInput = findChild(form, "tokenBAmountInput") + + form.localErrors = [form.localIssue("amount_exceeds_balance", ["amountB"])] + wait(0) + + compare(amountAInput.errorText, form.issueText("amount_exceeds_balance")) + compare(amountAInput.invalid, false) + compare(amountBInput.invalid, true) + + form.resolveToken("B", "invalid") + wait(0) + compare(amountAInput.errorText, form.issueText("invalid_token_id")) + compare(amountAInput.tokenInvalid, false) + compare(amountBInput.tokenInvalid, true) + } + + function test_staleMissingPoolQuoteDoesNotReplaceDraft() { + var quote = { + "status": "ok", + "tokenAId": tokenHigh, + "tokenBId": tokenLow, + "poolStatus": "missing_pool", + "minimumAmountARaw": "2000000", + "minimumAmountBRaw": "3", + "actualAmountARaw": "2000000", + "actualAmountBRaw": "3" + } + var form = createForm() + form.flowState = flowState(quote) + wait(0) + compare(form.amountA, "3") + + form.editMissingAmount("A", "2") + form.flowState = { + "quote": { + "status": "ok", + "tokenAId": tokenHigh, + "tokenBId": tokenLow, + "poolStatus": "missing_pool", + "minimumAmountARaw": "2000000", + "minimumAmountBRaw": "3", + "actualAmountARaw": "2000000", + "actualAmountBRaw": "3" + }, + "contextLoading": false, + "quoteLoading": false, + "quoteStale": true, + "submitting": false + } + wait(0) + + compare(form.amountA, "2") + } + + function test_poolActivationClearsCreationDraftWithoutPublishingProbeAmounts() { + var form = createForm() + form.confirmedPoolStatus = "missing_pool" + form.amountA = "3" + form.amountB = "2" + form.minimumAmountARaw = "3" + form.minimumAmountBRaw = "2000000" + + verify(form.acceptPoolActivation({ + "schema": "new-position.v1", + "status": "ok", + "tokenAId": tokenHigh, + "tokenBId": tokenLow, + "poolStatus": "active_pool", + "reserveARaw": "3000000", + "reserveBRaw": "2" + })) + + verify(form.activePool) + compare(form.activePoolQuote.poolStatus, "active_pool") + compare(form.amountA, "") + compare(form.amountB, "") + compare(form.minimumAmountARaw, "") + compare(form.minimumAmountBRaw, "") + quoteRequestedSpy.target = form + quoteRequestedSpy.clear() + form.requestQuote(true) + compare(quoteRequestedSpy.count, 0) + compare(form.localErrors.length, 0) + } + + function test_staleQuoteErrorsDoNotMarkCurrentDraft() { + var quote = { + "schema": "new-position.v1", + "status": "ok", + "tokenAId": tokenHigh, + "tokenBId": tokenLow, + "poolStatus": "active_pool", + "reserveARaw": "2", + "reserveBRaw": "10", + "maxAmountARaw": "4", + "maxAmountBRaw": "20", + "errors": [{ + "code": "amount_exceeds_balance", + "blockingFields": ["maxAmountARaw"] + }] + } + var form = createForm() + form.flowState = flowState(quote) + wait(0) + compare(form.fieldError("amountB"), form.issueText("amount_exceeds_balance")) + + var staleState = flowState(quote) + staleState.quoteStale = true + form.flowState = staleState + wait(0) + + compare(form.fieldError("amountB"), "") + compare(form.formErrorText(), "") + compare(form.accountPreview().length, 0) + } + + function test_activePoolEditUsesDisplayReserveRatio() { + var quote = { + "status": "ok", + "tokenAId": tokenHigh, + "tokenBId": tokenLow, + "poolStatus": "active_pool", + "reserveARaw": "2", + "reserveBRaw": "10", + "maxAmountARaw": "4", + "maxAmountBRaw": "20" + } + var form = createForm() + form.flowState = flowState(quote) + wait(0) + compare(form.amountA, "20") + compare(form.amountB, "4") + + quoteRequestedSpy.target = form + quoteRequestedSpy.clear() + form.editActiveAmount("A", "5") + compare(form.amountA, "5") + compare(form.amountB, "4") + compare(quoteRequestedSpy.count, 0) + + form.finishActiveAmount("A", "5") + compare(form.amountB, "1") + compare(quoteRequestedSpy.count, 1) + + var built = form.buildQuoteRequest() + verify(built.ok) + compare(built.request.maxAmountARaw, "1") + compare(built.request.maxAmountBRaw, "5") + + form.finishActiveAmount("B", "1.1234567") + compare(form.amountB, "1.1234567") + verify(!form.buildQuoteRequest().ok) + } + + function test_activePoolEditRecoversAfterInvalidQuote() { + var form = createForm() + form.flowState = flowState({ + "status": "ok", + "tokenAId": tokenHigh, + "tokenBId": tokenLow, + "poolStatus": "active_pool", + "poolFeeBps": 30, + "reserveARaw": "2", + "reserveBRaw": "10", + "maxAmountARaw": "4", + "maxAmountBRaw": "20" + }) + wait(0) + + form.amountA = "0" + form.amountB = "0" + form.flowState = flowState({ + "status": "error", + "code": "value_must_be_positive", + "tokenAId": tokenHigh, + "tokenBId": tokenLow + }) + wait(0) + + compare(form.poolFeeBps, 30) + form.finishActiveAmount("B", "1") + compare(form.amountA, "5") + + var built = form.buildQuoteRequest() + verify(built.ok) + compare(built.request.maxAmountARaw, "1") + compare(built.request.maxAmountBRaw, "5") + } + + function test_quoteStateChangeDoesNotRequestAnotherQuote() { + var form = createForm() + quoteRequestedSpy.target = form + quoteRequestedSpy.clear() + + form.flowState = { + "quote": ({}), + "contextLoading": false, + "quoteLoading": true, + "quoteStale": true, + "submitting": false + } + wait(0) + + compare(quoteRequestedSpy.count, 0) + } + + function test_existingPoolFeeCorrectionKeepsQuoteRequestValid() { + var form = createForm() + quoteRequestedSpy.target = form + quoteRequestedSpy.clear() + + form.flowState = flowState({ + "status": "error", + "code": "fee_tier_mismatch", + "tokenAId": tokenHigh, + "tokenBId": tokenLow, + "errors": [{ + "code": "fee_tier_mismatch", + "details": { "poolFeeBps": "5" } + }] + }) + wait(0) + + compare(form.selectedFeeBps, 5) + compare(quoteRequestedSpy.count, 1) + verify(quoteRequestedSpy.signalArguments[0][1].ok) + compare(quoteRequestedSpy.signalArguments[0][1].request.maxAmountARaw, "5000000000") + compare(quoteRequestedSpy.signalArguments[0][1].request.maxAmountBRaw, "1000") + } + + function test_contextFailureFinishesTokenResolution() { + var form = createForm() + form.resolvingTokenId = tokenThird + form.resolvingTokenSide = "A" + + form.newPositionContext = { + "status": "error", + "code": "config_unavailable", + "tokens": [] + } + form.finishTokenResolution(true) + wait(0) + + compare(form.resolvingTokenId, "") + compare(form.resolvingTokenSide, "") + compare(form.tokenResolutionError, form.issueText("config_unavailable")) + } + + function test_staleContextDoesNotFinishNewerTokenResolution() { + var form = createForm() + form.resolvingTokenId = tokenThird + form.resolvingTokenSide = "B" + + form.newPositionContext = { + "status": "ready", + "tokens": [{ + "definitionId": tokenLow, + "name": "Earlier token", + "selectable": true + }] + } + wait(0) + + compare(form.resolvingTokenId, tokenThird) + compare(form.resolvingTokenSide, "B") + compare(form.tokenResolutionError, "") + } + + function test_replacingSelectedTokensClearsPairDraft() { + var form = createForm() + form.amountA = "12" + form.amountB = "34" + form.minimumAmountARaw = "12" + form.minimumAmountBRaw = "34" + form.confirmedPoolStatus = "active_pool" + + form.newPositionContext = { + "status": "ready", + "tokens": [ + { + "definitionId": tokenHigh, + "name": "High", + "totalSupplyRaw": "1000000000000", + "balanceRaw": "5000000000", + "selectable": true + }, + { + "definitionId": tokenThird, + "name": "Third", + "totalSupplyRaw": "1000000", + "balanceRaw": "100", + "selectable": true + } + ] + } + wait(0) + + compare(form.selectedTokenAId, tokenHigh) + compare(form.selectedTokenBId, tokenThird) + compare(form.amountA, "") + compare(form.amountB, "") + compare(form.minimumAmountARaw, "") + compare(form.minimumAmountBRaw, "") + compare(form.confirmedPoolStatus, "") + } + + function test_networkFailurePreservesPairDraft() { + var form = createForm() + form.amountA = "12" + form.amountB = "34" + form.confirmedPoolStatus = "active_pool" + + form.newPositionContext = { + "status": "network_mismatch", + "tokens": [] + } + wait(0) + + compare(form.selectedTokenAId, tokenLow) + compare(form.selectedTokenBId, tokenHigh) + compare(form.amountA, "12") + compare(form.amountB, "34") + compare(form.confirmedPoolStatus, "active_pool") + verify(form.contextBlocksForm()) + } + + function test_submittedBase58TransactionIdIsCopied() { + var state = flowState(({})) + state.transactionId = submittedTransactionId + var form = createTemporaryObject(formComponent, testCase, { + "flowState": state + }) + var sink = createTemporaryObject(clipboardSinkComponent, testCase) + verify(form) + verify(sink) + wait(0) + + compare(form.transactionId, submittedTransactionId) + var copyButton = findChild(form, "copySubmittedTransactionButton") + verify(copyButton) + copyButton.clicked() + verify(copyButton.copied) + sink.paste() + tryCompare(sink, "text", submittedTransactionId) + } +} diff --git a/apps/amm/tests/qml/tst_ResponsivePopups.qml b/apps/amm/tests/qml/tst_ResponsivePopups.qml new file mode 100644 index 00000000..0c56b7fe --- /dev/null +++ b/apps/amm/tests/qml/tst_ResponsivePopups.qml @@ -0,0 +1,47 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtTest + +import "../../qml/components/liquidity" as Liquidity + +TestCase { + id: testCase + + name: "ResponsivePopups" + + Liquidity.AmmTheme { + id: theme + } + + Component { + id: viewportComponent + + Item { + width: 320 + height: 300 + } + } + + Component { + id: tokenSelectorComponent + + Liquidity.TokenSelectorModal { + theme: theme + } + } + + function test_tokenSelectorStaysInsideShortViewport() { + var viewport = createTemporaryObject(viewportComponent, testCase) + var selector = createTemporaryObject(tokenSelectorComponent, viewport, { + "parent": viewport + }) + verify(viewport) + verify(selector) + + verify(selector.x >= 0) + verify(selector.y >= 0) + verify(selector.x + selector.width <= viewport.width) + verify(selector.y + selector.height <= viewport.height) + } +} diff --git a/apps/amm/tests/qml/tst_TokenAmountInput.qml b/apps/amm/tests/qml/tst_TokenAmountInput.qml new file mode 100644 index 00000000..7a10ae33 --- /dev/null +++ b/apps/amm/tests/qml/tst_TokenAmountInput.qml @@ -0,0 +1,280 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtTest + +import "../../qml/components/liquidity" as Liquidity + +TestCase { + id: testCase + + name: "TokenAmountInput" + readonly property string enabledId: "22222222222222222222222222222222" + readonly property string disabledId: "33333333333333333333333333333333" + + Component { + id: inputComponent + + Liquidity.TokenAmountInput { + visible: false + width: 400 + theme: inputTheme + tokens: [ + { + "definitionId": testCase.disabledId, + "name": "Disabled", + "selectable": false, + "code": "token_not_fungible" + }, + { + "definitionId": testCase.enabledId, + "name": "Enabled", + "selectable": true + } + ] + + Liquidity.AmmTheme { + id: inputTheme + } + } + } + + SignalSpy { + id: commitSpy + signalName: "editingCommitted" + } + + SignalSpy { + id: selectedSpy + signalName: "tokenSelected" + } + + SignalSpy { + id: enteredSpy + signalName: "tokenEntered" + } + + function test_inactivityCommitsLatestEditOnce() { + var input = createTemporaryObject(inputComponent, testCase) + verify(input) + commitSpy.target = input + commitSpy.clear() + + input.amountEdited("1") + wait(150) + compare(commitSpy.count, 0) + + input.amountEdited("12") + wait(200) + compare(commitSpy.count, 0) + tryCompare(commitSpy, "count", 1, 150) + compare(commitSpy.signalArguments[0][0], "12") + } + + function test_editingFinishedCommitsWithoutDuplicate() { + var input = createTemporaryObject(inputComponent, testCase) + verify(input) + commitSpy.target = input + commitSpy.clear() + + input.amountEdited("7") + input.amountEditingFinished("7") + compare(commitSpy.count, 1) + compare(commitSpy.signalArguments[0][0], "7") + + wait(300) + compare(commitSpy.count, 1) + } + + function test_compactWidthShrinksTokenAccessory() { + var input = createTemporaryObject(inputComponent, testCase, { "width": 296 }) + verify(input) + compare(input.accessoryWidth, 132) + + input.width = 416 + compare(input.accessoryWidth, 180) + } + + function test_disabledTokenIsRejectedByTypedInput() { + var input = createTemporaryObject(inputComponent, testCase) + verify(input) + selectedSpy.target = input + enteredSpy.target = input + selectedSpy.clear() + enteredSpy.clear() + + input.acceptInput("Disabled") + + compare(selectedSpy.count, 0) + compare(enteredSpy.count, 1) + compare(enteredSpy.signalArguments[0][0], disabledId) + } + + function test_enabledTokenIsSelectedByTypedInput() { + var input = createTemporaryObject(inputComponent, testCase) + verify(input) + selectedSpy.target = input + enteredSpy.target = input + selectedSpy.clear() + enteredSpy.clear() + + input.acceptInput("Enabled") + + compare(selectedSpy.count, 1) + compare(selectedSpy.signalArguments[0][0], enabledId) + compare(enteredSpy.count, 0) + } + + function test_partialNameSelectsSoleMatchInsteadOfCustomAddress() { + var input = createTemporaryObject(inputComponent, testCase) + verify(input) + selectedSpy.target = input + enteredSpy.target = input + selectedSpy.clear() + enteredSpy.clear() + input.query = "Enabl" + tryCompare(input.rows, "length", 1) + + input.acceptInput("Enabl") + + compare(selectedSpy.count, 1) + compare(selectedSpy.signalArguments[0][0], enabledId) + compare(enteredSpy.count, 0) + } + + function test_disabledTokenCanExplainWhyItIsUnavailable() { + var input = createTemporaryObject(inputComponent, testCase, { + "visible": true, + "width": 320 + }) + verify(input) + + input.popup.open() + tryCompare(input.popup, "visible", true) + var tokenList = findChild(input, "tokenList") + verify(tokenList) + tryCompare(tokenList, "count", 2) + tryVerify(function() { return tokenList.itemAtIndex(0) !== null }) + var option = tokenList.itemAtIndex(0) + + mouseMove(option, option.width / 2, option.height / 2) + tryCompare(option, "pointerHovered", true) + compare(option.disabledReasonVisible, true) + + selectedSpy.target = input + enteredSpy.target = input + selectedSpy.clear() + enteredSpy.clear() + mouseClick(option, option.width / 2, option.height / 2) + compare(selectedSpy.count, 0) + compare(enteredSpy.count, 0) + } + + function test_tokenListSupportsKeyboardSelection() { + var input = createTemporaryObject(inputComponent, testCase, { + "visible": true, + "width": 320 + }) + verify(input) + selectedSpy.target = input + selectedSpy.clear() + + input.popup.open() + tryCompare(input.popup, "visible", true) + var tokenList = findChild(input, "tokenList") + verify(tokenList) + tryVerify(function() { return tokenList.itemAtIndex(1) !== null }) + var option = tokenList.itemAtIndex(1) + option.forceActiveFocus() + tryCompare(option, "activeFocus", true) + + keyClick(Qt.Key_Space) + + compare(selectedSpy.count, 1) + compare(selectedSpy.signalArguments[0][0], enabledId) + } + + function test_disabledReasonAppearsOnKeyboardFocus() { + var input = createTemporaryObject(inputComponent, testCase, { + "visible": true, + "width": 320 + }) + verify(input) + + input.popup.open() + tryCompare(input.popup, "visible", true) + var tokenList = findChild(input, "tokenList") + verify(tokenList) + tryVerify(function() { return tokenList.itemAtIndex(0) !== null }) + var option = tokenList.itemAtIndex(0) + option.forceActiveFocus() + + tryCompare(option, "activeFocus", true) + compare(option.disabledReasonVisible, true) + } + + function test_searchModelContainsOnlyMatchingRows() { + var input = createTemporaryObject(inputComponent, testCase) + verify(input) + compare(input.rows.length, 2) + + input.query = "Enabled" + + tryVerify(function() { return input.rows.length === 1 }) + compare(input.rows[0].definitionId, enabledId) + } + + function test_typingKeepsSearchTextWhileModelFilters() { + var input = createTemporaryObject(inputComponent, testCase, { + "visible": true, + "width": 320 + }) + verify(input) + input.popup.open() + tryCompare(input.popup, "visible", true) + var editor = findChild(input, "tokenSearchField") + verify(editor) + + tryCompare(editor, "activeFocus", true) + keyClick(Qt.Key_E) + keyClick(Qt.Key_N) + keyClick(Qt.Key_A) + keyClick(Qt.Key_B) + keyClick(Qt.Key_L) + keyClick(Qt.Key_E) + keyClick(Qt.Key_D) + + compare(editor.text.toLowerCase(), "enabled") + compare(input.rows.length, 1) + compare(input.rows[0].definitionId, enabledId) + } + + function test_clickOpensSharedTokenModal() { + var input = createTemporaryObject(inputComponent, testCase, { + "visible": true, + "width": 320 + }) + verify(input) + var button = findChild(input, "tokenSelectButton") + verify(button) + + button.click() + + tryCompare(input.popup, "visible", true) + verify(findChild(input, "tokenSearchField")) + verify(findChild(input, "tokenList")) + } + + function test_unlistedAddressCanBeEntered() { + var input = createTemporaryObject(inputComponent, testCase) + verify(input) + enteredSpy.target = input + enteredSpy.clear() + var unlistedId = "44444444444444444444444444444444" + + input.acceptInput(unlistedId) + + compare(enteredSpy.count, 1) + compare(enteredSpy.signalArguments[0][0], unlistedId) + } +} diff --git a/flake.nix b/flake.nix index 15fc2816..61f36614 100644 --- a/flake.nix +++ b/flake.nix @@ -98,10 +98,36 @@ ''; } ); + + # Second AMM client crate (apps/amm/client) — the new-position/pool + # flow's protocol lib. Built alongside amm_client_ffi (they coexist: + # symbols are prefixed amm_* vs amm_client_*). TODO: consolidate the two + # into a single AMM client FFI. + ammClientArgs = commonArgs // { + pname = "amm_client"; + cargoExtraArgs = "-p amm_client"; + }; + ammClient = craneLib.buildPackage ( + ammClientArgs + // { + cargoArtifacts = craneLib.buildDepsOnly ammClientArgs; + postInstall = + '' + mkdir -p $out/include + cp apps/amm/client/include/amm_client.h $out/include/ + '' + + pkgs.lib.optionalString pkgs.stdenv.isDarwin '' + if [ -f $out/lib/libamm_client.dylib ]; then + install_name_tool -id "$out/lib/libamm_client.dylib" $out/lib/libamm_client.dylib + fi + ''; + } + ); in { packages.default = ammClientFfi; packages.amm_client_ffi = ammClientFfi; + packages.amm_client = ammClient; } ); @@ -117,6 +143,7 @@ flakeInputs = inputs; externalLibInputs = { amm_client_ffi = { input = self; packages.default = "amm_client_ffi"; }; + amm_client = { input = self; packages.default = "amm_client"; }; }; # The AMM UI links the shared C++ wallet access lib and bundles the # Logos.Wallet QML module (apps/shared/wallet). apps/amm/flake.nix wires @@ -160,10 +187,11 @@ let pkgs = import nixpkgs { inherit system; overlays = [ rust-overlay.overlays.default ]; }; ammFfi = crateOutputs.packages.${system}.amm_client_ffi; + ammClient = crateOutputs.packages.${system}.amm_client; in app // { program = "${pkgs.writeShellScript "run-amm-ui" '' - export DYLD_FALLBACK_LIBRARY_PATH="${ammFfi}/lib''${DYLD_FALLBACK_LIBRARY_PATH:+:$DYLD_FALLBACK_LIBRARY_PATH}" + export DYLD_FALLBACK_LIBRARY_PATH="${ammFfi}/lib:${ammClient}/lib''${DYLD_FALLBACK_LIBRARY_PATH:+:$DYLD_FALLBACK_LIBRARY_PATH}" exec ${app.program} "$@" ''}"; }; From 6cadc86b5484c64a5d52730fe43d88d7330f525c Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Wed, 15 Jul 2026 21:24:56 -0300 Subject: [PATCH 05/10] fix(amm-ui): require explicit liquidity inputs Keep wallet assets as token choices without automatically selecting a pair. Keep pool-probe amounts internal so active-pool quotes do not populate the form before user input. --- .../components/liquidity/NewPositionForm.qml | 61 +++++++++---------- apps/amm/tests/qml/tst_NewPositionForm.qml | 46 ++++++++------ 2 files changed, 55 insertions(+), 52 deletions(-) diff --git a/apps/amm/qml/components/liquidity/NewPositionForm.qml b/apps/amm/qml/components/liquidity/NewPositionForm.qml index c70000fc..9cd98968 100644 --- a/apps/amm/qml/components/liquidity/NewPositionForm.qml +++ b/apps/amm/qml/components/liquidity/NewPositionForm.qml @@ -109,13 +109,13 @@ AmmActionCard { implicitHeight: content.implicitHeight + root.contentPadding * 2 implicitWidth: 480 - Component.onCompleted: Qt.callLater(root.ensurePair) + Component.onCompleted: Qt.callLater(root.reconcileSelection) onNewPositionContextChanged: Qt.callLater(root.applyContextChange) function applyContextChange() { if (root.resolvingToken) root.finishTokenResolution() else - root.ensurePair() + root.reconcileSelection() } onQuotePayloadChanged: { if (root.quoteStale) @@ -786,26 +786,23 @@ AmmActionCard { root.tokenResolutionErrorSide = side } - function ensurePair() { + function reconcileSelection() { var status = String(root.newPositionContext.status || "") if (status !== "ready" && status !== "no_wallet") return var previousA = root.selectedTokenAId var previousB = root.selectedTokenBId var selectable = root.selectableTokenIds() - if (selectable.length === 0) { + if (root.selectedTokenAId.length > 0 + && selectable.indexOf(root.selectedTokenAId) < 0) { root.selectedTokenAId = "" - root.selectedTokenBId = "" - if (previousA.length > 0 || previousB.length > 0) - root.resetPairDraft() - return } - if (selectable.indexOf(root.selectedTokenAId) < 0) - root.selectedTokenAId = selectable[0] - if (selectable.indexOf(root.selectedTokenBId) < 0 - || root.selectedTokenBId === root.selectedTokenAId) { - root.selectedTokenBId = selectable.length > 1 ? selectable[1] : "" + if (root.selectedTokenBId.length > 0 + && selectable.indexOf(root.selectedTokenBId) < 0) { + root.selectedTokenBId = "" } + if (root.selectedTokenBId === root.selectedTokenAId) + root.selectedTokenBId = "" if (root.selectedTokenAId !== previousA || root.selectedTokenBId !== previousB) root.resetPairDraft() else if (root.hasPair) @@ -987,14 +984,7 @@ AmmActionCard { return { "ok": false, "errors": errors, "request": ({}) } } - var canonicalAId = root.displayIsCanonical ? root.selectedTokenAId : root.selectedTokenBId - var canonicalBId = root.displayIsCanonical ? root.selectedTokenBId : root.selectedTokenAId - var request = { - "schema": "new-position.v1", - "tokenAId": canonicalAId, - "tokenBId": canonicalBId, - "feeBps": root.selectedFeeBps - } + var request = root.pairRequest() if (root.activePool) { var parsedA = AmountMath.parseHuman(root.amountA, root.decimalsA) @@ -1085,6 +1075,17 @@ AmmActionCard { return { "ok": errors.length === 0, "errors": errors, "request": request } } + function pairRequest() { + return { + "schema": "new-position.v1", + "tokenAId": root.displayIsCanonical + ? root.selectedTokenAId : root.selectedTokenBId, + "tokenBId": root.displayIsCanonical + ? root.selectedTokenBId : root.selectedTokenAId, + "feeBps": root.selectedFeeBps + } + } + function requestQuote(immediate) { if (root.activePool && root.amountA.length === 0 && root.amountB.length === 0) { root.localErrors = [] @@ -1327,24 +1328,18 @@ AmmActionCard { return if (root.poolFeeBps > 0 && root.selectedFeeBps !== root.poolFeeBps) { root.selectedFeeBps = root.poolFeeBps - if (root.amountA.length === 0 && root.amountB.length === 0) { - root.amountA = AmountMath.formatRaw( - root.probeRaw(root.tokenA, root.decimalsA), root.decimalsA) - root.amountB = AmountMath.formatRaw( - root.probeRaw(root.tokenB, root.decimalsB), root.decimalsB) - } - root.requestQuote(true) + root.localErrors = [] + root.quoteRequested(true, { + "ok": true, + "errors": [], + "request": root.poolProbeRequest(root.pairRequest()) + }) return } if (root.quotePayload.status !== "ok") return - if (root.activePool && root.amountA.length === 0 && root.amountB.length === 0) { - root.amountA = AmountMath.formatRaw(root.displayRaw("maxAmountARaw", "maxAmountBRaw", "A"), root.decimalsA) - root.amountB = AmountMath.formatRaw(root.displayRaw("maxAmountARaw", "maxAmountBRaw", "B"), root.decimalsB) - } - if (root.missingPool) { var rawA = root.displayRaw("actualAmountARaw", "actualAmountBRaw", "A") var rawB = root.displayRaw("actualAmountARaw", "actualAmountBRaw", "B") diff --git a/apps/amm/tests/qml/tst_NewPositionForm.qml b/apps/amm/tests/qml/tst_NewPositionForm.qml index 083be1fa..d2a81928 100644 --- a/apps/amm/tests/qml/tst_NewPositionForm.qml +++ b/apps/amm/tests/qml/tst_NewPositionForm.qml @@ -91,17 +91,33 @@ TestCase { } } - function createForm() { + function createEmptyForm(context) { var form = createTemporaryObject(formComponent, testCase, { - "flowState": flowState(({})) + "flowState": flowState(({})), + "newPositionContext": context || readyContext() }) verify(form) wait(0) + return form + } + + function createForm(context) { + var form = createEmptyForm(context) + form.selectToken("A", tokenLow) + form.selectToken("B", tokenHigh) compare(form.selectedTokenAId, tokenLow) compare(form.selectedTokenBId, tokenHigh) return form } + function test_walletTokensDoNotPrefillPosition() { + var form = createEmptyForm() + compare(form.selectedTokenAId, "") + compare(form.selectedTokenBId, "") + compare(form.amountA, "") + compare(form.amountB, "") + } + function test_displayOrderMapsToCanonicalRequestAfterSwap() { var form = createForm() var built = form.buildQuoteRequest() @@ -159,12 +175,7 @@ TestCase { } function test_missingPoolAcceptsLargeDirectAmountsFromEitherSide() { - var form = createTemporaryObject(formComponent, testCase, { - "flowState": flowState(({})), - "newPositionContext": rawAmountsContext() - }) - verify(form) - wait(0) + var form = createForm(rawAmountsContext()) form.priceAmountA = "15" form.priceAmountB = "10" form.flowState = flowState({ @@ -199,12 +210,7 @@ TestCase { } function test_missingPoolRoundsPairedRawAmounts() { - var form = createTemporaryObject(formComponent, testCase, { - "flowState": flowState(({})), - "newPositionContext": rawAmountsContext() - }) - verify(form) - wait(0) + var form = createForm(rawAmountsContext()) form.priceAmountA = "15" form.priceAmountB = "10" form.flowState = flowState({ @@ -415,14 +421,14 @@ TestCase { var form = createForm() form.flowState = flowState(quote) wait(0) - compare(form.amountA, "20") - compare(form.amountB, "4") + compare(form.amountA, "") + compare(form.amountB, "") quoteRequestedSpy.target = form quoteRequestedSpy.clear() form.editActiveAmount("A", "5") compare(form.amountA, "5") - compare(form.amountB, "4") + compare(form.amountB, "") compare(quoteRequestedSpy.count, 0) form.finishActiveAmount("A", "5") @@ -509,6 +515,8 @@ TestCase { wait(0) compare(form.selectedFeeBps, 5) + compare(form.amountA, "") + compare(form.amountB, "") compare(quoteRequestedSpy.count, 1) verify(quoteRequestedSpy.signalArguments[0][1].ok) compare(quoteRequestedSpy.signalArguments[0][1].request.maxAmountARaw, "5000000000") @@ -582,8 +590,8 @@ TestCase { } wait(0) - compare(form.selectedTokenAId, tokenHigh) - compare(form.selectedTokenBId, tokenThird) + compare(form.selectedTokenAId, "") + compare(form.selectedTokenBId, tokenHigh) compare(form.amountA, "") compare(form.amountB, "") compare(form.minimumAmountARaw, "") From 81d20cef5bedc6ee2b09e1600d7b38ea551d8eea Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Thu, 16 Jul 2026 11:40:40 -0300 Subject: [PATCH 06/10] fix(amm): align execution zone dependencies --- apps/amm/MACOS.md | 173 ++++ apps/amm/README.md | 11 +- apps/amm/flake.lock | 888 +++++++++--------- apps/amm/scripts/check-macos-prerequisites.sh | 117 +++ 4 files changed, 729 insertions(+), 460 deletions(-) create mode 100644 apps/amm/MACOS.md create mode 100755 apps/amm/scripts/check-macos-prerequisites.sh diff --git a/apps/amm/MACOS.md b/apps/amm/MACOS.md new file mode 100644 index 00000000..0036bc9e --- /dev/null +++ b/apps/amm/MACOS.md @@ -0,0 +1,173 @@ +# AMM UI on macOS + +The AMM flake exposes native packages for Apple Silicon and Intel macOS. Nix +provides the Rust, C++, Qt, and Apple SDK build dependencies. The only required +host toolchain is Apple's Metal compiler, which Apple distributes through +Xcode rather than through a redistributable package. + +The standalone build needs no input overrides, temporary source pins, or +`DYLD_LIBRARY_PATH`. + +## One-time prerequisites + +### Nix + +Install Nix in multi-user mode, make sure its daemon is available, and enable +flakes: + +```sh +mkdir -p ~/.config/nix +printf '%s\n' 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf +nix --version +nix store info +``` + +Nix normally has `sandbox = false` on macOS. This build must retain that +setting because RISC Zero invokes Apple's host-installed Metal toolchain. The +preflight script below reports an actionable error if the daemon is configured +otherwise. + +### Xcode and the Metal Toolchain + +Apple Command Line Tools alone do not include `metal` and `metallib`. Install +the newest full Xcode release supported by the installed macOS from the App +Store or [Apple Developer Downloads](https://developer.apple.com/download/all/). +This requires an Apple account and cannot be automated by the repository. + +After installation: + +```sh +sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer +sudo xcodebuild -license accept +sudo xcodebuild -runFirstLaunch +xcodebuild -downloadComponent MetalToolchain +xcrun --sdk macosx --find metal +xcrun --sdk macosx --find metallib +``` + +The last two commands must print executable paths. Do not use +`RISC0_SKIP_BUILD_KERNELS=1`: the pinned dependency graph requires the real +Metal kernels. + +### Disk space and preflight + +Leave at least 60 GiB free before downloading Xcode. Once Xcode is installed, +keep at least 40 GiB free for the first Nix build. Later builds reuse the Nix +store. + +Run the complete machine check from `apps/amm`: + +```sh +bash scripts/check-macos-prerequisites.sh +``` + +## Build and run + +From `apps/amm`: + +```sh +nix build . +nix run . +``` + +The first cold build is large because it includes Qt, the wallet, the AMM +client, and the RISC Zero Metal kernels. The first start opens without a +wallet; select **Connect** to create or open `~/.lee/wallet/`. + +## Why the flake is structured this way + +Nix 2.25 rejects committed lock entries for unlocked relative flake inputs such +as `path:../shared/wallet` and `path:../..`. Newer Nix versions may accept that +layout, which is why it can appear to work on one developer's Mac and fail on +another. + +The portable layout avoids both relative flake inputs: + +- the shared wallet is passed to CMake as an ordinary Nix source path; +- the repository-root AMM client package is imported directly with the same + locked `nixpkgs` and Crane inputs; +- `flake.lock` contains only immutable upstream sources. + +The app output uses the builder's full-library QML package layout. The current +`qt-plugin` layout copies the UI plugin and replica factory but omits sibling +external libraries such as `libamm_client`. The module metadata still selects +the C++ backend, so this packaging choice does not change process isolation or +runtime behavior. + +Do not replace these with a pull-request URL, self-reference, or local +`path:` flake input. A normal `nix flake lock`, `nix build`, and `nix run` +must work without overrides. + +## Known-good validation host + +This configuration has been exercised on: + +- Apple Silicon (`arm64`) +- macOS 26.5.2 +- Xcode 26.6 (build 17F113) +- Metal Toolchain 32023.883 +- Nix 2.25.3 + +The flake also evaluates its complete package and app outputs for +`x86_64-darwin`; an Intel Mac is still required for native runtime validation. + +## Recovery + +### Nix is unavailable + +Open a new terminal after installing Nix. For the standard multi-user +installation, check the store and daemon before trying to bootstrap it again: + +```sh +test -x /nix/var/nix/profiles/default/bin/nix +nix store info +sudo launchctl print system/org.nixos.nix-daemon +``` + +`launchctl bootstrap` can report an I/O error when a service is already loaded +or in an inconsistent state; that message alone does not prove the daemon is +stopped. + +### `xcrun` cannot find `metal` + +The selected developer directory points at Command Line Tools, or Xcode lacks +the separately downloaded Metal Toolchain component. Repeat the Xcode setup +and verification commands above. + +Apple does not distribute the Metal compiler as a standalone redistributable +SDK, so Nix cannot install this host component. + +### The build reports that the macOS sandbox blocks Metal + +Confirm the active setting: + +```sh +nix config show sandbox +``` + +For this native macOS build it must be `false`. If a machine-wide policy set it +to `true`, update that Nix installation's daemon configuration and restart the +daemon before retrying. + +### The first build runs out of space + +Remove only disposable build outputs, then collect dead Nix store paths: + +```sh +rm -rf /private/tmp/amm-build +nix-store --gc +``` + +Do not remove `/nix/store` directly. + +### A lock error mentions an unlocked input + +Restore the committed app lock and verify it without rewriting it: + +```sh +git restore flake.lock +nix flake metadata --no-update-lock-file . +``` + +If the error still names `path:../shared/wallet` or `path:../..`, the checkout +does not contain the portable flake fix described above. diff --git a/apps/amm/README.md b/apps/amm/README.md index 7219a3a5..97e7044f 100644 --- a/apps/amm/README.md +++ b/apps/amm/README.md @@ -50,6 +50,9 @@ nix profile install 'github:logos-co/logos-package-manager#cli' This makes `lgpm` available as a global command. +For macOS prerequisites, the Metal toolchain requirement, and common Nix +recovery steps, see [macOS setup](MACOS.md). + ## Running the UI standalone The app is built from the **repository-root** flake (which also provides the @@ -82,9 +85,11 @@ nix build '.#lgx' --out-link result-lgx # Portable variant (self-contained, works without nix) nix build '.#lgx-portable' --out-link result-lgx-portable -# The core wallet module it depends on. Use the same rev this app pins as the -# `logos_execution_zone` input in flake.nix so the ImageID/ABI match. -nix build 'github:logos-blockchain/logos-execution-zone-module?rev=d2e9400ac06c3cdbfc2405b4f153fff9841a453c#lgx' \ +# The core wallet module it depends on. These are the same immutable upstream +# revisions used by this app's flake, including the merged macOS Metal fix. +nix build 'github:logos-blockchain/logos-execution-zone-module?rev=d70225ced646934d2294fd9e8f8b03615c104b80#lgx' \ + --override-input logos-execution-zone \ + 'github:logos-blockchain/logos-execution-zone?rev=a7e06a660940a00093b1760560d37ff84aff5a05' \ --out-link result-core ``` diff --git a/apps/amm/flake.lock b/apps/amm/flake.lock index f5272d3d..c272fd5b 100644 --- a/apps/amm/flake.lock +++ b/apps/amm/flake.lock @@ -1,31 +1,17 @@ { "nodes": { - "amm_client": { - "inputs": { - "crane": "crane", - "nixpkgs": "nixpkgs" - }, - "locked": { - "path": "../..", - "type": "path" - }, - "original": { - "path": "../..", - "type": "path" - }, - "parent": [] - }, "crane": { "locked": { - "lastModified": 1783203018, - "narHash": "sha256-G6R9IT/xwFuu+CYBWDUAok6AdC4ERC4ZfPPFtEpxnZE=", + "lastModified": 1779041105, + "narHash": "sha256-nnGD2f8OlAZT2i5OfwikJsw+ifWfiA4d6A8BWlgOXV0=", "owner": "ipetkov", "repo": "crane", - "rev": "80db5bdc391be8a1794f6d8a2d56e3a84ebcede2", + "rev": "10e6e3cb966f7cfcc789fe5eee7a85f3188ce08b", "type": "github" }, "original": { "owner": "ipetkov", + "ref": "v0.23.4", "repo": "crane", "type": "github" } @@ -106,7 +92,7 @@ }, "logos-blockchain-circuits": { "inputs": { - "nixpkgs": "nixpkgs_125" + "nixpkgs": "nixpkgs_124" }, "locked": { "lastModified": 1781004244, @@ -2268,17 +2254,17 @@ "rust-rapidsnark": "rust-rapidsnark" }, "locked": { - "lastModified": 1782428741, - "narHash": "sha256-ltLcysXUdVUXAe25Tl8x7e7ZsTzj1sHlyS3glp97TAo=", + "lastModified": 1784202021, + "narHash": "sha256-BG94Ob5tbJyw7GclNBdnOWqcBLRld8sc+xeOU8LI5RQ=", "owner": "logos-blockchain", "repo": "logos-execution-zone", - "rev": "e37876a64028a335eb693198a1ed6a0e875ec5b4", + "rev": "a7e06a660940a00093b1760560d37ff84aff5a05", "type": "github" }, "original": { "owner": "logos-blockchain", "repo": "logos-execution-zone", - "rev": "e37876a64028a335eb693198a1ed6a0e875ec5b4", + "rev": "a7e06a660940a00093b1760560d37ff84aff5a05", "type": "github" } }, @@ -4793,7 +4779,7 @@ }, "logos-nix": { "inputs": { - "nixpkgs": "nixpkgs_2" + "nixpkgs": "nixpkgs" }, "locked": { "lastModified": 1774455309, @@ -4811,7 +4797,7 @@ }, "logos-nix_10": { "inputs": { - "nixpkgs": "nixpkgs_11" + "nixpkgs": "nixpkgs_10" }, "locked": { "lastModified": 1774455309, @@ -4829,7 +4815,7 @@ }, "logos-nix_100": { "inputs": { - "nixpkgs": "nixpkgs_101" + "nixpkgs": "nixpkgs_100" }, "locked": { "lastModified": 1773955630, @@ -4847,7 +4833,7 @@ }, "logos-nix_101": { "inputs": { - "nixpkgs": "nixpkgs_102" + "nixpkgs": "nixpkgs_101" }, "locked": { "lastModified": 1773955630, @@ -4865,7 +4851,7 @@ }, "logos-nix_102": { "inputs": { - "nixpkgs": "nixpkgs_103" + "nixpkgs": "nixpkgs_102" }, "locked": { "lastModified": 1773955630, @@ -4883,7 +4869,7 @@ }, "logos-nix_103": { "inputs": { - "nixpkgs": "nixpkgs_104" + "nixpkgs": "nixpkgs_103" }, "locked": { "lastModified": 1773955630, @@ -4901,7 +4887,7 @@ }, "logos-nix_104": { "inputs": { - "nixpkgs": "nixpkgs_105" + "nixpkgs": "nixpkgs_104" }, "locked": { "lastModified": 1773955630, @@ -4919,7 +4905,7 @@ }, "logos-nix_105": { "inputs": { - "nixpkgs": "nixpkgs_106" + "nixpkgs": "nixpkgs_105" }, "locked": { "lastModified": 1774455309, @@ -4937,7 +4923,7 @@ }, "logos-nix_106": { "inputs": { - "nixpkgs": "nixpkgs_107" + "nixpkgs": "nixpkgs_106" }, "locked": { "lastModified": 1773955630, @@ -4955,7 +4941,7 @@ }, "logos-nix_107": { "inputs": { - "nixpkgs": "nixpkgs_108" + "nixpkgs": "nixpkgs_107" }, "locked": { "lastModified": 1774455309, @@ -4973,7 +4959,7 @@ }, "logos-nix_108": { "inputs": { - "nixpkgs": "nixpkgs_109" + "nixpkgs": "nixpkgs_108" }, "locked": { "lastModified": 1774455309, @@ -4991,7 +4977,7 @@ }, "logos-nix_109": { "inputs": { - "nixpkgs": "nixpkgs_110" + "nixpkgs": "nixpkgs_109" }, "locked": { "lastModified": 1773955630, @@ -5009,7 +4995,7 @@ }, "logos-nix_11": { "inputs": { - "nixpkgs": "nixpkgs_12" + "nixpkgs": "nixpkgs_11" }, "locked": { "lastModified": 1773955630, @@ -5027,7 +5013,7 @@ }, "logos-nix_110": { "inputs": { - "nixpkgs": "nixpkgs_111" + "nixpkgs": "nixpkgs_110" }, "locked": { "lastModified": 1773955630, @@ -5045,7 +5031,7 @@ }, "logos-nix_111": { "inputs": { - "nixpkgs": "nixpkgs_112" + "nixpkgs": "nixpkgs_111" }, "locked": { "lastModified": 1774455309, @@ -5063,7 +5049,7 @@ }, "logos-nix_112": { "inputs": { - "nixpkgs": "nixpkgs_113" + "nixpkgs": "nixpkgs_112" }, "locked": { "lastModified": 1774455309, @@ -5081,7 +5067,7 @@ }, "logos-nix_113": { "inputs": { - "nixpkgs": "nixpkgs_114" + "nixpkgs": "nixpkgs_113" }, "locked": { "lastModified": 1773955630, @@ -5099,7 +5085,7 @@ }, "logos-nix_114": { "inputs": { - "nixpkgs": "nixpkgs_115" + "nixpkgs": "nixpkgs_114" }, "locked": { "lastModified": 1773955630, @@ -5117,7 +5103,7 @@ }, "logos-nix_115": { "inputs": { - "nixpkgs": "nixpkgs_116" + "nixpkgs": "nixpkgs_115" }, "locked": { "lastModified": 1774455309, @@ -5135,7 +5121,7 @@ }, "logos-nix_116": { "inputs": { - "nixpkgs": "nixpkgs_117" + "nixpkgs": "nixpkgs_116" }, "locked": { "lastModified": 1774455309, @@ -5153,7 +5139,7 @@ }, "logos-nix_117": { "inputs": { - "nixpkgs": "nixpkgs_118" + "nixpkgs": "nixpkgs_117" }, "locked": { "lastModified": 1773955630, @@ -5171,7 +5157,7 @@ }, "logos-nix_118": { "inputs": { - "nixpkgs": "nixpkgs_119" + "nixpkgs": "nixpkgs_118" }, "locked": { "lastModified": 1773955630, @@ -5189,7 +5175,7 @@ }, "logos-nix_119": { "inputs": { - "nixpkgs": "nixpkgs_120" + "nixpkgs": "nixpkgs_119" }, "locked": { "lastModified": 1773955630, @@ -5207,7 +5193,7 @@ }, "logos-nix_12": { "inputs": { - "nixpkgs": "nixpkgs_13" + "nixpkgs": "nixpkgs_12" }, "locked": { "lastModified": 1774455309, @@ -5225,7 +5211,7 @@ }, "logos-nix_120": { "inputs": { - "nixpkgs": "nixpkgs_121" + "nixpkgs": "nixpkgs_120" }, "locked": { "lastModified": 1773955630, @@ -5243,7 +5229,7 @@ }, "logos-nix_121": { "inputs": { - "nixpkgs": "nixpkgs_122" + "nixpkgs": "nixpkgs_121" }, "locked": { "lastModified": 1774455309, @@ -5261,7 +5247,7 @@ }, "logos-nix_122": { "inputs": { - "nixpkgs": "nixpkgs_123" + "nixpkgs": "nixpkgs_122" }, "locked": { "lastModified": 1773955630, @@ -5279,7 +5265,7 @@ }, "logos-nix_123": { "inputs": { - "nixpkgs": "nixpkgs_124" + "nixpkgs": "nixpkgs_123" }, "locked": { "lastModified": 1773955630, @@ -5297,7 +5283,7 @@ }, "logos-nix_124": { "inputs": { - "nixpkgs": "nixpkgs_126" + "nixpkgs": "nixpkgs_125" }, "locked": { "lastModified": 1774455309, @@ -5315,7 +5301,7 @@ }, "logos-nix_125": { "inputs": { - "nixpkgs": "nixpkgs_127" + "nixpkgs": "nixpkgs_126" }, "locked": { "lastModified": 1774455309, @@ -5333,7 +5319,7 @@ }, "logos-nix_126": { "inputs": { - "nixpkgs": "nixpkgs_128" + "nixpkgs": "nixpkgs_127" }, "locked": { "lastModified": 1774455309, @@ -5351,7 +5337,7 @@ }, "logos-nix_127": { "inputs": { - "nixpkgs": "nixpkgs_129" + "nixpkgs": "nixpkgs_128" }, "locked": { "lastModified": 1774455309, @@ -5369,7 +5355,7 @@ }, "logos-nix_128": { "inputs": { - "nixpkgs": "nixpkgs_130" + "nixpkgs": "nixpkgs_129" }, "locked": { "lastModified": 1773955630, @@ -5387,7 +5373,7 @@ }, "logos-nix_129": { "inputs": { - "nixpkgs": "nixpkgs_131" + "nixpkgs": "nixpkgs_130" }, "locked": { "lastModified": 1774455309, @@ -5405,7 +5391,7 @@ }, "logos-nix_13": { "inputs": { - "nixpkgs": "nixpkgs_14" + "nixpkgs": "nixpkgs_13" }, "locked": { "lastModified": 1773955630, @@ -5423,7 +5409,7 @@ }, "logos-nix_130": { "inputs": { - "nixpkgs": "nixpkgs_132" + "nixpkgs": "nixpkgs_131" }, "locked": { "lastModified": 1774455309, @@ -5441,7 +5427,7 @@ }, "logos-nix_131": { "inputs": { - "nixpkgs": "nixpkgs_133" + "nixpkgs": "nixpkgs_132" }, "locked": { "lastModified": 1774455309, @@ -5459,7 +5445,7 @@ }, "logos-nix_132": { "inputs": { - "nixpkgs": "nixpkgs_134" + "nixpkgs": "nixpkgs_133" }, "locked": { "lastModified": 1774455309, @@ -5477,7 +5463,7 @@ }, "logos-nix_133": { "inputs": { - "nixpkgs": "nixpkgs_135" + "nixpkgs": "nixpkgs_134" }, "locked": { "lastModified": 1774455309, @@ -5495,7 +5481,7 @@ }, "logos-nix_134": { "inputs": { - "nixpkgs": "nixpkgs_136" + "nixpkgs": "nixpkgs_135" }, "locked": { "lastModified": 1774455309, @@ -5513,7 +5499,7 @@ }, "logos-nix_135": { "inputs": { - "nixpkgs": "nixpkgs_137" + "nixpkgs": "nixpkgs_136" }, "locked": { "lastModified": 1773955630, @@ -5531,7 +5517,7 @@ }, "logos-nix_136": { "inputs": { - "nixpkgs": "nixpkgs_138" + "nixpkgs": "nixpkgs_137" }, "locked": { "lastModified": 1774455309, @@ -5549,7 +5535,7 @@ }, "logos-nix_137": { "inputs": { - "nixpkgs": "nixpkgs_139" + "nixpkgs": "nixpkgs_138" }, "locked": { "lastModified": 1773955630, @@ -5567,7 +5553,7 @@ }, "logos-nix_138": { "inputs": { - "nixpkgs": "nixpkgs_140" + "nixpkgs": "nixpkgs_139" }, "locked": { "lastModified": 1774455309, @@ -5585,7 +5571,7 @@ }, "logos-nix_139": { "inputs": { - "nixpkgs": "nixpkgs_141" + "nixpkgs": "nixpkgs_140" }, "locked": { "lastModified": 1773955630, @@ -5603,7 +5589,7 @@ }, "logos-nix_14": { "inputs": { - "nixpkgs": "nixpkgs_15" + "nixpkgs": "nixpkgs_14" }, "locked": { "lastModified": 1774455309, @@ -5621,7 +5607,7 @@ }, "logos-nix_140": { "inputs": { - "nixpkgs": "nixpkgs_142" + "nixpkgs": "nixpkgs_141" }, "locked": { "lastModified": 1774455309, @@ -5639,7 +5625,7 @@ }, "logos-nix_141": { "inputs": { - "nixpkgs": "nixpkgs_143" + "nixpkgs": "nixpkgs_142" }, "locked": { "lastModified": 1774455309, @@ -5657,7 +5643,7 @@ }, "logos-nix_142": { "inputs": { - "nixpkgs": "nixpkgs_144" + "nixpkgs": "nixpkgs_143" }, "locked": { "lastModified": 1773955630, @@ -5675,7 +5661,7 @@ }, "logos-nix_143": { "inputs": { - "nixpkgs": "nixpkgs_145" + "nixpkgs": "nixpkgs_144" }, "locked": { "lastModified": 1774455309, @@ -5693,7 +5679,7 @@ }, "logos-nix_144": { "inputs": { - "nixpkgs": "nixpkgs_146" + "nixpkgs": "nixpkgs_145" }, "locked": { "lastModified": 1773955630, @@ -5711,7 +5697,7 @@ }, "logos-nix_145": { "inputs": { - "nixpkgs": "nixpkgs_147" + "nixpkgs": "nixpkgs_146" }, "locked": { "lastModified": 1774455309, @@ -5729,7 +5715,7 @@ }, "logos-nix_146": { "inputs": { - "nixpkgs": "nixpkgs_148" + "nixpkgs": "nixpkgs_147" }, "locked": { "lastModified": 1773955630, @@ -5747,7 +5733,7 @@ }, "logos-nix_147": { "inputs": { - "nixpkgs": "nixpkgs_149" + "nixpkgs": "nixpkgs_148" }, "locked": { "lastModified": 1774455309, @@ -5765,7 +5751,7 @@ }, "logos-nix_148": { "inputs": { - "nixpkgs": "nixpkgs_150" + "nixpkgs": "nixpkgs_149" }, "locked": { "lastModified": 1773955630, @@ -5783,7 +5769,7 @@ }, "logos-nix_149": { "inputs": { - "nixpkgs": "nixpkgs_151" + "nixpkgs": "nixpkgs_150" }, "locked": { "lastModified": 1773955630, @@ -5801,7 +5787,7 @@ }, "logos-nix_15": { "inputs": { - "nixpkgs": "nixpkgs_16" + "nixpkgs": "nixpkgs_15" }, "locked": { "lastModified": 1773955630, @@ -5819,7 +5805,7 @@ }, "logos-nix_150": { "inputs": { - "nixpkgs": "nixpkgs_152" + "nixpkgs": "nixpkgs_151" }, "locked": { "lastModified": 1774455309, @@ -5837,7 +5823,7 @@ }, "logos-nix_151": { "inputs": { - "nixpkgs": "nixpkgs_153" + "nixpkgs": "nixpkgs_152" }, "locked": { "lastModified": 1773955630, @@ -5855,7 +5841,7 @@ }, "logos-nix_152": { "inputs": { - "nixpkgs": "nixpkgs_154" + "nixpkgs": "nixpkgs_153" }, "locked": { "lastModified": 1773955630, @@ -5873,7 +5859,7 @@ }, "logos-nix_153": { "inputs": { - "nixpkgs": "nixpkgs_155" + "nixpkgs": "nixpkgs_154" }, "locked": { "lastModified": 1773955630, @@ -5891,7 +5877,7 @@ }, "logos-nix_154": { "inputs": { - "nixpkgs": "nixpkgs_156" + "nixpkgs": "nixpkgs_155" }, "locked": { "lastModified": 1773955630, @@ -5909,7 +5895,7 @@ }, "logos-nix_155": { "inputs": { - "nixpkgs": "nixpkgs_157" + "nixpkgs": "nixpkgs_156" }, "locked": { "lastModified": 1774455309, @@ -5927,7 +5913,7 @@ }, "logos-nix_156": { "inputs": { - "nixpkgs": "nixpkgs_158" + "nixpkgs": "nixpkgs_157" }, "locked": { "lastModified": 1773955630, @@ -5945,7 +5931,7 @@ }, "logos-nix_157": { "inputs": { - "nixpkgs": "nixpkgs_159" + "nixpkgs": "nixpkgs_158" }, "locked": { "lastModified": 1774455309, @@ -5963,7 +5949,7 @@ }, "logos-nix_158": { "inputs": { - "nixpkgs": "nixpkgs_160" + "nixpkgs": "nixpkgs_159" }, "locked": { "lastModified": 1774455309, @@ -5981,7 +5967,7 @@ }, "logos-nix_159": { "inputs": { - "nixpkgs": "nixpkgs_161" + "nixpkgs": "nixpkgs_160" }, "locked": { "lastModified": 1773955630, @@ -5999,7 +5985,7 @@ }, "logos-nix_16": { "inputs": { - "nixpkgs": "nixpkgs_17" + "nixpkgs": "nixpkgs_16" }, "locked": { "lastModified": 1774455309, @@ -6017,7 +6003,7 @@ }, "logos-nix_160": { "inputs": { - "nixpkgs": "nixpkgs_162" + "nixpkgs": "nixpkgs_161" }, "locked": { "lastModified": 1773955630, @@ -6035,7 +6021,7 @@ }, "logos-nix_161": { "inputs": { - "nixpkgs": "nixpkgs_163" + "nixpkgs": "nixpkgs_162" }, "locked": { "lastModified": 1773955630, @@ -6053,7 +6039,7 @@ }, "logos-nix_162": { "inputs": { - "nixpkgs": "nixpkgs_164" + "nixpkgs": "nixpkgs_163" }, "locked": { "lastModified": 1773955630, @@ -6071,7 +6057,7 @@ }, "logos-nix_163": { "inputs": { - "nixpkgs": "nixpkgs_165" + "nixpkgs": "nixpkgs_164" }, "locked": { "lastModified": 1773955630, @@ -6089,7 +6075,7 @@ }, "logos-nix_164": { "inputs": { - "nixpkgs": "nixpkgs_166" + "nixpkgs": "nixpkgs_165" }, "locked": { "lastModified": 1774455309, @@ -6107,7 +6093,7 @@ }, "logos-nix_165": { "inputs": { - "nixpkgs": "nixpkgs_167" + "nixpkgs": "nixpkgs_166" }, "locked": { "lastModified": 1773955630, @@ -6125,7 +6111,7 @@ }, "logos-nix_166": { "inputs": { - "nixpkgs": "nixpkgs_168" + "nixpkgs": "nixpkgs_167" }, "locked": { "lastModified": 1774455309, @@ -6143,7 +6129,7 @@ }, "logos-nix_167": { "inputs": { - "nixpkgs": "nixpkgs_169" + "nixpkgs": "nixpkgs_168" }, "locked": { "lastModified": 1774455309, @@ -6161,7 +6147,7 @@ }, "logos-nix_168": { "inputs": { - "nixpkgs": "nixpkgs_170" + "nixpkgs": "nixpkgs_169" }, "locked": { "lastModified": 1773955630, @@ -6179,7 +6165,7 @@ }, "logos-nix_169": { "inputs": { - "nixpkgs": "nixpkgs_171" + "nixpkgs": "nixpkgs_170" }, "locked": { "lastModified": 1773955630, @@ -6197,7 +6183,7 @@ }, "logos-nix_17": { "inputs": { - "nixpkgs": "nixpkgs_18" + "nixpkgs": "nixpkgs_17" }, "locked": { "lastModified": 1773955630, @@ -6215,7 +6201,7 @@ }, "logos-nix_170": { "inputs": { - "nixpkgs": "nixpkgs_172" + "nixpkgs": "nixpkgs_171" }, "locked": { "lastModified": 1774455309, @@ -6233,7 +6219,7 @@ }, "logos-nix_171": { "inputs": { - "nixpkgs": "nixpkgs_173" + "nixpkgs": "nixpkgs_172" }, "locked": { "lastModified": 1774455309, @@ -6251,7 +6237,7 @@ }, "logos-nix_172": { "inputs": { - "nixpkgs": "nixpkgs_174" + "nixpkgs": "nixpkgs_173" }, "locked": { "lastModified": 1773955630, @@ -6269,7 +6255,7 @@ }, "logos-nix_173": { "inputs": { - "nixpkgs": "nixpkgs_175" + "nixpkgs": "nixpkgs_174" }, "locked": { "lastModified": 1773955630, @@ -6287,7 +6273,7 @@ }, "logos-nix_174": { "inputs": { - "nixpkgs": "nixpkgs_176" + "nixpkgs": "nixpkgs_175" }, "locked": { "lastModified": 1774455309, @@ -6305,7 +6291,7 @@ }, "logos-nix_175": { "inputs": { - "nixpkgs": "nixpkgs_177" + "nixpkgs": "nixpkgs_176" }, "locked": { "lastModified": 1774455309, @@ -6323,7 +6309,7 @@ }, "logos-nix_176": { "inputs": { - "nixpkgs": "nixpkgs_178" + "nixpkgs": "nixpkgs_177" }, "locked": { "lastModified": 1773955630, @@ -6341,7 +6327,7 @@ }, "logos-nix_177": { "inputs": { - "nixpkgs": "nixpkgs_179" + "nixpkgs": "nixpkgs_178" }, "locked": { "lastModified": 1773955630, @@ -6359,7 +6345,7 @@ }, "logos-nix_178": { "inputs": { - "nixpkgs": "nixpkgs_180" + "nixpkgs": "nixpkgs_179" }, "locked": { "lastModified": 1773955630, @@ -6377,7 +6363,7 @@ }, "logos-nix_179": { "inputs": { - "nixpkgs": "nixpkgs_181" + "nixpkgs": "nixpkgs_180" }, "locked": { "lastModified": 1773955630, @@ -6395,7 +6381,7 @@ }, "logos-nix_18": { "inputs": { - "nixpkgs": "nixpkgs_19" + "nixpkgs": "nixpkgs_18" }, "locked": { "lastModified": 1773955630, @@ -6413,7 +6399,7 @@ }, "logos-nix_180": { "inputs": { - "nixpkgs": "nixpkgs_182" + "nixpkgs": "nixpkgs_181" }, "locked": { "lastModified": 1774455309, @@ -6431,7 +6417,7 @@ }, "logos-nix_181": { "inputs": { - "nixpkgs": "nixpkgs_183" + "nixpkgs": "nixpkgs_182" }, "locked": { "lastModified": 1773955630, @@ -6449,7 +6435,7 @@ }, "logos-nix_182": { "inputs": { - "nixpkgs": "nixpkgs_184" + "nixpkgs": "nixpkgs_183" }, "locked": { "lastModified": 1773955630, @@ -6467,7 +6453,7 @@ }, "logos-nix_183": { "inputs": { - "nixpkgs": "nixpkgs_185" + "nixpkgs": "nixpkgs_184" }, "locked": { "lastModified": 1774455309, @@ -6485,7 +6471,7 @@ }, "logos-nix_184": { "inputs": { - "nixpkgs": "nixpkgs_186" + "nixpkgs": "nixpkgs_185" }, "locked": { "lastModified": 1773955630, @@ -6503,7 +6489,7 @@ }, "logos-nix_185": { "inputs": { - "nixpkgs": "nixpkgs_187" + "nixpkgs": "nixpkgs_186" }, "locked": { "lastModified": 1774455309, @@ -6521,7 +6507,7 @@ }, "logos-nix_186": { "inputs": { - "nixpkgs": "nixpkgs_188" + "nixpkgs": "nixpkgs_187" }, "locked": { "lastModified": 1773955630, @@ -6539,7 +6525,7 @@ }, "logos-nix_187": { "inputs": { - "nixpkgs": "nixpkgs_189" + "nixpkgs": "nixpkgs_188" }, "locked": { "lastModified": 1774455309, @@ -6557,7 +6543,7 @@ }, "logos-nix_188": { "inputs": { - "nixpkgs": "nixpkgs_190" + "nixpkgs": "nixpkgs_189" }, "locked": { "lastModified": 1773955630, @@ -6575,7 +6561,7 @@ }, "logos-nix_189": { "inputs": { - "nixpkgs": "nixpkgs_191" + "nixpkgs": "nixpkgs_190" }, "locked": { "lastModified": 1774455309, @@ -6593,7 +6579,7 @@ }, "logos-nix_19": { "inputs": { - "nixpkgs": "nixpkgs_20" + "nixpkgs": "nixpkgs_19" }, "locked": { "lastModified": 1774455309, @@ -6611,7 +6597,7 @@ }, "logos-nix_190": { "inputs": { - "nixpkgs": "nixpkgs_192" + "nixpkgs": "nixpkgs_191" }, "locked": { "lastModified": 1773955630, @@ -6629,7 +6615,7 @@ }, "logos-nix_191": { "inputs": { - "nixpkgs": "nixpkgs_193" + "nixpkgs": "nixpkgs_192" }, "locked": { "lastModified": 1774455309, @@ -6647,7 +6633,7 @@ }, "logos-nix_192": { "inputs": { - "nixpkgs": "nixpkgs_194" + "nixpkgs": "nixpkgs_193" }, "locked": { "lastModified": 1773955630, @@ -6665,7 +6651,7 @@ }, "logos-nix_193": { "inputs": { - "nixpkgs": "nixpkgs_195" + "nixpkgs": "nixpkgs_194" }, "locked": { "lastModified": 1773955630, @@ -6683,7 +6669,7 @@ }, "logos-nix_194": { "inputs": { - "nixpkgs": "nixpkgs_196" + "nixpkgs": "nixpkgs_195" }, "locked": { "lastModified": 1774455309, @@ -6701,7 +6687,7 @@ }, "logos-nix_195": { "inputs": { - "nixpkgs": "nixpkgs_197" + "nixpkgs": "nixpkgs_196" }, "locked": { "lastModified": 1773955630, @@ -6719,7 +6705,7 @@ }, "logos-nix_196": { "inputs": { - "nixpkgs": "nixpkgs_198" + "nixpkgs": "nixpkgs_197" }, "locked": { "lastModified": 1773955630, @@ -6737,7 +6723,7 @@ }, "logos-nix_197": { "inputs": { - "nixpkgs": "nixpkgs_199" + "nixpkgs": "nixpkgs_198" }, "locked": { "lastModified": 1773955630, @@ -6755,7 +6741,7 @@ }, "logos-nix_198": { "inputs": { - "nixpkgs": "nixpkgs_200" + "nixpkgs": "nixpkgs_199" }, "locked": { "lastModified": 1773955630, @@ -6773,7 +6759,7 @@ }, "logos-nix_199": { "inputs": { - "nixpkgs": "nixpkgs_201" + "nixpkgs": "nixpkgs_200" }, "locked": { "lastModified": 1774455309, @@ -6791,7 +6777,7 @@ }, "logos-nix_2": { "inputs": { - "nixpkgs": "nixpkgs_3" + "nixpkgs": "nixpkgs_2" }, "locked": { "lastModified": 1773955630, @@ -6809,7 +6795,7 @@ }, "logos-nix_20": { "inputs": { - "nixpkgs": "nixpkgs_21" + "nixpkgs": "nixpkgs_20" }, "locked": { "lastModified": 1773955630, @@ -6827,7 +6813,7 @@ }, "logos-nix_200": { "inputs": { - "nixpkgs": "nixpkgs_202" + "nixpkgs": "nixpkgs_201" }, "locked": { "lastModified": 1773955630, @@ -6845,7 +6831,7 @@ }, "logos-nix_201": { "inputs": { - "nixpkgs": "nixpkgs_203" + "nixpkgs": "nixpkgs_202" }, "locked": { "lastModified": 1774455309, @@ -6863,7 +6849,7 @@ }, "logos-nix_202": { "inputs": { - "nixpkgs": "nixpkgs_204" + "nixpkgs": "nixpkgs_203" }, "locked": { "lastModified": 1774455309, @@ -6881,7 +6867,7 @@ }, "logos-nix_203": { "inputs": { - "nixpkgs": "nixpkgs_205" + "nixpkgs": "nixpkgs_204" }, "locked": { "lastModified": 1773955630, @@ -6899,7 +6885,7 @@ }, "logos-nix_204": { "inputs": { - "nixpkgs": "nixpkgs_206" + "nixpkgs": "nixpkgs_205" }, "locked": { "lastModified": 1773955630, @@ -6917,7 +6903,7 @@ }, "logos-nix_205": { "inputs": { - "nixpkgs": "nixpkgs_207" + "nixpkgs": "nixpkgs_206" }, "locked": { "lastModified": 1773955630, @@ -6935,7 +6921,7 @@ }, "logos-nix_206": { "inputs": { - "nixpkgs": "nixpkgs_208" + "nixpkgs": "nixpkgs_207" }, "locked": { "lastModified": 1773955630, @@ -6953,7 +6939,7 @@ }, "logos-nix_207": { "inputs": { - "nixpkgs": "nixpkgs_209" + "nixpkgs": "nixpkgs_208" }, "locked": { "lastModified": 1773955630, @@ -6971,7 +6957,7 @@ }, "logos-nix_208": { "inputs": { - "nixpkgs": "nixpkgs_210" + "nixpkgs": "nixpkgs_209" }, "locked": { "lastModified": 1774455309, @@ -6989,7 +6975,7 @@ }, "logos-nix_209": { "inputs": { - "nixpkgs": "nixpkgs_211" + "nixpkgs": "nixpkgs_210" }, "locked": { "lastModified": 1773955630, @@ -7007,7 +6993,7 @@ }, "logos-nix_21": { "inputs": { - "nixpkgs": "nixpkgs_22" + "nixpkgs": "nixpkgs_21" }, "locked": { "lastModified": 1773955630, @@ -7025,7 +7011,7 @@ }, "logos-nix_210": { "inputs": { - "nixpkgs": "nixpkgs_212" + "nixpkgs": "nixpkgs_211" }, "locked": { "lastModified": 1774455309, @@ -7043,7 +7029,7 @@ }, "logos-nix_211": { "inputs": { - "nixpkgs": "nixpkgs_213" + "nixpkgs": "nixpkgs_212" }, "locked": { "lastModified": 1774455309, @@ -7061,7 +7047,7 @@ }, "logos-nix_212": { "inputs": { - "nixpkgs": "nixpkgs_214" + "nixpkgs": "nixpkgs_213" }, "locked": { "lastModified": 1773955630, @@ -7079,7 +7065,7 @@ }, "logos-nix_213": { "inputs": { - "nixpkgs": "nixpkgs_215" + "nixpkgs": "nixpkgs_214" }, "locked": { "lastModified": 1773955630, @@ -7097,7 +7083,7 @@ }, "logos-nix_214": { "inputs": { - "nixpkgs": "nixpkgs_216" + "nixpkgs": "nixpkgs_215" }, "locked": { "lastModified": 1774455309, @@ -7115,7 +7101,7 @@ }, "logos-nix_215": { "inputs": { - "nixpkgs": "nixpkgs_217" + "nixpkgs": "nixpkgs_216" }, "locked": { "lastModified": 1774455309, @@ -7133,7 +7119,7 @@ }, "logos-nix_216": { "inputs": { - "nixpkgs": "nixpkgs_218" + "nixpkgs": "nixpkgs_217" }, "locked": { "lastModified": 1773955630, @@ -7151,7 +7137,7 @@ }, "logos-nix_217": { "inputs": { - "nixpkgs": "nixpkgs_219" + "nixpkgs": "nixpkgs_218" }, "locked": { "lastModified": 1773955630, @@ -7169,7 +7155,7 @@ }, "logos-nix_218": { "inputs": { - "nixpkgs": "nixpkgs_220" + "nixpkgs": "nixpkgs_219" }, "locked": { "lastModified": 1774455309, @@ -7187,7 +7173,7 @@ }, "logos-nix_219": { "inputs": { - "nixpkgs": "nixpkgs_221" + "nixpkgs": "nixpkgs_220" }, "locked": { "lastModified": 1774455309, @@ -7205,7 +7191,7 @@ }, "logos-nix_22": { "inputs": { - "nixpkgs": "nixpkgs_23" + "nixpkgs": "nixpkgs_22" }, "locked": { "lastModified": 1773955630, @@ -7223,7 +7209,7 @@ }, "logos-nix_220": { "inputs": { - "nixpkgs": "nixpkgs_222" + "nixpkgs": "nixpkgs_221" }, "locked": { "lastModified": 1773955630, @@ -7241,7 +7227,7 @@ }, "logos-nix_221": { "inputs": { - "nixpkgs": "nixpkgs_223" + "nixpkgs": "nixpkgs_222" }, "locked": { "lastModified": 1773955630, @@ -7259,7 +7245,7 @@ }, "logos-nix_222": { "inputs": { - "nixpkgs": "nixpkgs_224" + "nixpkgs": "nixpkgs_223" }, "locked": { "lastModified": 1773955630, @@ -7277,7 +7263,7 @@ }, "logos-nix_223": { "inputs": { - "nixpkgs": "nixpkgs_225" + "nixpkgs": "nixpkgs_224" }, "locked": { "lastModified": 1773955630, @@ -7295,7 +7281,7 @@ }, "logos-nix_224": { "inputs": { - "nixpkgs": "nixpkgs_226" + "nixpkgs": "nixpkgs_225" }, "locked": { "lastModified": 1774455309, @@ -7313,7 +7299,7 @@ }, "logos-nix_225": { "inputs": { - "nixpkgs": "nixpkgs_227" + "nixpkgs": "nixpkgs_226" }, "locked": { "lastModified": 1773955630, @@ -7331,7 +7317,7 @@ }, "logos-nix_226": { "inputs": { - "nixpkgs": "nixpkgs_228" + "nixpkgs": "nixpkgs_227" }, "locked": { "lastModified": 1773955630, @@ -7349,7 +7335,7 @@ }, "logos-nix_227": { "inputs": { - "nixpkgs": "nixpkgs_229" + "nixpkgs": "nixpkgs_228" }, "locked": { "lastModified": 1774455309, @@ -7367,7 +7353,7 @@ }, "logos-nix_228": { "inputs": { - "nixpkgs": "nixpkgs_230" + "nixpkgs": "nixpkgs_229" }, "locked": { "lastModified": 1773955630, @@ -7385,7 +7371,7 @@ }, "logos-nix_229": { "inputs": { - "nixpkgs": "nixpkgs_231" + "nixpkgs": "nixpkgs_230" }, "locked": { "lastModified": 1774455309, @@ -7403,7 +7389,7 @@ }, "logos-nix_23": { "inputs": { - "nixpkgs": "nixpkgs_24" + "nixpkgs": "nixpkgs_23" }, "locked": { "lastModified": 1773955630, @@ -7421,7 +7407,7 @@ }, "logos-nix_230": { "inputs": { - "nixpkgs": "nixpkgs_232" + "nixpkgs": "nixpkgs_231" }, "locked": { "lastModified": 1774455309, @@ -7439,7 +7425,7 @@ }, "logos-nix_231": { "inputs": { - "nixpkgs": "nixpkgs_233" + "nixpkgs": "nixpkgs_232" }, "locked": { "lastModified": 1773955630, @@ -7457,7 +7443,7 @@ }, "logos-nix_232": { "inputs": { - "nixpkgs": "nixpkgs_234" + "nixpkgs": "nixpkgs_233" }, "locked": { "lastModified": 1773955630, @@ -7475,7 +7461,7 @@ }, "logos-nix_233": { "inputs": { - "nixpkgs": "nixpkgs_235" + "nixpkgs": "nixpkgs_234" }, "locked": { "lastModified": 1773955630, @@ -7493,7 +7479,7 @@ }, "logos-nix_234": { "inputs": { - "nixpkgs": "nixpkgs_236" + "nixpkgs": "nixpkgs_235" }, "locked": { "lastModified": 1773955630, @@ -7511,7 +7497,7 @@ }, "logos-nix_235": { "inputs": { - "nixpkgs": "nixpkgs_237" + "nixpkgs": "nixpkgs_236" }, "locked": { "lastModified": 1773955630, @@ -7529,7 +7515,7 @@ }, "logos-nix_236": { "inputs": { - "nixpkgs": "nixpkgs_238" + "nixpkgs": "nixpkgs_237" }, "locked": { "lastModified": 1774455309, @@ -7547,7 +7533,7 @@ }, "logos-nix_237": { "inputs": { - "nixpkgs": "nixpkgs_239" + "nixpkgs": "nixpkgs_238" }, "locked": { "lastModified": 1773955630, @@ -7565,7 +7551,7 @@ }, "logos-nix_238": { "inputs": { - "nixpkgs": "nixpkgs_240" + "nixpkgs": "nixpkgs_239" }, "locked": { "lastModified": 1774455309, @@ -7583,7 +7569,7 @@ }, "logos-nix_239": { "inputs": { - "nixpkgs": "nixpkgs_241" + "nixpkgs": "nixpkgs_240" }, "locked": { "lastModified": 1774455309, @@ -7601,7 +7587,7 @@ }, "logos-nix_24": { "inputs": { - "nixpkgs": "nixpkgs_25" + "nixpkgs": "nixpkgs_24" }, "locked": { "lastModified": 1774455309, @@ -7619,7 +7605,7 @@ }, "logos-nix_240": { "inputs": { - "nixpkgs": "nixpkgs_242" + "nixpkgs": "nixpkgs_241" }, "locked": { "lastModified": 1773955630, @@ -7637,7 +7623,7 @@ }, "logos-nix_241": { "inputs": { - "nixpkgs": "nixpkgs_243" + "nixpkgs": "nixpkgs_242" }, "locked": { "lastModified": 1773955630, @@ -7655,7 +7641,7 @@ }, "logos-nix_242": { "inputs": { - "nixpkgs": "nixpkgs_244" + "nixpkgs": "nixpkgs_243" }, "locked": { "lastModified": 1774455309, @@ -7673,7 +7659,7 @@ }, "logos-nix_243": { "inputs": { - "nixpkgs": "nixpkgs_245" + "nixpkgs": "nixpkgs_244" }, "locked": { "lastModified": 1774455309, @@ -7691,7 +7677,7 @@ }, "logos-nix_244": { "inputs": { - "nixpkgs": "nixpkgs_246" + "nixpkgs": "nixpkgs_245" }, "locked": { "lastModified": 1773955630, @@ -7709,7 +7695,7 @@ }, "logos-nix_245": { "inputs": { - "nixpkgs": "nixpkgs_247" + "nixpkgs": "nixpkgs_246" }, "locked": { "lastModified": 1773955630, @@ -7727,7 +7713,7 @@ }, "logos-nix_246": { "inputs": { - "nixpkgs": "nixpkgs_248" + "nixpkgs": "nixpkgs_247" }, "locked": { "lastModified": 1774455309, @@ -7745,7 +7731,7 @@ }, "logos-nix_247": { "inputs": { - "nixpkgs": "nixpkgs_249" + "nixpkgs": "nixpkgs_248" }, "locked": { "lastModified": 1774455309, @@ -7763,7 +7749,7 @@ }, "logos-nix_248": { "inputs": { - "nixpkgs": "nixpkgs_250" + "nixpkgs": "nixpkgs_249" }, "locked": { "lastModified": 1773955630, @@ -7781,7 +7767,7 @@ }, "logos-nix_249": { "inputs": { - "nixpkgs": "nixpkgs_251" + "nixpkgs": "nixpkgs_250" }, "locked": { "lastModified": 1773955630, @@ -7799,7 +7785,7 @@ }, "logos-nix_25": { "inputs": { - "nixpkgs": "nixpkgs_26" + "nixpkgs": "nixpkgs_25" }, "locked": { "lastModified": 1773955630, @@ -7817,7 +7803,7 @@ }, "logos-nix_250": { "inputs": { - "nixpkgs": "nixpkgs_252" + "nixpkgs": "nixpkgs_251" }, "locked": { "lastModified": 1773955630, @@ -7835,7 +7821,7 @@ }, "logos-nix_251": { "inputs": { - "nixpkgs": "nixpkgs_253" + "nixpkgs": "nixpkgs_252" }, "locked": { "lastModified": 1773955630, @@ -7853,7 +7839,7 @@ }, "logos-nix_252": { "inputs": { - "nixpkgs": "nixpkgs_254" + "nixpkgs": "nixpkgs_253" }, "locked": { "lastModified": 1774455309, @@ -7871,7 +7857,7 @@ }, "logos-nix_253": { "inputs": { - "nixpkgs": "nixpkgs_255" + "nixpkgs": "nixpkgs_254" }, "locked": { "lastModified": 1773955630, @@ -7889,7 +7875,7 @@ }, "logos-nix_254": { "inputs": { - "nixpkgs": "nixpkgs_256" + "nixpkgs": "nixpkgs_255" }, "locked": { "lastModified": 1773955630, @@ -7907,7 +7893,7 @@ }, "logos-nix_255": { "inputs": { - "nixpkgs": "nixpkgs_257" + "nixpkgs": "nixpkgs_256" }, "locked": { "lastModified": 1774455309, @@ -7925,7 +7911,7 @@ }, "logos-nix_256": { "inputs": { - "nixpkgs": "nixpkgs_258" + "nixpkgs": "nixpkgs_257" }, "locked": { "lastModified": 1774455309, @@ -7943,7 +7929,7 @@ }, "logos-nix_257": { "inputs": { - "nixpkgs": "nixpkgs_259" + "nixpkgs": "nixpkgs_258" }, "locked": { "lastModified": 1773955630, @@ -7961,7 +7947,7 @@ }, "logos-nix_258": { "inputs": { - "nixpkgs": "nixpkgs_260" + "nixpkgs": "nixpkgs_259" }, "locked": { "lastModified": 1774455309, @@ -7979,7 +7965,7 @@ }, "logos-nix_259": { "inputs": { - "nixpkgs": "nixpkgs_261" + "nixpkgs": "nixpkgs_260" }, "locked": { "lastModified": 1774455309, @@ -7997,7 +7983,7 @@ }, "logos-nix_26": { "inputs": { - "nixpkgs": "nixpkgs_27" + "nixpkgs": "nixpkgs_26" }, "locked": { "lastModified": 1774455309, @@ -8015,7 +8001,7 @@ }, "logos-nix_260": { "inputs": { - "nixpkgs": "nixpkgs_262" + "nixpkgs": "nixpkgs_261" }, "locked": { "lastModified": 1774455309, @@ -8033,7 +8019,7 @@ }, "logos-nix_261": { "inputs": { - "nixpkgs": "nixpkgs_263" + "nixpkgs": "nixpkgs_262" }, "locked": { "lastModified": 1774455309, @@ -8051,7 +8037,7 @@ }, "logos-nix_262": { "inputs": { - "nixpkgs": "nixpkgs_264" + "nixpkgs": "nixpkgs_263" }, "locked": { "lastModified": 1773955630, @@ -8069,7 +8055,7 @@ }, "logos-nix_263": { "inputs": { - "nixpkgs": "nixpkgs_265" + "nixpkgs": "nixpkgs_264" }, "locked": { "lastModified": 1773955630, @@ -8087,7 +8073,7 @@ }, "logos-nix_264": { "inputs": { - "nixpkgs": "nixpkgs_266" + "nixpkgs": "nixpkgs_265" }, "locked": { "lastModified": 1773955630, @@ -8105,7 +8091,7 @@ }, "logos-nix_265": { "inputs": { - "nixpkgs": "nixpkgs_267" + "nixpkgs": "nixpkgs_266" }, "locked": { "lastModified": 1773955630, @@ -8123,7 +8109,7 @@ }, "logos-nix_266": { "inputs": { - "nixpkgs": "nixpkgs_268" + "nixpkgs": "nixpkgs_267" }, "locked": { "lastModified": 1774455309, @@ -8141,7 +8127,7 @@ }, "logos-nix_267": { "inputs": { - "nixpkgs": "nixpkgs_269" + "nixpkgs": "nixpkgs_268" }, "locked": { "lastModified": 1774455309, @@ -8159,7 +8145,7 @@ }, "logos-nix_268": { "inputs": { - "nixpkgs": "nixpkgs_270" + "nixpkgs": "nixpkgs_269" }, "locked": { "lastModified": 1773955630, @@ -8177,7 +8163,7 @@ }, "logos-nix_269": { "inputs": { - "nixpkgs": "nixpkgs_272" + "nixpkgs": "nixpkgs_271" }, "locked": { "lastModified": 1774455309, @@ -8195,7 +8181,7 @@ }, "logos-nix_27": { "inputs": { - "nixpkgs": "nixpkgs_28" + "nixpkgs": "nixpkgs_27" }, "locked": { "lastModified": 1774455309, @@ -8213,7 +8199,7 @@ }, "logos-nix_270": { "inputs": { - "nixpkgs": "nixpkgs_273" + "nixpkgs": "nixpkgs_272" }, "locked": { "lastModified": 1773955630, @@ -8231,7 +8217,7 @@ }, "logos-nix_271": { "inputs": { - "nixpkgs": "nixpkgs_274" + "nixpkgs": "nixpkgs_273" }, "locked": { "lastModified": 1774455309, @@ -8249,7 +8235,7 @@ }, "logos-nix_272": { "inputs": { - "nixpkgs": "nixpkgs_275" + "nixpkgs": "nixpkgs_274" }, "locked": { "lastModified": 1773955630, @@ -8267,7 +8253,7 @@ }, "logos-nix_273": { "inputs": { - "nixpkgs": "nixpkgs_276" + "nixpkgs": "nixpkgs_275" }, "locked": { "lastModified": 1774455309, @@ -8285,7 +8271,7 @@ }, "logos-nix_274": { "inputs": { - "nixpkgs": "nixpkgs_277" + "nixpkgs": "nixpkgs_276" }, "locked": { "lastModified": 1773955630, @@ -8303,7 +8289,7 @@ }, "logos-nix_275": { "inputs": { - "nixpkgs": "nixpkgs_278" + "nixpkgs": "nixpkgs_277" }, "locked": { "lastModified": 1774455309, @@ -8321,7 +8307,7 @@ }, "logos-nix_276": { "inputs": { - "nixpkgs": "nixpkgs_279" + "nixpkgs": "nixpkgs_278" }, "locked": { "lastModified": 1774455309, @@ -8339,7 +8325,7 @@ }, "logos-nix_277": { "inputs": { - "nixpkgs": "nixpkgs_280" + "nixpkgs": "nixpkgs_279" }, "locked": { "lastModified": 1774455309, @@ -8357,7 +8343,7 @@ }, "logos-nix_278": { "inputs": { - "nixpkgs": "nixpkgs_281" + "nixpkgs": "nixpkgs_280" }, "locked": { "lastModified": 1774455309, @@ -8375,7 +8361,7 @@ }, "logos-nix_279": { "inputs": { - "nixpkgs": "nixpkgs_282" + "nixpkgs": "nixpkgs_281" }, "locked": { "lastModified": 1773955630, @@ -8393,7 +8379,7 @@ }, "logos-nix_28": { "inputs": { - "nixpkgs": "nixpkgs_29" + "nixpkgs": "nixpkgs_28" }, "locked": { "lastModified": 1773955630, @@ -8411,7 +8397,7 @@ }, "logos-nix_280": { "inputs": { - "nixpkgs": "nixpkgs_283" + "nixpkgs": "nixpkgs_282" }, "locked": { "lastModified": 1774455309, @@ -8429,7 +8415,7 @@ }, "logos-nix_281": { "inputs": { - "nixpkgs": "nixpkgs_284" + "nixpkgs": "nixpkgs_283" }, "locked": { "lastModified": 1773955630, @@ -8447,7 +8433,7 @@ }, "logos-nix_282": { "inputs": { - "nixpkgs": "nixpkgs_285" + "nixpkgs": "nixpkgs_284" }, "locked": { "lastModified": 1774455309, @@ -8465,7 +8451,7 @@ }, "logos-nix_283": { "inputs": { - "nixpkgs": "nixpkgs_286" + "nixpkgs": "nixpkgs_285" }, "locked": { "lastModified": 1773955630, @@ -8483,7 +8469,7 @@ }, "logos-nix_284": { "inputs": { - "nixpkgs": "nixpkgs_287" + "nixpkgs": "nixpkgs_286" }, "locked": { "lastModified": 1774455309, @@ -8501,7 +8487,7 @@ }, "logos-nix_285": { "inputs": { - "nixpkgs": "nixpkgs_288" + "nixpkgs": "nixpkgs_287" }, "locked": { "lastModified": 1773955630, @@ -8519,7 +8505,7 @@ }, "logos-nix_286": { "inputs": { - "nixpkgs": "nixpkgs_289" + "nixpkgs": "nixpkgs_288" }, "locked": { "lastModified": 1773955630, @@ -8537,7 +8523,7 @@ }, "logos-nix_287": { "inputs": { - "nixpkgs": "nixpkgs_290" + "nixpkgs": "nixpkgs_289" }, "locked": { "lastModified": 1774455309, @@ -8555,7 +8541,7 @@ }, "logos-nix_288": { "inputs": { - "nixpkgs": "nixpkgs_291" + "nixpkgs": "nixpkgs_290" }, "locked": { "lastModified": 1773955630, @@ -8573,7 +8559,7 @@ }, "logos-nix_289": { "inputs": { - "nixpkgs": "nixpkgs_292" + "nixpkgs": "nixpkgs_291" }, "locked": { "lastModified": 1773955630, @@ -8591,7 +8577,7 @@ }, "logos-nix_29": { "inputs": { - "nixpkgs": "nixpkgs_30" + "nixpkgs": "nixpkgs_29" }, "locked": { "lastModified": 1773955630, @@ -8609,7 +8595,7 @@ }, "logos-nix_290": { "inputs": { - "nixpkgs": "nixpkgs_293" + "nixpkgs": "nixpkgs_292" }, "locked": { "lastModified": 1773955630, @@ -8627,7 +8613,7 @@ }, "logos-nix_291": { "inputs": { - "nixpkgs": "nixpkgs_294" + "nixpkgs": "nixpkgs_293" }, "locked": { "lastModified": 1773955630, @@ -8645,7 +8631,7 @@ }, "logos-nix_292": { "inputs": { - "nixpkgs": "nixpkgs_295" + "nixpkgs": "nixpkgs_294" }, "locked": { "lastModified": 1774455309, @@ -8663,7 +8649,7 @@ }, "logos-nix_293": { "inputs": { - "nixpkgs": "nixpkgs_296" + "nixpkgs": "nixpkgs_295" }, "locked": { "lastModified": 1773955630, @@ -8681,7 +8667,7 @@ }, "logos-nix_294": { "inputs": { - "nixpkgs": "nixpkgs_297" + "nixpkgs": "nixpkgs_296" }, "locked": { "lastModified": 1774455309, @@ -8699,7 +8685,7 @@ }, "logos-nix_295": { "inputs": { - "nixpkgs": "nixpkgs_298" + "nixpkgs": "nixpkgs_297" }, "locked": { "lastModified": 1774455309, @@ -8717,7 +8703,7 @@ }, "logos-nix_296": { "inputs": { - "nixpkgs": "nixpkgs_299" + "nixpkgs": "nixpkgs_298" }, "locked": { "lastModified": 1773955630, @@ -8735,7 +8721,7 @@ }, "logos-nix_297": { "inputs": { - "nixpkgs": "nixpkgs_300" + "nixpkgs": "nixpkgs_299" }, "locked": { "lastModified": 1773955630, @@ -8753,7 +8739,7 @@ }, "logos-nix_298": { "inputs": { - "nixpkgs": "nixpkgs_301" + "nixpkgs": "nixpkgs_300" }, "locked": { "lastModified": 1773955630, @@ -8771,7 +8757,7 @@ }, "logos-nix_299": { "inputs": { - "nixpkgs": "nixpkgs_302" + "nixpkgs": "nixpkgs_301" }, "locked": { "lastModified": 1773955630, @@ -8789,7 +8775,7 @@ }, "logos-nix_3": { "inputs": { - "nixpkgs": "nixpkgs_4" + "nixpkgs": "nixpkgs_3" }, "locked": { "lastModified": 1774455309, @@ -8807,7 +8793,7 @@ }, "logos-nix_30": { "inputs": { - "nixpkgs": "nixpkgs_31" + "nixpkgs": "nixpkgs_30" }, "locked": { "lastModified": 1773955630, @@ -8825,7 +8811,7 @@ }, "logos-nix_300": { "inputs": { - "nixpkgs": "nixpkgs_303" + "nixpkgs": "nixpkgs_302" }, "locked": { "lastModified": 1773955630, @@ -8843,7 +8829,7 @@ }, "logos-nix_301": { "inputs": { - "nixpkgs": "nixpkgs_304" + "nixpkgs": "nixpkgs_303" }, "locked": { "lastModified": 1774455309, @@ -8861,7 +8847,7 @@ }, "logos-nix_302": { "inputs": { - "nixpkgs": "nixpkgs_305" + "nixpkgs": "nixpkgs_304" }, "locked": { "lastModified": 1773955630, @@ -8879,7 +8865,7 @@ }, "logos-nix_303": { "inputs": { - "nixpkgs": "nixpkgs_306" + "nixpkgs": "nixpkgs_305" }, "locked": { "lastModified": 1774455309, @@ -8897,7 +8883,7 @@ }, "logos-nix_304": { "inputs": { - "nixpkgs": "nixpkgs_307" + "nixpkgs": "nixpkgs_306" }, "locked": { "lastModified": 1774455309, @@ -8915,7 +8901,7 @@ }, "logos-nix_305": { "inputs": { - "nixpkgs": "nixpkgs_308" + "nixpkgs": "nixpkgs_307" }, "locked": { "lastModified": 1773955630, @@ -8933,7 +8919,7 @@ }, "logos-nix_306": { "inputs": { - "nixpkgs": "nixpkgs_309" + "nixpkgs": "nixpkgs_308" }, "locked": { "lastModified": 1773955630, @@ -8951,7 +8937,7 @@ }, "logos-nix_307": { "inputs": { - "nixpkgs": "nixpkgs_310" + "nixpkgs": "nixpkgs_309" }, "locked": { "lastModified": 1774455309, @@ -8969,7 +8955,7 @@ }, "logos-nix_308": { "inputs": { - "nixpkgs": "nixpkgs_311" + "nixpkgs": "nixpkgs_310" }, "locked": { "lastModified": 1774455309, @@ -8987,7 +8973,7 @@ }, "logos-nix_309": { "inputs": { - "nixpkgs": "nixpkgs_312" + "nixpkgs": "nixpkgs_311" }, "locked": { "lastModified": 1773955630, @@ -9005,7 +8991,7 @@ }, "logos-nix_31": { "inputs": { - "nixpkgs": "nixpkgs_32" + "nixpkgs": "nixpkgs_31" }, "locked": { "lastModified": 1773955630, @@ -9023,7 +9009,7 @@ }, "logos-nix_310": { "inputs": { - "nixpkgs": "nixpkgs_313" + "nixpkgs": "nixpkgs_312" }, "locked": { "lastModified": 1773955630, @@ -9041,7 +9027,7 @@ }, "logos-nix_311": { "inputs": { - "nixpkgs": "nixpkgs_314" + "nixpkgs": "nixpkgs_313" }, "locked": { "lastModified": 1774455309, @@ -9059,7 +9045,7 @@ }, "logos-nix_312": { "inputs": { - "nixpkgs": "nixpkgs_315" + "nixpkgs": "nixpkgs_314" }, "locked": { "lastModified": 1774455309, @@ -9077,7 +9063,7 @@ }, "logos-nix_313": { "inputs": { - "nixpkgs": "nixpkgs_316" + "nixpkgs": "nixpkgs_315" }, "locked": { "lastModified": 1773955630, @@ -9095,7 +9081,7 @@ }, "logos-nix_314": { "inputs": { - "nixpkgs": "nixpkgs_317" + "nixpkgs": "nixpkgs_316" }, "locked": { "lastModified": 1773955630, @@ -9113,7 +9099,7 @@ }, "logos-nix_315": { "inputs": { - "nixpkgs": "nixpkgs_318" + "nixpkgs": "nixpkgs_317" }, "locked": { "lastModified": 1773955630, @@ -9131,7 +9117,7 @@ }, "logos-nix_316": { "inputs": { - "nixpkgs": "nixpkgs_319" + "nixpkgs": "nixpkgs_318" }, "locked": { "lastModified": 1773955630, @@ -9149,7 +9135,7 @@ }, "logos-nix_317": { "inputs": { - "nixpkgs": "nixpkgs_320" + "nixpkgs": "nixpkgs_319" }, "locked": { "lastModified": 1774455309, @@ -9167,7 +9153,7 @@ }, "logos-nix_318": { "inputs": { - "nixpkgs": "nixpkgs_321" + "nixpkgs": "nixpkgs_320" }, "locked": { "lastModified": 1773955630, @@ -9185,7 +9171,7 @@ }, "logos-nix_319": { "inputs": { - "nixpkgs": "nixpkgs_322" + "nixpkgs": "nixpkgs_321" }, "locked": { "lastModified": 1773955630, @@ -9203,7 +9189,7 @@ }, "logos-nix_32": { "inputs": { - "nixpkgs": "nixpkgs_33" + "nixpkgs": "nixpkgs_32" }, "locked": { "lastModified": 1773955630, @@ -9221,7 +9207,7 @@ }, "logos-nix_320": { "inputs": { - "nixpkgs": "nixpkgs_323" + "nixpkgs": "nixpkgs_322" }, "locked": { "lastModified": 1774455309, @@ -9239,7 +9225,7 @@ }, "logos-nix_321": { "inputs": { - "nixpkgs": "nixpkgs_324" + "nixpkgs": "nixpkgs_323" }, "locked": { "lastModified": 1773955630, @@ -9257,7 +9243,7 @@ }, "logos-nix_322": { "inputs": { - "nixpkgs": "nixpkgs_325" + "nixpkgs": "nixpkgs_324" }, "locked": { "lastModified": 1774455309, @@ -9275,7 +9261,7 @@ }, "logos-nix_323": { "inputs": { - "nixpkgs": "nixpkgs_326" + "nixpkgs": "nixpkgs_325" }, "locked": { "lastModified": 1773955630, @@ -9293,7 +9279,7 @@ }, "logos-nix_324": { "inputs": { - "nixpkgs": "nixpkgs_327" + "nixpkgs": "nixpkgs_326" }, "locked": { "lastModified": 1774455309, @@ -9311,7 +9297,7 @@ }, "logos-nix_325": { "inputs": { - "nixpkgs": "nixpkgs_328" + "nixpkgs": "nixpkgs_327" }, "locked": { "lastModified": 1773955630, @@ -9329,7 +9315,7 @@ }, "logos-nix_326": { "inputs": { - "nixpkgs": "nixpkgs_329" + "nixpkgs": "nixpkgs_328" }, "locked": { "lastModified": 1774455309, @@ -9347,7 +9333,7 @@ }, "logos-nix_327": { "inputs": { - "nixpkgs": "nixpkgs_330" + "nixpkgs": "nixpkgs_329" }, "locked": { "lastModified": 1773955630, @@ -9365,7 +9351,7 @@ }, "logos-nix_328": { "inputs": { - "nixpkgs": "nixpkgs_331" + "nixpkgs": "nixpkgs_330" }, "locked": { "lastModified": 1774455309, @@ -9383,7 +9369,7 @@ }, "logos-nix_329": { "inputs": { - "nixpkgs": "nixpkgs_332" + "nixpkgs": "nixpkgs_331" }, "locked": { "lastModified": 1773955630, @@ -9401,7 +9387,7 @@ }, "logos-nix_33": { "inputs": { - "nixpkgs": "nixpkgs_34" + "nixpkgs": "nixpkgs_33" }, "locked": { "lastModified": 1774455309, @@ -9419,7 +9405,7 @@ }, "logos-nix_330": { "inputs": { - "nixpkgs": "nixpkgs_333" + "nixpkgs": "nixpkgs_332" }, "locked": { "lastModified": 1773955630, @@ -9437,7 +9423,7 @@ }, "logos-nix_331": { "inputs": { - "nixpkgs": "nixpkgs_334" + "nixpkgs": "nixpkgs_333" }, "locked": { "lastModified": 1774455309, @@ -9455,7 +9441,7 @@ }, "logos-nix_332": { "inputs": { - "nixpkgs": "nixpkgs_335" + "nixpkgs": "nixpkgs_334" }, "locked": { "lastModified": 1773955630, @@ -9473,7 +9459,7 @@ }, "logos-nix_333": { "inputs": { - "nixpkgs": "nixpkgs_336" + "nixpkgs": "nixpkgs_335" }, "locked": { "lastModified": 1773955630, @@ -9491,7 +9477,7 @@ }, "logos-nix_334": { "inputs": { - "nixpkgs": "nixpkgs_337" + "nixpkgs": "nixpkgs_336" }, "locked": { "lastModified": 1773955630, @@ -9509,7 +9495,7 @@ }, "logos-nix_335": { "inputs": { - "nixpkgs": "nixpkgs_338" + "nixpkgs": "nixpkgs_337" }, "locked": { "lastModified": 1773955630, @@ -9527,7 +9513,7 @@ }, "logos-nix_336": { "inputs": { - "nixpkgs": "nixpkgs_339" + "nixpkgs": "nixpkgs_338" }, "locked": { "lastModified": 1774455309, @@ -9545,7 +9531,7 @@ }, "logos-nix_337": { "inputs": { - "nixpkgs": "nixpkgs_340" + "nixpkgs": "nixpkgs_339" }, "locked": { "lastModified": 1773955630, @@ -9563,7 +9549,7 @@ }, "logos-nix_338": { "inputs": { - "nixpkgs": "nixpkgs_341" + "nixpkgs": "nixpkgs_340" }, "locked": { "lastModified": 1774455309, @@ -9581,7 +9567,7 @@ }, "logos-nix_339": { "inputs": { - "nixpkgs": "nixpkgs_342" + "nixpkgs": "nixpkgs_341" }, "locked": { "lastModified": 1774455309, @@ -9599,7 +9585,7 @@ }, "logos-nix_34": { "inputs": { - "nixpkgs": "nixpkgs_35" + "nixpkgs": "nixpkgs_34" }, "locked": { "lastModified": 1773955630, @@ -9617,7 +9603,7 @@ }, "logos-nix_340": { "inputs": { - "nixpkgs": "nixpkgs_343" + "nixpkgs": "nixpkgs_342" }, "locked": { "lastModified": 1773955630, @@ -9635,7 +9621,7 @@ }, "logos-nix_341": { "inputs": { - "nixpkgs": "nixpkgs_344" + "nixpkgs": "nixpkgs_343" }, "locked": { "lastModified": 1773955630, @@ -9653,7 +9639,7 @@ }, "logos-nix_342": { "inputs": { - "nixpkgs": "nixpkgs_345" + "nixpkgs": "nixpkgs_344" }, "locked": { "lastModified": 1773955630, @@ -9671,7 +9657,7 @@ }, "logos-nix_343": { "inputs": { - "nixpkgs": "nixpkgs_346" + "nixpkgs": "nixpkgs_345" }, "locked": { "lastModified": 1773955630, @@ -9689,7 +9675,7 @@ }, "logos-nix_344": { "inputs": { - "nixpkgs": "nixpkgs_347" + "nixpkgs": "nixpkgs_346" }, "locked": { "lastModified": 1773955630, @@ -9707,7 +9693,7 @@ }, "logos-nix_345": { "inputs": { - "nixpkgs": "nixpkgs_348" + "nixpkgs": "nixpkgs_347" }, "locked": { "lastModified": 1774455309, @@ -9725,7 +9711,7 @@ }, "logos-nix_346": { "inputs": { - "nixpkgs": "nixpkgs_349" + "nixpkgs": "nixpkgs_348" }, "locked": { "lastModified": 1773955630, @@ -9743,7 +9729,7 @@ }, "logos-nix_347": { "inputs": { - "nixpkgs": "nixpkgs_350" + "nixpkgs": "nixpkgs_349" }, "locked": { "lastModified": 1774455309, @@ -9761,7 +9747,7 @@ }, "logos-nix_348": { "inputs": { - "nixpkgs": "nixpkgs_351" + "nixpkgs": "nixpkgs_350" }, "locked": { "lastModified": 1774455309, @@ -9779,7 +9765,7 @@ }, "logos-nix_349": { "inputs": { - "nixpkgs": "nixpkgs_352" + "nixpkgs": "nixpkgs_351" }, "locked": { "lastModified": 1773955630, @@ -9797,7 +9783,7 @@ }, "logos-nix_35": { "inputs": { - "nixpkgs": "nixpkgs_36" + "nixpkgs": "nixpkgs_35" }, "locked": { "lastModified": 1774455309, @@ -9815,7 +9801,7 @@ }, "logos-nix_350": { "inputs": { - "nixpkgs": "nixpkgs_353" + "nixpkgs": "nixpkgs_352" }, "locked": { "lastModified": 1773955630, @@ -9833,7 +9819,7 @@ }, "logos-nix_351": { "inputs": { - "nixpkgs": "nixpkgs_354" + "nixpkgs": "nixpkgs_353" }, "locked": { "lastModified": 1774455309, @@ -9851,7 +9837,7 @@ }, "logos-nix_352": { "inputs": { - "nixpkgs": "nixpkgs_355" + "nixpkgs": "nixpkgs_354" }, "locked": { "lastModified": 1774455309, @@ -9869,7 +9855,7 @@ }, "logos-nix_353": { "inputs": { - "nixpkgs": "nixpkgs_356" + "nixpkgs": "nixpkgs_355" }, "locked": { "lastModified": 1773955630, @@ -9887,7 +9873,7 @@ }, "logos-nix_354": { "inputs": { - "nixpkgs": "nixpkgs_357" + "nixpkgs": "nixpkgs_356" }, "locked": { "lastModified": 1773955630, @@ -9905,7 +9891,7 @@ }, "logos-nix_355": { "inputs": { - "nixpkgs": "nixpkgs_358" + "nixpkgs": "nixpkgs_357" }, "locked": { "lastModified": 1774455309, @@ -9923,7 +9909,7 @@ }, "logos-nix_356": { "inputs": { - "nixpkgs": "nixpkgs_359" + "nixpkgs": "nixpkgs_358" }, "locked": { "lastModified": 1774455309, @@ -9941,7 +9927,7 @@ }, "logos-nix_357": { "inputs": { - "nixpkgs": "nixpkgs_360" + "nixpkgs": "nixpkgs_359" }, "locked": { "lastModified": 1773955630, @@ -9959,7 +9945,7 @@ }, "logos-nix_358": { "inputs": { - "nixpkgs": "nixpkgs_361" + "nixpkgs": "nixpkgs_360" }, "locked": { "lastModified": 1773955630, @@ -9977,7 +9963,7 @@ }, "logos-nix_359": { "inputs": { - "nixpkgs": "nixpkgs_362" + "nixpkgs": "nixpkgs_361" }, "locked": { "lastModified": 1773955630, @@ -9995,7 +9981,7 @@ }, "logos-nix_36": { "inputs": { - "nixpkgs": "nixpkgs_37" + "nixpkgs": "nixpkgs_36" }, "locked": { "lastModified": 1774455309, @@ -10013,7 +9999,7 @@ }, "logos-nix_360": { "inputs": { - "nixpkgs": "nixpkgs_363" + "nixpkgs": "nixpkgs_362" }, "locked": { "lastModified": 1773955630, @@ -10031,7 +10017,7 @@ }, "logos-nix_361": { "inputs": { - "nixpkgs": "nixpkgs_364" + "nixpkgs": "nixpkgs_363" }, "locked": { "lastModified": 1774455309, @@ -10049,7 +10035,7 @@ }, "logos-nix_362": { "inputs": { - "nixpkgs": "nixpkgs_365" + "nixpkgs": "nixpkgs_364" }, "locked": { "lastModified": 1773955630, @@ -10067,7 +10053,7 @@ }, "logos-nix_363": { "inputs": { - "nixpkgs": "nixpkgs_366" + "nixpkgs": "nixpkgs_365" }, "locked": { "lastModified": 1773955630, @@ -10085,7 +10071,7 @@ }, "logos-nix_364": { "inputs": { - "nixpkgs": "nixpkgs_367" + "nixpkgs": "nixpkgs_366" }, "locked": { "lastModified": 1774455309, @@ -10103,7 +10089,7 @@ }, "logos-nix_365": { "inputs": { - "nixpkgs": "nixpkgs_368" + "nixpkgs": "nixpkgs_367" }, "locked": { "lastModified": 1773955630, @@ -10121,7 +10107,7 @@ }, "logos-nix_366": { "inputs": { - "nixpkgs": "nixpkgs_369" + "nixpkgs": "nixpkgs_368" }, "locked": { "lastModified": 1774455309, @@ -10139,7 +10125,7 @@ }, "logos-nix_367": { "inputs": { - "nixpkgs": "nixpkgs_370" + "nixpkgs": "nixpkgs_369" }, "locked": { "lastModified": 1774455309, @@ -10157,7 +10143,7 @@ }, "logos-nix_368": { "inputs": { - "nixpkgs": "nixpkgs_371" + "nixpkgs": "nixpkgs_370" }, "locked": { "lastModified": 1773955630, @@ -10175,7 +10161,7 @@ }, "logos-nix_369": { "inputs": { - "nixpkgs": "nixpkgs_372" + "nixpkgs": "nixpkgs_371" }, "locked": { "lastModified": 1773955630, @@ -10193,7 +10179,7 @@ }, "logos-nix_37": { "inputs": { - "nixpkgs": "nixpkgs_38" + "nixpkgs": "nixpkgs_37" }, "locked": { "lastModified": 1773955630, @@ -10211,7 +10197,7 @@ }, "logos-nix_370": { "inputs": { - "nixpkgs": "nixpkgs_373" + "nixpkgs": "nixpkgs_372" }, "locked": { "lastModified": 1773955630, @@ -10229,7 +10215,7 @@ }, "logos-nix_371": { "inputs": { - "nixpkgs": "nixpkgs_374" + "nixpkgs": "nixpkgs_373" }, "locked": { "lastModified": 1773955630, @@ -10247,7 +10233,7 @@ }, "logos-nix_372": { "inputs": { - "nixpkgs": "nixpkgs_375" + "nixpkgs": "nixpkgs_374" }, "locked": { "lastModified": 1774455309, @@ -10265,7 +10251,7 @@ }, "logos-nix_373": { "inputs": { - "nixpkgs": "nixpkgs_376" + "nixpkgs": "nixpkgs_375" }, "locked": { "lastModified": 1774455309, @@ -10283,7 +10269,7 @@ }, "logos-nix_374": { "inputs": { - "nixpkgs": "nixpkgs_377" + "nixpkgs": "nixpkgs_376" }, "locked": { "lastModified": 1773955630, @@ -10301,7 +10287,7 @@ }, "logos-nix_375": { "inputs": { - "nixpkgs": "nixpkgs_378" + "nixpkgs": "nixpkgs_377" }, "locked": { "lastModified": 1774455309, @@ -10319,7 +10305,7 @@ }, "logos-nix_376": { "inputs": { - "nixpkgs": "nixpkgs_379" + "nixpkgs": "nixpkgs_378" }, "locked": { "lastModified": 1773955630, @@ -10337,7 +10323,7 @@ }, "logos-nix_377": { "inputs": { - "nixpkgs": "nixpkgs_380" + "nixpkgs": "nixpkgs_379" }, "locked": { "lastModified": 1774455309, @@ -10355,7 +10341,7 @@ }, "logos-nix_378": { "inputs": { - "nixpkgs": "nixpkgs_381" + "nixpkgs": "nixpkgs_380" }, "locked": { "lastModified": 1774455309, @@ -10373,7 +10359,7 @@ }, "logos-nix_379": { "inputs": { - "nixpkgs": "nixpkgs_382" + "nixpkgs": "nixpkgs_381" }, "locked": { "lastModified": 1773955630, @@ -10391,7 +10377,7 @@ }, "logos-nix_38": { "inputs": { - "nixpkgs": "nixpkgs_39" + "nixpkgs": "nixpkgs_38" }, "locked": { "lastModified": 1773955630, @@ -10409,7 +10395,7 @@ }, "logos-nix_380": { "inputs": { - "nixpkgs": "nixpkgs_383" + "nixpkgs": "nixpkgs_382" }, "locked": { "lastModified": 1773955630, @@ -10427,7 +10413,7 @@ }, "logos-nix_381": { "inputs": { - "nixpkgs": "nixpkgs_384" + "nixpkgs": "nixpkgs_383" }, "locked": { "lastModified": 1774455309, @@ -10445,7 +10431,7 @@ }, "logos-nix_382": { "inputs": { - "nixpkgs": "nixpkgs_385" + "nixpkgs": "nixpkgs_384" }, "locked": { "lastModified": 1774455309, @@ -10463,7 +10449,7 @@ }, "logos-nix_383": { "inputs": { - "nixpkgs": "nixpkgs_386" + "nixpkgs": "nixpkgs_385" }, "locked": { "lastModified": 1773955630, @@ -10481,7 +10467,7 @@ }, "logos-nix_384": { "inputs": { - "nixpkgs": "nixpkgs_387" + "nixpkgs": "nixpkgs_386" }, "locked": { "lastModified": 1773955630, @@ -10499,7 +10485,7 @@ }, "logos-nix_385": { "inputs": { - "nixpkgs": "nixpkgs_388" + "nixpkgs": "nixpkgs_387" }, "locked": { "lastModified": 1774455309, @@ -10517,7 +10503,7 @@ }, "logos-nix_386": { "inputs": { - "nixpkgs": "nixpkgs_389" + "nixpkgs": "nixpkgs_388" }, "locked": { "lastModified": 1774455309, @@ -10535,7 +10521,7 @@ }, "logos-nix_387": { "inputs": { - "nixpkgs": "nixpkgs_390" + "nixpkgs": "nixpkgs_389" }, "locked": { "lastModified": 1773955630, @@ -10553,7 +10539,7 @@ }, "logos-nix_388": { "inputs": { - "nixpkgs": "nixpkgs_391" + "nixpkgs": "nixpkgs_390" }, "locked": { "lastModified": 1773955630, @@ -10571,7 +10557,7 @@ }, "logos-nix_389": { "inputs": { - "nixpkgs": "nixpkgs_392" + "nixpkgs": "nixpkgs_391" }, "locked": { "lastModified": 1773955630, @@ -10589,7 +10575,7 @@ }, "logos-nix_39": { "inputs": { - "nixpkgs": "nixpkgs_40" + "nixpkgs": "nixpkgs_39" }, "locked": { "lastModified": 1774455309, @@ -10607,7 +10593,7 @@ }, "logos-nix_390": { "inputs": { - "nixpkgs": "nixpkgs_393" + "nixpkgs": "nixpkgs_392" }, "locked": { "lastModified": 1773955630, @@ -10625,7 +10611,7 @@ }, "logos-nix_391": { "inputs": { - "nixpkgs": "nixpkgs_394" + "nixpkgs": "nixpkgs_393" }, "locked": { "lastModified": 1774455309, @@ -10643,7 +10629,7 @@ }, "logos-nix_392": { "inputs": { - "nixpkgs": "nixpkgs_395" + "nixpkgs": "nixpkgs_394" }, "locked": { "lastModified": 1773955630, @@ -10661,7 +10647,7 @@ }, "logos-nix_393": { "inputs": { - "nixpkgs": "nixpkgs_396" + "nixpkgs": "nixpkgs_395" }, "locked": { "lastModified": 1773955630, @@ -10679,7 +10665,7 @@ }, "logos-nix_394": { "inputs": { - "nixpkgs": "nixpkgs_397" + "nixpkgs": "nixpkgs_396" }, "locked": { "lastModified": 1774455309, @@ -10697,7 +10683,7 @@ }, "logos-nix_395": { "inputs": { - "nixpkgs": "nixpkgs_398" + "nixpkgs": "nixpkgs_397" }, "locked": { "lastModified": 1773955630, @@ -10715,7 +10701,7 @@ }, "logos-nix_396": { "inputs": { - "nixpkgs": "nixpkgs_399" + "nixpkgs": "nixpkgs_398" }, "locked": { "lastModified": 1773955630, @@ -10733,7 +10719,7 @@ }, "logos-nix_4": { "inputs": { - "nixpkgs": "nixpkgs_5" + "nixpkgs": "nixpkgs_4" }, "locked": { "lastModified": 1773955630, @@ -10751,7 +10737,7 @@ }, "logos-nix_40": { "inputs": { - "nixpkgs": "nixpkgs_41" + "nixpkgs": "nixpkgs_40" }, "locked": { "lastModified": 1774455309, @@ -10769,7 +10755,7 @@ }, "logos-nix_41": { "inputs": { - "nixpkgs": "nixpkgs_42" + "nixpkgs": "nixpkgs_41" }, "locked": { "lastModified": 1773955630, @@ -10787,7 +10773,7 @@ }, "logos-nix_42": { "inputs": { - "nixpkgs": "nixpkgs_43" + "nixpkgs": "nixpkgs_42" }, "locked": { "lastModified": 1773955630, @@ -10805,7 +10791,7 @@ }, "logos-nix_43": { "inputs": { - "nixpkgs": "nixpkgs_44" + "nixpkgs": "nixpkgs_43" }, "locked": { "lastModified": 1774455309, @@ -10823,7 +10809,7 @@ }, "logos-nix_44": { "inputs": { - "nixpkgs": "nixpkgs_45" + "nixpkgs": "nixpkgs_44" }, "locked": { "lastModified": 1774455309, @@ -10841,7 +10827,7 @@ }, "logos-nix_45": { "inputs": { - "nixpkgs": "nixpkgs_46" + "nixpkgs": "nixpkgs_45" }, "locked": { "lastModified": 1773955630, @@ -10859,7 +10845,7 @@ }, "logos-nix_46": { "inputs": { - "nixpkgs": "nixpkgs_47" + "nixpkgs": "nixpkgs_46" }, "locked": { "lastModified": 1773955630, @@ -10877,7 +10863,7 @@ }, "logos-nix_47": { "inputs": { - "nixpkgs": "nixpkgs_48" + "nixpkgs": "nixpkgs_47" }, "locked": { "lastModified": 1773955630, @@ -10895,7 +10881,7 @@ }, "logos-nix_48": { "inputs": { - "nixpkgs": "nixpkgs_49" + "nixpkgs": "nixpkgs_48" }, "locked": { "lastModified": 1773955630, @@ -10913,7 +10899,7 @@ }, "logos-nix_49": { "inputs": { - "nixpkgs": "nixpkgs_50" + "nixpkgs": "nixpkgs_49" }, "locked": { "lastModified": 1774455309, @@ -10931,7 +10917,7 @@ }, "logos-nix_5": { "inputs": { - "nixpkgs": "nixpkgs_6" + "nixpkgs": "nixpkgs_5" }, "locked": { "lastModified": 1774455309, @@ -10949,7 +10935,7 @@ }, "logos-nix_50": { "inputs": { - "nixpkgs": "nixpkgs_51" + "nixpkgs": "nixpkgs_50" }, "locked": { "lastModified": 1773955630, @@ -10967,7 +10953,7 @@ }, "logos-nix_51": { "inputs": { - "nixpkgs": "nixpkgs_52" + "nixpkgs": "nixpkgs_51" }, "locked": { "lastModified": 1773955630, @@ -10985,7 +10971,7 @@ }, "logos-nix_52": { "inputs": { - "nixpkgs": "nixpkgs_53" + "nixpkgs": "nixpkgs_52" }, "locked": { "lastModified": 1774455309, @@ -11003,7 +10989,7 @@ }, "logos-nix_53": { "inputs": { - "nixpkgs": "nixpkgs_54" + "nixpkgs": "nixpkgs_53" }, "locked": { "lastModified": 1773955630, @@ -11021,7 +11007,7 @@ }, "logos-nix_54": { "inputs": { - "nixpkgs": "nixpkgs_55" + "nixpkgs": "nixpkgs_54" }, "locked": { "lastModified": 1774455309, @@ -11039,7 +11025,7 @@ }, "logos-nix_55": { "inputs": { - "nixpkgs": "nixpkgs_56" + "nixpkgs": "nixpkgs_55" }, "locked": { "lastModified": 1773955630, @@ -11057,7 +11043,7 @@ }, "logos-nix_56": { "inputs": { - "nixpkgs": "nixpkgs_57" + "nixpkgs": "nixpkgs_56" }, "locked": { "lastModified": 1774455309, @@ -11075,7 +11061,7 @@ }, "logos-nix_57": { "inputs": { - "nixpkgs": "nixpkgs_58" + "nixpkgs": "nixpkgs_57" }, "locked": { "lastModified": 1773955630, @@ -11093,7 +11079,7 @@ }, "logos-nix_58": { "inputs": { - "nixpkgs": "nixpkgs_59" + "nixpkgs": "nixpkgs_58" }, "locked": { "lastModified": 1774455309, @@ -11111,7 +11097,7 @@ }, "logos-nix_59": { "inputs": { - "nixpkgs": "nixpkgs_60" + "nixpkgs": "nixpkgs_59" }, "locked": { "lastModified": 1773955630, @@ -11129,7 +11115,7 @@ }, "logos-nix_6": { "inputs": { - "nixpkgs": "nixpkgs_7" + "nixpkgs": "nixpkgs_6" }, "locked": { "lastModified": 1773955630, @@ -11147,7 +11133,7 @@ }, "logos-nix_60": { "inputs": { - "nixpkgs": "nixpkgs_61" + "nixpkgs": "nixpkgs_60" }, "locked": { "lastModified": 1774455309, @@ -11165,7 +11151,7 @@ }, "logos-nix_61": { "inputs": { - "nixpkgs": "nixpkgs_62" + "nixpkgs": "nixpkgs_61" }, "locked": { "lastModified": 1773955630, @@ -11183,7 +11169,7 @@ }, "logos-nix_62": { "inputs": { - "nixpkgs": "nixpkgs_63" + "nixpkgs": "nixpkgs_62" }, "locked": { "lastModified": 1773955630, @@ -11201,7 +11187,7 @@ }, "logos-nix_63": { "inputs": { - "nixpkgs": "nixpkgs_64" + "nixpkgs": "nixpkgs_63" }, "locked": { "lastModified": 1774455309, @@ -11219,7 +11205,7 @@ }, "logos-nix_64": { "inputs": { - "nixpkgs": "nixpkgs_65" + "nixpkgs": "nixpkgs_64" }, "locked": { "lastModified": 1773955630, @@ -11237,7 +11223,7 @@ }, "logos-nix_65": { "inputs": { - "nixpkgs": "nixpkgs_66" + "nixpkgs": "nixpkgs_65" }, "locked": { "lastModified": 1773955630, @@ -11255,7 +11241,7 @@ }, "logos-nix_66": { "inputs": { - "nixpkgs": "nixpkgs_67" + "nixpkgs": "nixpkgs_66" }, "locked": { "lastModified": 1773955630, @@ -11273,7 +11259,7 @@ }, "logos-nix_67": { "inputs": { - "nixpkgs": "nixpkgs_68" + "nixpkgs": "nixpkgs_67" }, "locked": { "lastModified": 1773955630, @@ -11291,7 +11277,7 @@ }, "logos-nix_68": { "inputs": { - "nixpkgs": "nixpkgs_69" + "nixpkgs": "nixpkgs_68" }, "locked": { "lastModified": 1774455309, @@ -11309,7 +11295,7 @@ }, "logos-nix_69": { "inputs": { - "nixpkgs": "nixpkgs_70" + "nixpkgs": "nixpkgs_69" }, "locked": { "lastModified": 1773955630, @@ -11327,7 +11313,7 @@ }, "logos-nix_7": { "inputs": { - "nixpkgs": "nixpkgs_8" + "nixpkgs": "nixpkgs_7" }, "locked": { "lastModified": 1774455309, @@ -11345,7 +11331,7 @@ }, "logos-nix_70": { "inputs": { - "nixpkgs": "nixpkgs_71" + "nixpkgs": "nixpkgs_70" }, "locked": { "lastModified": 1774455309, @@ -11363,7 +11349,7 @@ }, "logos-nix_71": { "inputs": { - "nixpkgs": "nixpkgs_72" + "nixpkgs": "nixpkgs_71" }, "locked": { "lastModified": 1774455309, @@ -11381,7 +11367,7 @@ }, "logos-nix_72": { "inputs": { - "nixpkgs": "nixpkgs_73" + "nixpkgs": "nixpkgs_72" }, "locked": { "lastModified": 1773955630, @@ -11399,7 +11385,7 @@ }, "logos-nix_73": { "inputs": { - "nixpkgs": "nixpkgs_74" + "nixpkgs": "nixpkgs_73" }, "locked": { "lastModified": 1773955630, @@ -11417,7 +11403,7 @@ }, "logos-nix_74": { "inputs": { - "nixpkgs": "nixpkgs_75" + "nixpkgs": "nixpkgs_74" }, "locked": { "lastModified": 1773955630, @@ -11435,7 +11421,7 @@ }, "logos-nix_75": { "inputs": { - "nixpkgs": "nixpkgs_76" + "nixpkgs": "nixpkgs_75" }, "locked": { "lastModified": 1773955630, @@ -11453,7 +11439,7 @@ }, "logos-nix_76": { "inputs": { - "nixpkgs": "nixpkgs_77" + "nixpkgs": "nixpkgs_76" }, "locked": { "lastModified": 1773955630, @@ -11471,7 +11457,7 @@ }, "logos-nix_77": { "inputs": { - "nixpkgs": "nixpkgs_78" + "nixpkgs": "nixpkgs_77" }, "locked": { "lastModified": 1774455309, @@ -11489,7 +11475,7 @@ }, "logos-nix_78": { "inputs": { - "nixpkgs": "nixpkgs_79" + "nixpkgs": "nixpkgs_78" }, "locked": { "lastModified": 1773955630, @@ -11507,7 +11493,7 @@ }, "logos-nix_79": { "inputs": { - "nixpkgs": "nixpkgs_80" + "nixpkgs": "nixpkgs_79" }, "locked": { "lastModified": 1774455309, @@ -11525,7 +11511,7 @@ }, "logos-nix_8": { "inputs": { - "nixpkgs": "nixpkgs_9" + "nixpkgs": "nixpkgs_8" }, "locked": { "lastModified": 1774455309, @@ -11543,7 +11529,7 @@ }, "logos-nix_80": { "inputs": { - "nixpkgs": "nixpkgs_81" + "nixpkgs": "nixpkgs_80" }, "locked": { "lastModified": 1774455309, @@ -11561,7 +11547,7 @@ }, "logos-nix_81": { "inputs": { - "nixpkgs": "nixpkgs_82" + "nixpkgs": "nixpkgs_81" }, "locked": { "lastModified": 1773955630, @@ -11579,7 +11565,7 @@ }, "logos-nix_82": { "inputs": { - "nixpkgs": "nixpkgs_83" + "nixpkgs": "nixpkgs_82" }, "locked": { "lastModified": 1773955630, @@ -11597,7 +11583,7 @@ }, "logos-nix_83": { "inputs": { - "nixpkgs": "nixpkgs_84" + "nixpkgs": "nixpkgs_83" }, "locked": { "lastModified": 1774455309, @@ -11615,7 +11601,7 @@ }, "logos-nix_84": { "inputs": { - "nixpkgs": "nixpkgs_85" + "nixpkgs": "nixpkgs_84" }, "locked": { "lastModified": 1774455309, @@ -11633,7 +11619,7 @@ }, "logos-nix_85": { "inputs": { - "nixpkgs": "nixpkgs_86" + "nixpkgs": "nixpkgs_85" }, "locked": { "lastModified": 1773955630, @@ -11651,7 +11637,7 @@ }, "logos-nix_86": { "inputs": { - "nixpkgs": "nixpkgs_87" + "nixpkgs": "nixpkgs_86" }, "locked": { "lastModified": 1773955630, @@ -11669,7 +11655,7 @@ }, "logos-nix_87": { "inputs": { - "nixpkgs": "nixpkgs_88" + "nixpkgs": "nixpkgs_87" }, "locked": { "lastModified": 1774455309, @@ -11687,7 +11673,7 @@ }, "logos-nix_88": { "inputs": { - "nixpkgs": "nixpkgs_89" + "nixpkgs": "nixpkgs_88" }, "locked": { "lastModified": 1774455309, @@ -11705,7 +11691,7 @@ }, "logos-nix_89": { "inputs": { - "nixpkgs": "nixpkgs_90" + "nixpkgs": "nixpkgs_89" }, "locked": { "lastModified": 1773955630, @@ -11723,7 +11709,7 @@ }, "logos-nix_9": { "inputs": { - "nixpkgs": "nixpkgs_10" + "nixpkgs": "nixpkgs_9" }, "locked": { "lastModified": 1774455309, @@ -11741,7 +11727,7 @@ }, "logos-nix_90": { "inputs": { - "nixpkgs": "nixpkgs_91" + "nixpkgs": "nixpkgs_90" }, "locked": { "lastModified": 1773955630, @@ -11759,7 +11745,7 @@ }, "logos-nix_91": { "inputs": { - "nixpkgs": "nixpkgs_92" + "nixpkgs": "nixpkgs_91" }, "locked": { "lastModified": 1773955630, @@ -11777,7 +11763,7 @@ }, "logos-nix_92": { "inputs": { - "nixpkgs": "nixpkgs_93" + "nixpkgs": "nixpkgs_92" }, "locked": { "lastModified": 1773955630, @@ -11795,7 +11781,7 @@ }, "logos-nix_93": { "inputs": { - "nixpkgs": "nixpkgs_94" + "nixpkgs": "nixpkgs_93" }, "locked": { "lastModified": 1774455309, @@ -11813,7 +11799,7 @@ }, "logos-nix_94": { "inputs": { - "nixpkgs": "nixpkgs_95" + "nixpkgs": "nixpkgs_94" }, "locked": { "lastModified": 1773955630, @@ -11831,7 +11817,7 @@ }, "logos-nix_95": { "inputs": { - "nixpkgs": "nixpkgs_96" + "nixpkgs": "nixpkgs_95" }, "locked": { "lastModified": 1773955630, @@ -11849,7 +11835,7 @@ }, "logos-nix_96": { "inputs": { - "nixpkgs": "nixpkgs_97" + "nixpkgs": "nixpkgs_96" }, "locked": { "lastModified": 1774455309, @@ -11867,7 +11853,7 @@ }, "logos-nix_97": { "inputs": { - "nixpkgs": "nixpkgs_98" + "nixpkgs": "nixpkgs_97" }, "locked": { "lastModified": 1773955630, @@ -11885,7 +11871,7 @@ }, "logos-nix_98": { "inputs": { - "nixpkgs": "nixpkgs_99" + "nixpkgs": "nixpkgs_98" }, "locked": { "lastModified": 1774455309, @@ -11903,7 +11889,7 @@ }, "logos-nix_99": { "inputs": { - "nixpkgs": "nixpkgs_100" + "nixpkgs": "nixpkgs_99" }, "locked": { "lastModified": 1774455309, @@ -20128,11 +20114,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1783776592, - "narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=", + "lastModified": 1759036355, + "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3", + "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", "type": "github" }, "original": { @@ -20576,32 +20562,32 @@ }, "nixpkgs_124": { "locked": { - "lastModified": 1759036355, - "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", + "lastModified": 1767313136, + "narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", + "rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-unstable", + "ref": "nixos-25.05", "repo": "nixpkgs", "type": "github" } }, "nixpkgs_125": { "locked": { - "lastModified": 1767313136, - "narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=", + "lastModified": 1759036355, + "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d", + "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-25.05", + "ref": "nixos-unstable", "repo": "nixpkgs", "type": "github" } @@ -23168,32 +23154,32 @@ }, "nixpkgs_270": { "locked": { - "lastModified": 1759036355, - "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", + "lastModified": 1767313136, + "narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", + "rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-unstable", + "ref": "nixos-25.05", "repo": "nixpkgs", "type": "github" } }, "nixpkgs_271": { "locked": { - "lastModified": 1767313136, - "narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=", + "lastModified": 1759036355, + "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d", + "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-25.05", + "ref": "nixos-unstable", "repo": "nixpkgs", "type": "github" } @@ -25440,16 +25426,16 @@ }, "nixpkgs_399": { "locked": { - "lastModified": 1759036355, - "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", + "lastModified": 1782841183, + "narHash": "sha256-Ndt/5R7UN4rBdhFR1lxHZZZ42cD6vGlnuxC2VvvsKE4=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", + "rev": "c9bfd86ed684d27e63b0ff9ebb18699f84f27a3b", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-unstable", + "ref": "nixos-25.11-small", "repo": "nixpkgs", "type": "github" } @@ -26808,10 +26794,10 @@ }, "root": { "inputs": { - "amm_client": "amm_client", + "crane": "crane", "logos-module-builder": "logos-module-builder", "logos_execution_zone": "logos_execution_zone", - "shared_wallet": "shared_wallet" + "nixpkgs": "nixpkgs_399" } }, "rust-overlay": { @@ -26881,7 +26867,7 @@ }, "rust-rapidsnark": { "inputs": { - "nixpkgs": "nixpkgs_271" + "nixpkgs": "nixpkgs_270" }, "locked": { "lastModified": 1781090841, @@ -26897,18 +26883,6 @@ "rev": "e91187f8ccb5bbfc7bb00dac88169112428da78f", "type": "github" } - }, - "shared_wallet": { - "flake": false, - "locked": { - "path": "../shared/wallet", - "type": "path" - }, - "original": { - "path": "../shared/wallet", - "type": "path" - }, - "parent": [] } }, "root": "root", diff --git a/apps/amm/scripts/check-macos-prerequisites.sh b/apps/amm/scripts/check-macos-prerequisites.sh new file mode 100755 index 00000000..b2e48281 --- /dev/null +++ b/apps/amm/scripts/check-macos-prerequisites.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash + +# Verify the host requirements for a supported native macOS AMM build. + +set -euo pipefail + +failures=0 + +pass() { + printf 'ok: %s\n' "$1" +} + +fail() { + printf 'missing: %s\n' "$1" >&2 + failures=$((failures + 1)) +} + +if [[ "$(uname -s)" != "Darwin" ]]; then + printf 'missing: this script must run on macOS\n' >&2 + exit 1 +fi + +architecture="$(uname -m)" +case "$architecture" in + arm64|x86_64) + pass "supported architecture $architecture" + ;; + *) + fail "unsupported architecture $architecture" + ;; +esac + +developer_dir="$(xcode-select -p 2>/dev/null || true)" +if [[ "$developer_dir" == *.app/Contents/Developer ]]; then + pass "full Xcode selected at $developer_dir" + required_gib=40 +else + fail "full Xcode is not selected (current developer directory: ${developer_dir:-none})" + required_gib=60 +fi + +if xcode_version="$(xcodebuild -version 2>/dev/null)"; then + pass "$(printf '%s' "$xcode_version" | tr '\n' ' ')" +else + fail "xcodebuild is unavailable; install Xcode.app and select it with xcode-select" +fi + +if xcodebuild -checkFirstLaunchStatus >/dev/null 2>&1; then + pass "Xcode first-launch setup and licence are complete" +else + fail "Xcode setup is incomplete; run sudo xcodebuild -license accept and -runFirstLaunch" +fi + +for tool in metal metallib; do + if tool_path="$(xcrun --sdk macosx --find "$tool" 2>/dev/null)" && + [[ -x "$tool_path" ]]; then + pass "$tool at $tool_path" + else + fail "$tool is unavailable; run xcodebuild -downloadComponent MetalToolchain" + fi +done + +nix_bin="$(command -v nix 2>/dev/null || true)" +if [[ -z "$nix_bin" && -x /nix/var/nix/profiles/default/bin/nix ]]; then + nix_bin=/nix/var/nix/profiles/default/bin/nix +fi + +if [[ -n "$nix_bin" ]]; then + pass "Nix at $nix_bin ($("$nix_bin" --version))" + + configured_features="$( + "$nix_bin" config show experimental-features 2>/dev/null || + "$nix_bin" show-config 2>/dev/null | + awk -F ' = ' '$1 == "experimental-features" { print $2 }' + )" + if [[ " $configured_features " == *" nix-command "* && + " $configured_features " == *" flakes "* ]]; then + pass "Nix flakes and nix-command are enabled" + else + fail "enable experimental-features = nix-command flakes in nix.conf" + fi + + if "$nix_bin" --extra-experimental-features 'nix-command flakes' \ + store info >/dev/null 2>&1; then + pass "Nix daemon and store are available" + else + fail "Nix cannot contact its daemon or store" + fi + + sandbox_setting="$("$nix_bin" config show sandbox 2>/dev/null || true)" + if [[ "$sandbox_setting" == "false" ]]; then + pass "Nix macOS sandbox is disabled for the host Metal toolchain" + else + fail "Nix sandbox must be false for the host Metal toolchain (current: ${sandbox_setting:-unknown})" + fi +else + fail "Nix is unavailable; install multi-user Nix and start its daemon" +fi + +if [[ -d /nix ]]; then + available_kib="$(df -Pk /nix | awk 'NR == 2 { print $4 }')" + required_kib=$((required_gib * 1024 * 1024)) + if [[ "${available_kib:-0}" -ge "$required_kib" ]]; then + pass "at least ${required_gib} GiB free on the Nix volume" + else + fail "less than ${required_gib} GiB free on the Nix volume" + fi +else + fail "/nix is not mounted" +fi + +if (( failures > 0 )); then + printf '\nSee MACOS.md for installation and recovery instructions.\n' >&2 + exit 1 +fi + +printf '\nAll macOS AMM prerequisites are available.\n' From e01878faf7805e27e3d1d7eea5ce52877378b994 Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:45:15 +0200 Subject: [PATCH 07/10] chore: remove unnecessary macos prerequisites docs --- apps/amm/MACOS.md | 173 ------------------ apps/amm/README.md | 3 - apps/amm/scripts/check-macos-prerequisites.sh | 117 ------------ 3 files changed, 293 deletions(-) delete mode 100644 apps/amm/MACOS.md delete mode 100755 apps/amm/scripts/check-macos-prerequisites.sh diff --git a/apps/amm/MACOS.md b/apps/amm/MACOS.md deleted file mode 100644 index 0036bc9e..00000000 --- a/apps/amm/MACOS.md +++ /dev/null @@ -1,173 +0,0 @@ -# AMM UI on macOS - -The AMM flake exposes native packages for Apple Silicon and Intel macOS. Nix -provides the Rust, C++, Qt, and Apple SDK build dependencies. The only required -host toolchain is Apple's Metal compiler, which Apple distributes through -Xcode rather than through a redistributable package. - -The standalone build needs no input overrides, temporary source pins, or -`DYLD_LIBRARY_PATH`. - -## One-time prerequisites - -### Nix - -Install Nix in multi-user mode, make sure its daemon is available, and enable -flakes: - -```sh -mkdir -p ~/.config/nix -printf '%s\n' 'experimental-features = nix-command flakes' >> ~/.config/nix/nix.conf -nix --version -nix store info -``` - -Nix normally has `sandbox = false` on macOS. This build must retain that -setting because RISC Zero invokes Apple's host-installed Metal toolchain. The -preflight script below reports an actionable error if the daemon is configured -otherwise. - -### Xcode and the Metal Toolchain - -Apple Command Line Tools alone do not include `metal` and `metallib`. Install -the newest full Xcode release supported by the installed macOS from the App -Store or [Apple Developer Downloads](https://developer.apple.com/download/all/). -This requires an Apple account and cannot be automated by the repository. - -After installation: - -```sh -sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer -sudo xcodebuild -license accept -sudo xcodebuild -runFirstLaunch -xcodebuild -downloadComponent MetalToolchain -xcrun --sdk macosx --find metal -xcrun --sdk macosx --find metallib -``` - -The last two commands must print executable paths. Do not use -`RISC0_SKIP_BUILD_KERNELS=1`: the pinned dependency graph requires the real -Metal kernels. - -### Disk space and preflight - -Leave at least 60 GiB free before downloading Xcode. Once Xcode is installed, -keep at least 40 GiB free for the first Nix build. Later builds reuse the Nix -store. - -Run the complete machine check from `apps/amm`: - -```sh -bash scripts/check-macos-prerequisites.sh -``` - -## Build and run - -From `apps/amm`: - -```sh -nix build . -nix run . -``` - -The first cold build is large because it includes Qt, the wallet, the AMM -client, and the RISC Zero Metal kernels. The first start opens without a -wallet; select **Connect** to create or open `~/.lee/wallet/`. - -## Why the flake is structured this way - -Nix 2.25 rejects committed lock entries for unlocked relative flake inputs such -as `path:../shared/wallet` and `path:../..`. Newer Nix versions may accept that -layout, which is why it can appear to work on one developer's Mac and fail on -another. - -The portable layout avoids both relative flake inputs: - -- the shared wallet is passed to CMake as an ordinary Nix source path; -- the repository-root AMM client package is imported directly with the same - locked `nixpkgs` and Crane inputs; -- `flake.lock` contains only immutable upstream sources. - -The app output uses the builder's full-library QML package layout. The current -`qt-plugin` layout copies the UI plugin and replica factory but omits sibling -external libraries such as `libamm_client`. The module metadata still selects -the C++ backend, so this packaging choice does not change process isolation or -runtime behavior. - -Do not replace these with a pull-request URL, self-reference, or local -`path:` flake input. A normal `nix flake lock`, `nix build`, and `nix run` -must work without overrides. - -## Known-good validation host - -This configuration has been exercised on: - -- Apple Silicon (`arm64`) -- macOS 26.5.2 -- Xcode 26.6 (build 17F113) -- Metal Toolchain 32023.883 -- Nix 2.25.3 - -The flake also evaluates its complete package and app outputs for -`x86_64-darwin`; an Intel Mac is still required for native runtime validation. - -## Recovery - -### Nix is unavailable - -Open a new terminal after installing Nix. For the standard multi-user -installation, check the store and daemon before trying to bootstrap it again: - -```sh -test -x /nix/var/nix/profiles/default/bin/nix -nix store info -sudo launchctl print system/org.nixos.nix-daemon -``` - -`launchctl bootstrap` can report an I/O error when a service is already loaded -or in an inconsistent state; that message alone does not prove the daemon is -stopped. - -### `xcrun` cannot find `metal` - -The selected developer directory points at Command Line Tools, or Xcode lacks -the separately downloaded Metal Toolchain component. Repeat the Xcode setup -and verification commands above. - -Apple does not distribute the Metal compiler as a standalone redistributable -SDK, so Nix cannot install this host component. - -### The build reports that the macOS sandbox blocks Metal - -Confirm the active setting: - -```sh -nix config show sandbox -``` - -For this native macOS build it must be `false`. If a machine-wide policy set it -to `true`, update that Nix installation's daemon configuration and restart the -daemon before retrying. - -### The first build runs out of space - -Remove only disposable build outputs, then collect dead Nix store paths: - -```sh -rm -rf /private/tmp/amm-build -nix-store --gc -``` - -Do not remove `/nix/store` directly. - -### A lock error mentions an unlocked input - -Restore the committed app lock and verify it without rewriting it: - -```sh -git restore flake.lock -nix flake metadata --no-update-lock-file . -``` - -If the error still names `path:../shared/wallet` or `path:../..`, the checkout -does not contain the portable flake fix described above. diff --git a/apps/amm/README.md b/apps/amm/README.md index 97e7044f..126e0f86 100644 --- a/apps/amm/README.md +++ b/apps/amm/README.md @@ -50,9 +50,6 @@ nix profile install 'github:logos-co/logos-package-manager#cli' This makes `lgpm` available as a global command. -For macOS prerequisites, the Metal toolchain requirement, and common Nix -recovery steps, see [macOS setup](MACOS.md). - ## Running the UI standalone The app is built from the **repository-root** flake (which also provides the diff --git a/apps/amm/scripts/check-macos-prerequisites.sh b/apps/amm/scripts/check-macos-prerequisites.sh deleted file mode 100755 index b2e48281..00000000 --- a/apps/amm/scripts/check-macos-prerequisites.sh +++ /dev/null @@ -1,117 +0,0 @@ -#!/usr/bin/env bash - -# Verify the host requirements for a supported native macOS AMM build. - -set -euo pipefail - -failures=0 - -pass() { - printf 'ok: %s\n' "$1" -} - -fail() { - printf 'missing: %s\n' "$1" >&2 - failures=$((failures + 1)) -} - -if [[ "$(uname -s)" != "Darwin" ]]; then - printf 'missing: this script must run on macOS\n' >&2 - exit 1 -fi - -architecture="$(uname -m)" -case "$architecture" in - arm64|x86_64) - pass "supported architecture $architecture" - ;; - *) - fail "unsupported architecture $architecture" - ;; -esac - -developer_dir="$(xcode-select -p 2>/dev/null || true)" -if [[ "$developer_dir" == *.app/Contents/Developer ]]; then - pass "full Xcode selected at $developer_dir" - required_gib=40 -else - fail "full Xcode is not selected (current developer directory: ${developer_dir:-none})" - required_gib=60 -fi - -if xcode_version="$(xcodebuild -version 2>/dev/null)"; then - pass "$(printf '%s' "$xcode_version" | tr '\n' ' ')" -else - fail "xcodebuild is unavailable; install Xcode.app and select it with xcode-select" -fi - -if xcodebuild -checkFirstLaunchStatus >/dev/null 2>&1; then - pass "Xcode first-launch setup and licence are complete" -else - fail "Xcode setup is incomplete; run sudo xcodebuild -license accept and -runFirstLaunch" -fi - -for tool in metal metallib; do - if tool_path="$(xcrun --sdk macosx --find "$tool" 2>/dev/null)" && - [[ -x "$tool_path" ]]; then - pass "$tool at $tool_path" - else - fail "$tool is unavailable; run xcodebuild -downloadComponent MetalToolchain" - fi -done - -nix_bin="$(command -v nix 2>/dev/null || true)" -if [[ -z "$nix_bin" && -x /nix/var/nix/profiles/default/bin/nix ]]; then - nix_bin=/nix/var/nix/profiles/default/bin/nix -fi - -if [[ -n "$nix_bin" ]]; then - pass "Nix at $nix_bin ($("$nix_bin" --version))" - - configured_features="$( - "$nix_bin" config show experimental-features 2>/dev/null || - "$nix_bin" show-config 2>/dev/null | - awk -F ' = ' '$1 == "experimental-features" { print $2 }' - )" - if [[ " $configured_features " == *" nix-command "* && - " $configured_features " == *" flakes "* ]]; then - pass "Nix flakes and nix-command are enabled" - else - fail "enable experimental-features = nix-command flakes in nix.conf" - fi - - if "$nix_bin" --extra-experimental-features 'nix-command flakes' \ - store info >/dev/null 2>&1; then - pass "Nix daemon and store are available" - else - fail "Nix cannot contact its daemon or store" - fi - - sandbox_setting="$("$nix_bin" config show sandbox 2>/dev/null || true)" - if [[ "$sandbox_setting" == "false" ]]; then - pass "Nix macOS sandbox is disabled for the host Metal toolchain" - else - fail "Nix sandbox must be false for the host Metal toolchain (current: ${sandbox_setting:-unknown})" - fi -else - fail "Nix is unavailable; install multi-user Nix and start its daemon" -fi - -if [[ -d /nix ]]; then - available_kib="$(df -Pk /nix | awk 'NR == 2 { print $4 }')" - required_kib=$((required_gib * 1024 * 1024)) - if [[ "${available_kib:-0}" -ge "$required_kib" ]]; then - pass "at least ${required_gib} GiB free on the Nix volume" - else - fail "less than ${required_gib} GiB free on the Nix volume" - fi -else - fail "/nix is not mounted" -fi - -if (( failures > 0 )); then - printf '\nSee MACOS.md for installation and recovery instructions.\n' >&2 - exit 1 -fi - -printf '\nAll macOS AMM prerequisites are available.\n' From 44b355ac7daae8dedb5f195837c1b24500b73878 Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:33:37 +0200 Subject: [PATCH 08/10] factor(amm-ui): source new-position network context from AMM_PROGRAM_BIN/TOKENS_CONFIG/wallet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The create-pool / new-position flow carried its own network layer: AMM_UI_NETWORK + AMM_UI_DEVNET_FILE (a devnet.json) or a bundled config/networks.json supplied the AMM program id and token set, and a JSON-RPC channel/checkpoint "identity probe" gated the flow to a verified network. Main already exposes all of this the way the Swap view consumes it, so collapse onto those sources instead of a parallel system: - ammProgramId <- $AMM_PROGRAM_BIN (derived like swapExactInput's program id; doubles as the quote's network fingerprint so a quote can't be replayed against a different deployment) - tokenIds <- $TOKENS_CONFIG (amm-tokens.json), same as the Swap picker - sequencer <- the wallet config (already surfaced via syncWalletState) networkSnapshot() builds the ActiveNetworkSnapshot from those; status is "ready"/"config_missing", gated to "loading" until wallet state resolves so no module reads happen during construction. The channel probe is gone — submit needs no channelId (the wallet module supplies the channel via submitPublicTransaction), so the whole verification apparatus was overhead. Removes: AMM_UI_NETWORK / AMM_UI_DEVNET_FILE, devnet.json / networks.json, the ActiveNetwork class (+ its test) and its QNetwork channel probe, and Qt6Network. ActiveNetwork.h keeps only the ActiveNetworkSnapshot struct. Run command drops the AMM_UI_* vars: ``` LEE_WALLET_HOME_DIR=… AMM_PROGRAM_BIN=… TOKENS_CONFIG=… nix run .#amm-ui ``` --- apps/amm/CMakeLists.txt | 17 --- apps/amm/README.md | 16 ++- apps/amm/config/networks.json | 18 --- apps/amm/src/ActiveNetwork.cpp | 141 ------------------- apps/amm/src/ActiveNetwork.h | 29 +--- apps/amm/src/AmmUiBackend.cpp | 165 ++++++++--------------- apps/amm/src/AmmUiBackend.h | 16 ++- apps/amm/tests/cpp/ActiveNetworkTest.cpp | 65 --------- 8 files changed, 83 insertions(+), 384 deletions(-) delete mode 100644 apps/amm/config/networks.json delete mode 100644 apps/amm/src/ActiveNetwork.cpp delete mode 100644 apps/amm/tests/cpp/ActiveNetworkTest.cpp diff --git a/apps/amm/CMakeLists.txt b/apps/amm/CMakeLists.txt index 037867cb..6e8e4370 100644 --- a/apps/amm/CMakeLists.txt +++ b/apps/amm/CMakeLists.txt @@ -37,17 +37,14 @@ logos_module( src/AmmUiBackend.h src/AmmUiBackend.cpp src/ActiveNetwork.h - src/ActiveNetwork.cpp src/AmmClient.h src/AmmClient.cpp src/NewPositionRuntime.h src/NewPositionRuntime.cpp FIND_PACKAGES Qt6Gui - Qt6Network LINK_LIBRARIES Qt6::Gui - Qt6::Network PkgConfig::BASE58 LINK_TARGETS logos_wallet_access @@ -56,21 +53,7 @@ logos_module( amm_client ) -qt_add_resources(amm_ui_module_plugin amm_ui_config - PREFIX "/amm" - FILES - config/networks.json -) - if(BUILD_TESTING) - add_executable(amm_active_network_test - tests/cpp/ActiveNetworkTest.cpp - src/ActiveNetwork.cpp - ) - target_include_directories(amm_active_network_test PRIVATE src) - target_link_libraries(amm_active_network_test PRIVATE Qt6::Core) - add_test(NAME amm_active_network COMMAND amm_active_network_test) - add_executable(amm_new_position_runtime_test tests/cpp/NewPositionRuntimeTest.cpp src/NewPositionRuntime.cpp diff --git a/apps/amm/README.md b/apps/amm/README.md index 126e0f86..b33841a0 100644 --- a/apps/amm/README.md +++ b/apps/amm/README.md @@ -135,10 +135,12 @@ core module from `result-core/` and the UI plugin from `result-lgx/`: 4. Choose the core module `.lgx` from `result-core/`, then the UI plugin `.lgx` from `result-lgx/` -To actually use the Swap view you must also set `AMM_PROGRAM_BIN` and -`TOKENS_CONFIG` (both explained below). Run this **from the repo root** — use -absolute paths (`$(pwd)/…`), because `nix run` may not preserve the working -directory, so relative paths won't resolve: +To actually use the on-chain views (**Swap** and **Liquidity**) you must also +set `AMM_PROGRAM_BIN` and `TOKENS_CONFIG` (both explained below). Both views read +the same two — the AMM program id is derived from `AMM_PROGRAM_BIN`, the token +set from `TOKENS_CONFIG`, and the sequencer from the wallet config. Run this +**from the repo root** — use absolute paths (`$(pwd)/…`), because `nix run` may +not preserve the working directory, so relative paths won't resolve: ```bash AMM_PROGRAM_BIN=$(pwd)/programs/amm/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/amm.bin \ @@ -146,10 +148,10 @@ TOKENS_CONFIG=$(pwd)/amm-tokens.json \ nix run .#amm-ui ``` -Without `AMM_PROGRAM_BIN` the Swap view stays disabled; without `TOKENS_CONFIG` -the token picker is empty. Each is detailed below. +Without `AMM_PROGRAM_BIN` the Swap and Liquidity views stay disabled; without +`TOKENS_CONFIG` the token picker is empty. Each is detailed below. -### AMM program binary (required for swaps) +### AMM program binary (required for swaps and liquidity) To execute a swap, the app must submit a transaction against the **exact AMM program you deployed** (its ELF determines the program id, and therefore every diff --git a/apps/amm/config/networks.json b/apps/amm/config/networks.json deleted file mode 100644 index 75e03eba..00000000 --- a/apps/amm/config/networks.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "testnet": { - "checkpointHash": "0d25d71fca70d7008a892f6b3f768a4c66badbcd64e67d79ca595b92f1db544a", - "ammProgramId": "77eeaa23668ad2675fb768cd7ecb1893387be464b9a51f16756006c1d307db07", - "tokenDefinitionIds": [ - "7b464ff9dd0d3bc07f7e2e0b0667ccd066d85ad12be4c79fc55687a863910aa6", - "48c81cf032e601ca367fc9816b957dbf5c0e4c11cf7008e8f4581ec1a67aab42", - "159caef810ea545951b3bd913efe625ee45008c80865c330e72a72ed48b61649", - "75f33110b185717209e3955f228d4a4448801d0ce8ba438a4a268050eeff3f44", - "fbd107ca4bb66bc58f59ac2d32a759be3ee0fb453f8fecd1991c11837d9660c7", - "5547fcb72644d95a385d313b887a96be41ff263bce6150b49fd87276839822bf", - "fa43e74a97d79c5f907ff3edabda5ad89bfbd3b0922572e675d4ad3c7b6029c7", - "4f3231d8a01e1d79f163bc27fce0c860a4a2f6890280e9d135eafbde0d68ed79", - "fa32f354408857006f8ea396b0419823bd04436eadb2d273d2618a46b4793ed8", - "00fe99e4fbd4c71f92e47c384c6235244c8cce39b6d6367e1e338eca0ffe01cb" - ] - } -} diff --git a/apps/amm/src/ActiveNetwork.cpp b/apps/amm/src/ActiveNetwork.cpp deleted file mode 100644 index c92bc73b..00000000 --- a/apps/amm/src/ActiveNetwork.cpp +++ /dev/null @@ -1,141 +0,0 @@ -#include "ActiveNetwork.h" - -#include -#include -#include -#include - -namespace { - const char NETWORK_ENV[] = "AMM_UI_NETWORK"; - const char DEVNET_FILE_ENV[] = "AMM_UI_DEVNET_FILE"; - - bool isLowerHex(const QString& value, int size) - { - if (value.size() != size) - return false; - for (const QChar character : value) { - const bool isAsciiDigit = character >= QLatin1Char('0') - && character <= QLatin1Char('9'); - if (!isAsciiDigit - && (character < QLatin1Char('a') || character > QLatin1Char('f'))) { - return false; - } - } - return true; - } -} - -bool ActiveNetwork::load() -{ - m_network = {}; - m_network.status = QStringLiteral("config_missing"); - m_expectedIdentity.clear(); - - const QByteArray selected = qgetenv(NETWORK_ENV); - m_network.id = selected.isEmpty() - ? QStringLiteral("testnet") - : QString::fromLocal8Bit(selected).trimmed(); - - QJsonObject entry; - if (isDevnet()) { - const QString path = QString::fromLocal8Bit(qgetenv(DEVNET_FILE_ENV)); - QFile file(path); - if (path.isEmpty() || !file.open(QIODevice::ReadOnly)) - return false; - const QJsonDocument document = QJsonDocument::fromJson(file.readAll()); - if (!document.isObject()) - return false; - entry = document.object(); - m_expectedIdentity = entry.value(QStringLiteral("channelId")).toString(); - } else { - QFile file(QStringLiteral(":/amm/config/networks.json")); - if (!file.open(QIODevice::ReadOnly)) - return false; - const QJsonDocument document = QJsonDocument::fromJson(file.readAll()); - if (!document.isObject()) - return false; - const QJsonObject networks = document.object(); - entry = networks.value(m_network.id).toObject(); - m_expectedIdentity = entry.value(QStringLiteral("checkpointHash")).toString(); - } - - m_network.ammProgramId = entry.value(QStringLiteral("ammProgramId")).toString(); - if (!isValidIdentity(m_expectedIdentity) - || !isLowerHex(m_network.ammProgramId, 64)) { - return false; - } - - for (const QJsonValue& value : entry.value(QStringLiteral("tokenDefinitionIds")).toArray()) { - const QString id = value.toString(); - if (!isLowerHex(id, 64)) { - m_network.tokenIds.clear(); - return false; - } - m_network.tokenIds.append(id); - } - m_network.status = QStringLiteral("network_unknown"); - return true; -} - -bool ActiveNetwork::isConfigured() const -{ - return m_network.status != QStringLiteral("config_missing"); -} - -bool ActiveNetwork::isDevnet() const -{ - return m_network.id == QStringLiteral("devnet"); -} - -bool ActiveNetwork::needsIdentityProbe() const -{ - return m_network.status == QStringLiteral("loading") - || m_network.status == QStringLiteral("network_unknown"); -} - -void ActiveNetwork::sequencerChanged(bool available) -{ - if (isConfigured()) - clearIdentity(available ? QStringLiteral("loading") : QStringLiteral("network_unknown")); -} - -void ActiveNetwork::reachabilityChanged(bool reachable, bool wasReachable) -{ - if (!isConfigured()) - return; - if (!reachable) - clearIdentity(QStringLiteral("network_unknown")); - else if (!wasReachable) - clearIdentity(QStringLiteral("loading")); -} - -void ActiveNetwork::beginIdentityProbe() -{ - if (isConfigured()) - clearIdentity(QStringLiteral("loading")); -} - -void ActiveNetwork::finishIdentityProbe(const QString& identity) -{ - if (identity.isEmpty()) { - clearIdentity(QStringLiteral("network_unknown")); - } else if (identity != m_expectedIdentity) { - clearIdentity(QStringLiteral("network_mismatch")); - } else { - m_network.status = QStringLiteral("ready"); - m_network.fingerprint = (isDevnet() ? QStringLiteral("channel:") - : QStringLiteral("block10:")) - + identity; - } -} - -bool ActiveNetwork::isValidIdentity(const QString& value) -{ - return isLowerHex(value, 64); -} - -void ActiveNetwork::clearIdentity(const QString& status) -{ - m_network.status = status; - m_network.fingerprint.clear(); -} diff --git a/apps/amm/src/ActiveNetwork.h b/apps/amm/src/ActiveNetwork.h index 9b1db9b7..771442b9 100644 --- a/apps/amm/src/ActiveNetwork.h +++ b/apps/amm/src/ActiveNetwork.h @@ -3,6 +3,11 @@ #include #include +// Network context handed to the new-position flow. The AMM deployment identity +// (ammProgramId, from $AMM_PROGRAM_BIN) and the configured token set (tokenIds, +// from $TOKENS_CONFIG) are the same sources the Swap view uses; there is no +// separate network config file or channel-identity probe. `fingerprint` binds a +// quote to the deployment so a quote can't be replayed against a different one. struct ActiveNetworkSnapshot { QString id; QString status; @@ -10,27 +15,3 @@ struct ActiveNetworkSnapshot { QString ammProgramId; QStringList tokenIds; }; - -class ActiveNetwork final { -public: - bool load(); - - const QString& status() const { return m_network.status; } - bool isConfigured() const; - bool isDevnet() const; - bool needsIdentityProbe() const; - ActiveNetworkSnapshot snapshot() const { return m_network; } - - void sequencerChanged(bool available); - void reachabilityChanged(bool reachable, bool wasReachable); - void beginIdentityProbe(); - void finishIdentityProbe(const QString& identity); - - static bool isValidIdentity(const QString& value); - -private: - void clearIdentity(const QString& status); - - ActiveNetworkSnapshot m_network; - QString m_expectedIdentity; -}; diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index 353c4a3a..0800f7d2 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -14,9 +14,6 @@ #include #include #include -#include -#include -#include #include #include #include @@ -144,46 +141,6 @@ namespace { } } -namespace { - const int CHECKPOINT_BLOCK_ID = 10; - const int BLOCK_HASH_OFFSET = 40; - const int BLOCK_HASH_SIZE = 32; - - QByteArray jsonRpcBody(const QString& method, const QJsonArray& params) - { - return QJsonDocument(QJsonObject { - { QStringLiteral("jsonrpc"), QStringLiteral("2.0") }, - { QStringLiteral("id"), 1 }, - { QStringLiteral("method"), method }, - { QStringLiteral("params"), params }, - }).toJson(QJsonDocument::Compact); - } - - QString blockHashFromResponse(const QByteArray& payload) - { - QJsonParseError parseError; - const QJsonDocument document = QJsonDocument::fromJson(payload, &parseError); - if (parseError.error != QJsonParseError::NoError || !document.isObject()) - return {}; - const QByteArray block = - QByteArray::fromBase64(document.object().value(QStringLiteral("result")).toString().toLatin1()); - if (block.size() < BLOCK_HASH_OFFSET + BLOCK_HASH_SIZE) - return {}; - return QString::fromLatin1(block.mid(BLOCK_HASH_OFFSET, BLOCK_HASH_SIZE).toHex()); - } - - QString channelIdFromResponse(const QByteArray& payload) - { - QJsonParseError parseError; - const QJsonDocument document = QJsonDocument::fromJson(payload, &parseError); - if (parseError.error != QJsonParseError::NoError || !document.isObject()) - return {}; - const QString channel = document.object().value(QStringLiteral("result")).toString(); - return ActiveNetwork::isValidIdentity(channel) ? channel : QString(); - } - -} - AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent) : AmmUiBackendSimpleSource(parent), m_logosAPI(logosAPI ? logosAPI : new LogosAPI("amm_ui", this)), @@ -192,13 +149,11 @@ AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent) m_walletController(std::make_unique( *m_wallet, QStringLiteral("AmmUI"))), m_ammClient(std::make_unique()), - m_newPosition(std::make_unique(m_wallet.get(), m_ammClient.get())), - m_net(new QNetworkAccessManager(this)) + m_newPosition(std::make_unique(m_wallet.get(), m_ammClient.get())) { setWalletStateReady(false); - m_network.load(); setNewPositionContext(m_newPosition->context( - QVariantMap(), m_network.snapshot(), false, false)); + QVariantMap(), networkSnapshot(), false, false)); connect(m_walletController.get(), &WalletController::stateChanged, this, &AmmUiBackend::syncWalletState); @@ -289,29 +244,25 @@ void AmmUiBackend::refreshNewPositionContext(QVariantMap request) else { request = m_newPositionHints; } - if (m_network.status() == QStringLiteral("network_unknown")) - probeNetworkIdentity(); setNewPositionContext(m_newPosition->context( - request, m_network.snapshot(), isWalletOpen(), refreshWalletAccounts)); + request, networkSnapshot(), isWalletOpen(), refreshWalletAccounts)); } QVariantMap AmmUiBackend::quoteNewPosition(QVariantMap request) { - return m_newPosition->quote(request, m_network.snapshot(), isWalletOpen()); + return m_newPosition->quote(request, networkSnapshot(), isWalletOpen()); } QVariantMap AmmUiBackend::submitNewPosition(QVariantMap request, QString quoteHash) { return m_newPosition->submit( - request, quoteHash, m_network.snapshot(), isWalletOpen()); + request, quoteHash, networkSnapshot(), isWalletOpen()); } void AmmUiBackend::syncWalletState() { const WalletUiState& state = m_walletController->state(); const bool walletWasOpen = isWalletOpen(); - const bool wasReachable = sequencerReachable(); - const QString previousAddress = sequencerAddr(); setIsWalletOpen(state.isWalletOpen); setWalletExists(state.walletExists); @@ -323,69 +274,71 @@ void AmmUiBackend::syncWalletState() setSequencerAddr(state.sequencerAddress); setSequencerReachable(state.sequencerReachable); - const bool addressChanged = previousAddress != state.sequencerAddress; - if (addressChanged) - m_network.sequencerChanged(!state.sequencerAddress.isEmpty()); - if (addressChanged || wasReachable != state.sequencerReachable) { - m_network.reachabilityChanged(state.sequencerReachable, wasReachable); - } if (walletWasOpen && !state.isWalletOpen) m_newPosition->clearWalletAccounts(); publishNetworkContext(); - if (state.sequencerReachable && m_network.needsIdentityProbe()) - probeNetworkIdentity(); } -void AmmUiBackend::probeNetworkIdentity() +void AmmUiBackend::publishNetworkContext() { - if (m_identityProbeInFlight - || !m_network.isConfigured() - || sequencerAddr().isEmpty()) { - return; - } - m_identityProbeInFlight = true; - m_network.beginIdentityProbe(); - publishNetworkContext(); - const QString address = sequencerAddr(); - const bool devnet = m_network.isDevnet(); - const QString method = devnet - ? QStringLiteral("getChannelId") - : QStringLiteral("getBlock"); - const QJsonArray params = devnet - ? QJsonArray() - : QJsonArray { CHECKPOINT_BLOCK_ID }; - - QNetworkRequest request{QUrl(address)}; - request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); - request.setTransferTimeout(4000); - QNetworkReply* reply = m_net->post(request, jsonRpcBody(method, params)); - connect(reply, &QNetworkReply::finished, this, [this, reply, address, devnet]() { - m_identityProbeInFlight = false; - if (address != sequencerAddr()) { - reply->deleteLater(); - probeNetworkIdentity(); - return; - } - if (!sequencerReachable()) { - reply->deleteLater(); - return; - } + setNewPositionContext(m_newPosition->context( + m_newPositionHints, networkSnapshot(), isWalletOpen(), false)); +} - const QByteArray payload = reply->readAll(); - const QString actual = devnet - ? channelIdFromResponse(payload) - : blockHashFromResponse(payload); - m_network.finishIdentityProbe(actual); - reply->deleteLater(); - publishNetworkContext(); - }); +QString AmmUiBackend::ammProgramIdHex() +{ + const QByteArray elf = loadAmmElf(); + if (elf.isEmpty()) + return QString(); + ProgramId ammId{}; + if (!amm_client_program_id_from_elf(reinterpret_cast(elf.constData()), + static_cast(elf.size()), &ammId)) { + qWarning() << "AmmUiBackend::ammProgramIdHex: amm_client_program_id_from_elf failed"; + return QString(); + } + // 32 bytes, little-endian per u32 word, lowercase hex — same encoding as + // swapExactInput's program-id and `spel program-id`. + QByteArray bytes; + bytes.reserve(32); + for (int i = 0; i < 8; ++i) { + const uint32_t word = ammId[i]; + bytes.append(static_cast(word & 0xff)); + bytes.append(static_cast((word >> 8) & 0xff)); + bytes.append(static_cast((word >> 16) & 0xff)); + bytes.append(static_cast((word >> 24) & 0xff)); + } + return QString::fromLatin1(bytes.toHex()); } -void AmmUiBackend::publishNetworkContext() +ActiveNetworkSnapshot AmmUiBackend::networkSnapshot() { - setNewPositionContext(m_newPosition->context( - m_newPositionHints, m_network.snapshot(), isWalletOpen(), false)); + ActiveNetworkSnapshot snapshot; + snapshot.id = QStringLiteral("lez"); + // Defer program/token resolution (which reaches the module) until wallet + // state is resolved; the constructor publishes an initial context before + // the module is up, and syncWalletState() republishes once it is. + if (!walletStateReady()) { + snapshot.status = QStringLiteral("loading"); + return snapshot; + } + snapshot.ammProgramId = ammProgramIdHex(); + // Bind a quote to this AMM deployment: the program id changes per deployment, + // so it doubles as the network fingerprint (a quote can't be replayed against + // a different program). Empty when AMM_PROGRAM_BIN is unset — status gates it. + snapshot.fingerprint = snapshot.ammProgramId; + // Configured token set = the TOKENS_CONFIG definition ids, the same source + // the Swap view's token picker uses (tokenList normalizes them to hex). + const QVariantList tokens = tokenList(); + for (const QVariant& entry : tokens) { + const QString id = entry.toMap().value(QStringLiteral("definitionId")).toString(); + if (!id.isEmpty()) + snapshot.tokenIds.append(id); + } + snapshot.status = snapshot.ammProgramId.isEmpty() + ? QStringLiteral("config_missing") + : QStringLiteral("ready"); + return snapshot; } QString AmmUiBackend::normalizeAccountId(const QString& id) diff --git a/apps/amm/src/AmmUiBackend.h b/apps/amm/src/AmmUiBackend.h index df694e6b..a95cff52 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -23,7 +23,6 @@ struct LogosModules; class AmmClient; class LogosWalletProvider; class NewPositionRuntime; -class QNetworkAccessManager; class WalletController; // Source-side implementation of the AmmUiBackend .rep interface. @@ -67,9 +66,18 @@ public slots: private: void syncWalletState(); - void probeNetworkIdentity(); void publishNetworkContext(); + // Builds the new-position network context from the same sources the Swap + // view uses: ammProgramId from $AMM_PROGRAM_BIN, tokenIds from + // $TOKENS_CONFIG. status is "ready" once AMM_PROGRAM_BIN resolves, else + // "config_missing". There is no separate network config or channel probe. + ActiveNetworkSnapshot networkSnapshot(); + + // 64-char lowercase-hex AMM program id derived from $AMM_PROGRAM_BIN (empty + // if unset/unreadable); matches swapExactInput's program-id encoding. + QString ammProgramIdHex(); + // Normalizes an account id given as either 64-char lowercase/uppercase hex // or base58 to lowercase hex. Returns an empty QString if `id` is neither // (or the base58 decode fails), so callers can detect and skip it. @@ -92,11 +100,7 @@ public slots: std::unique_ptr m_ammClient; std::unique_ptr m_newPosition; - QNetworkAccessManager* m_net; - - ActiveNetwork m_network; QVariantMap m_newPositionHints; - bool m_identityProbeInFlight = false; }; #endif // AMM_UI_BACKEND_H diff --git a/apps/amm/tests/cpp/ActiveNetworkTest.cpp b/apps/amm/tests/cpp/ActiveNetworkTest.cpp deleted file mode 100644 index 239f3e8d..00000000 --- a/apps/amm/tests/cpp/ActiveNetworkTest.cpp +++ /dev/null @@ -1,65 +0,0 @@ -#include "ActiveNetwork.h" - -#include -#include -#include -#include - -namespace { - bool expect(bool condition, const char* message) - { - if (!condition) - qCritical("%s", message); - return condition; - } -} - -int main() -{ - const QString identity(64, QLatin1Char('a')); - const QString programId(64, QLatin1Char('b')); - const QString tokenId(64, QLatin1Char('c')); - - QTemporaryFile config; - if (!config.open()) - return 1; - config.write(QJsonDocument(QJsonObject { - { QStringLiteral("channelId"), identity }, - { QStringLiteral("ammProgramId"), programId }, - { QStringLiteral("tokenDefinitionIds"), QJsonArray { tokenId } }, - }).toJson(QJsonDocument::Compact)); - config.flush(); - - qputenv("AMM_UI_NETWORK", "devnet"); - qputenv("AMM_UI_DEVNET_FILE", config.fileName().toLocal8Bit()); - - ActiveNetwork network; - if (!expect(network.load(), "devnet config should load")) - return 1; - if (!expect(network.snapshot().status == QStringLiteral("network_unknown"), - "loaded network should await identity")) - return 1; - - network.sequencerChanged(true); - network.finishIdentityProbe(QString(64, QLatin1Char('d'))); - if (!expect(network.status() == QStringLiteral("network_mismatch"), - "wrong identity should block the network")) - return 1; - - network.reachabilityChanged(false, true); - network.reachabilityChanged(true, false); - network.finishIdentityProbe(identity); - const ActiveNetworkSnapshot snapshot = network.snapshot(); - if (!expect(snapshot.status == QStringLiteral("ready"), - "matching identity should ready the network")) - return 1; - if (!expect(snapshot.fingerprint == QStringLiteral("channel:") + identity, - "devnet fingerprint should use channel identity")) - return 1; - if (!expect(snapshot.ammProgramId == programId - && snapshot.tokenIds == QStringList { tokenId }, - "network snapshot should preserve configured program and tokens")) - return 1; - - return 0; -} From 34c6c5e82156568906d490647325d61386da2f40 Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:11:17 +0200 Subject: [PATCH 09/10] fix(wallet): send tx instruction as a byte string, not QVariantList MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LogosWalletProvider::submitPublicTransaction passed the RISC0 instruction words to send_generic_public_transaction as a QVariantList. That param is a byte string (bstr): the module's QtRO glue mangles a list-of-u32 crossing the module process boundary, so the guest deserialized a corrupted Instruction variant and panicked, e.g. Guest panicked: called `Result::unwrap()` on an `Err` value: Custom("invalid value: integer `53765`, expected variant index 0 <= i < 10") (53765 = 0x0000D205 — the guest read a 4-byte word where 0x05, the AddLiquidity variant, is a single byte.) This broke every submit through the shared provider, including the new-position / add-liquidity flow. Send the little-endian bytes of the u32 words as a QByteArray instead — the same encoding the AMM swap path already uses (it calls the module directly and never went through this provider). signingRequirements stays a QVariantList; only the instruction needed the bstr encoding. --- .../shared/wallet/src/LogosWalletProvider.cpp | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/apps/shared/wallet/src/LogosWalletProvider.cpp b/apps/shared/wallet/src/LogosWalletProvider.cpp index 3e569525..42fd4e66 100644 --- a/apps/shared/wallet/src/LogosWalletProvider.cpp +++ b/apps/shared/wallet/src/LogosWalletProvider.cpp @@ -1,5 +1,6 @@ #include "LogosWalletProvider.h" +#include #include #include #include @@ -274,16 +275,26 @@ WalletSubmission LogosWalletProvider::submitPublicTransaction( for (bool required : transaction.signingRequirements) signingRequirements.append(required); - QVariantList instruction; - instruction.reserve(transaction.instruction.size()); - for (quint32 word : transaction.instruction) - instruction.append(word); + // `send_generic_public_transaction`'s `instruction` param is a byte string + // (bstr). Passing a QVariantList makes the module's QtRO glue mangle it, + // so the guest reads a garbage Instruction variant. Send the little-endian + // bytes of the u32 words instead — same encoding the AMM swap path uses. + // See docs/amm-swap-qtro-serialization-bug.md. + QByteArray instructionBytes; + instructionBytes.reserve( + static_cast(transaction.instruction.size() * sizeof(quint32))); + for (const quint32 word : transaction.instruction) { + instructionBytes.append(static_cast(word & 0xff)); + instructionBytes.append(static_cast((word >> 8) & 0xff)); + instructionBytes.append(static_cast((word >> 16) & 0xff)); + instructionBytes.append(static_cast((word >> 24) & 0xff)); + } const QString response = m_impl->logos->logos_execution_zone.send_generic_public_transaction( transaction.accountIds, signingRequirements, - QVariant::fromValue(instruction), + QVariant::fromValue(instructionBytes), transaction.programId); QJsonParseError parseError; From c53331934bc2ed313a664b9f02ca93543da367d0 Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:43:09 +0200 Subject: [PATCH 10/10] fix(amm-ui): cache network snapshot to avoid remote calls on the hot path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit networkSnapshot() rebuilt the new-position network context on every call — deriving ammProgramId from $AMM_PROGRAM_BIN and resolving the $TOKENS_CONFIG token ids, which run tokenList()'s remote account_id_from_base58 conversions. It is called on the quote hot path (every keystroke) and, critically, from inside runtime reply callbacks: after a create-pool submit, pool activation runs refreshContext() from within a quoteNewPosition reply, so the nested synchronous remote calls reentered the module connection and hung the reply. refreshNewPositionContext never completed, contextLoading never cleared, and the token selectors (gated on !contextLoading) stayed disabled — the view became unusable after creating a pool. $AMM_PROGRAM_BIN and $TOKENS_CONFIG are fixed for the process lifetime, so resolve ammProgramId and the token ids once and cache them; networkSnapshot() now returns the cached values with no per-call remote work. (Still gated to "loading" until wallet state resolves, so the one-time resolve happens at startup, not inside a callback.) --- apps/amm/src/AmmUiBackend.cpp | 33 ++++++++++++++++++++++----------- apps/amm/src/AmmUiBackend.h | 9 +++++++++ 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index 0800f7d2..acb5396e 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -322,20 +322,31 @@ ActiveNetworkSnapshot AmmUiBackend::networkSnapshot() snapshot.status = QStringLiteral("loading"); return snapshot; } - snapshot.ammProgramId = ammProgramIdHex(); + // Resolve the AMM deployment id ($AMM_PROGRAM_BIN) and configured token set + // ($TOKENS_CONFIG) ONCE and cache — they're fixed for the process lifetime. + // networkSnapshot() runs on the quote hot path and from inside runtime reply + // callbacks, and tokenList() makes remote base58 conversions; recomputing each + // call reenters the module connection and hangs the reply. + if (!m_networkResolved) { + m_ammProgramIdCache = ammProgramIdHex(); + m_tokenIdsCache.clear(); + // Configured token set = the TOKENS_CONFIG definition ids, the same source + // the Swap view's token picker uses (tokenList normalizes them to hex). + const QVariantList tokens = tokenList(); + for (const QVariant& entry : tokens) { + const QString id = entry.toMap().value(QStringLiteral("definitionId")).toString(); + if (!id.isEmpty()) + m_tokenIdsCache.append(id); + } + m_networkResolved = true; + } + snapshot.ammProgramId = m_ammProgramIdCache; // Bind a quote to this AMM deployment: the program id changes per deployment, // so it doubles as the network fingerprint (a quote can't be replayed against // a different program). Empty when AMM_PROGRAM_BIN is unset — status gates it. - snapshot.fingerprint = snapshot.ammProgramId; - // Configured token set = the TOKENS_CONFIG definition ids, the same source - // the Swap view's token picker uses (tokenList normalizes them to hex). - const QVariantList tokens = tokenList(); - for (const QVariant& entry : tokens) { - const QString id = entry.toMap().value(QStringLiteral("definitionId")).toString(); - if (!id.isEmpty()) - snapshot.tokenIds.append(id); - } - snapshot.status = snapshot.ammProgramId.isEmpty() + snapshot.fingerprint = m_ammProgramIdCache; + snapshot.tokenIds = m_tokenIdsCache; + snapshot.status = m_ammProgramIdCache.isEmpty() ? QStringLiteral("config_missing") : QStringLiteral("ready"); return snapshot; diff --git a/apps/amm/src/AmmUiBackend.h b/apps/amm/src/AmmUiBackend.h index a95cff52..854a105b 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -101,6 +102,14 @@ public slots: std::unique_ptr m_newPosition; QVariantMap m_newPositionHints; + + // Network context is derived from $AMM_PROGRAM_BIN + $TOKENS_CONFIG, which are + // fixed for the process lifetime — resolve them once and cache. networkSnapshot() + // runs on the hot path (every quote) and from inside runtime callbacks, and + // tokenList() makes remote base58 conversions, so it must not recompute each call. + bool m_networkResolved = false; + QString m_ammProgramIdCache; + QStringList m_tokenIdsCache; }; #endif // AMM_UI_BACKEND_H