From 295c4efece3f9393638bb8cd48f90d350f88b235 Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Tue, 30 Jun 2026 11:42:01 -0300 Subject: [PATCH] feat(amm): complete wallet-backed AMM flow --- apps/amm/CMakeLists.txt | 32 + apps/amm/README.md | 10 + apps/amm/config/amm-programs.json | 31 + apps/amm/config/ata-programs.json | 16 + apps/amm/config/stablecoin-programs.json | 17 + apps/amm/config/supported-chains.json | 14 + apps/amm/config/token-programs.json | 35 + apps/amm/config/twap-oracle-programs.json | 15 + apps/amm/flake.lock | 10 +- apps/amm/flake.nix | 65 +- apps/amm/qml/Main.qml | 42 +- apps/amm/qml/NavBar.qml | 18 +- .../components/liquidity/AddLiquidityForm.qml | 7 +- .../liquidity/LiquidityConfirmationDialog.qml | 2 +- .../liquidity/PoolPositionSummary.qml | 4 +- .../liquidity/RemoveLiquidityForm.qml | 6 +- .../components/liquidity/TokenAmountInput.qml | 4 +- .../qml/components/shared/SuccessToast.qml | 96 +- apps/amm/qml/components/swap/SwapCard.qml | 17 +- .../swap/SwapConfirmationDialog.qml | 2 +- apps/amm/qml/components/swap/TokenInput.qml | 12 +- .../qml/components/wallet/AccountControl.qml | 301 ++- .../qml/components/wallet/AccountDelegate.qml | 25 +- .../components/wallet/BackupWalletDialog.qml | 93 + .../components/wallet/CreateAccountDialog.qml | 12 +- .../components/wallet/CreateWalletDialog.qml | 15 +- .../qml/components/wallet/LogosCopyButton.qml | 14 +- .../components/wallet/WalletIconButton.qml | 7 +- apps/amm/qml/pages/LiquidityPage.qml | 81 +- apps/amm/qml/pages/SwapPage.qml | 119 +- .../{DummyPoolState.qml => PoolState.qml} | 78 +- .../{DummySwapState.qml => SwapState.qml} | 0 apps/amm/result | 1 - apps/amm/src/AccountModel.cpp | 26 +- apps/amm/src/AccountModel.h | 2 + apps/amm/src/AmmUiBackend.cpp | 1856 ++++++++++++++++- apps/amm/src/AmmUiBackend.h | 66 +- apps/amm/src/AmmUiBackend.rep | 23 +- 38 files changed, 2852 insertions(+), 322 deletions(-) create mode 100644 apps/amm/config/amm-programs.json create mode 100644 apps/amm/config/ata-programs.json create mode 100644 apps/amm/config/stablecoin-programs.json create mode 100644 apps/amm/config/supported-chains.json create mode 100644 apps/amm/config/token-programs.json create mode 100644 apps/amm/config/twap-oracle-programs.json create mode 100644 apps/amm/qml/components/wallet/BackupWalletDialog.qml rename apps/amm/qml/state/{DummyPoolState.qml => PoolState.qml} (71%) rename apps/amm/qml/state/{DummySwapState.qml => SwapState.qml} (100%) delete mode 120000 apps/amm/result diff --git a/apps/amm/CMakeLists.txt b/apps/amm/CMakeLists.txt index 10f4346..0f96484 100644 --- a/apps/amm/CMakeLists.txt +++ b/apps/amm/CMakeLists.txt @@ -1,6 +1,25 @@ cmake_minimum_required(VERSION 3.14) project(AmmUiPlugin LANGUAGES CXX) +include(GNUInstallDirs) + +add_compile_definitions(AMM_UI_ASSET_DIR="${CMAKE_INSTALL_FULL_LIBDIR}") + +set(AMM_UI_PROGRAM_DIR "" CACHE PATH "Directory containing amm.bin and token.bin") +if(NOT AMM_UI_PROGRAM_DIR AND DEFINED ENV{AMM_UI_PROGRAM_DIR}) + set(AMM_UI_PROGRAM_DIR "$ENV{AMM_UI_PROGRAM_DIR}" CACHE PATH "Directory containing amm.bin and token.bin" FORCE) +endif() + +if(AMM_UI_PROGRAM_DIR) + foreach(_program_binary IN ITEMS amm.bin token.bin) + if(NOT EXISTS "${AMM_UI_PROGRAM_DIR}/${_program_binary}") + message(FATAL_ERROR "Required AMM UI program binary not found: ${AMM_UI_PROGRAM_DIR}/${_program_binary}") + endif() + endforeach() +else() + message(WARNING "AMM_UI_PROGRAM_DIR not set; amm.bin and token.bin will not be installed") +endif() + if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT}) include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake) else() @@ -26,4 +45,17 @@ logos_module( LINK_LIBRARIES Qt6::Gui Qt6::Network + ${CMAKE_DL_LIBS} ) + +install(DIRECTORY config + DESTINATION ${CMAKE_INSTALL_LIBDIR} +) + +if(AMM_UI_PROGRAM_DIR) + install(FILES + ${AMM_UI_PROGRAM_DIR}/amm.bin + ${AMM_UI_PROGRAM_DIR}/token.bin + DESTINATION ${CMAKE_INSTALL_LIBDIR}/programs + ) +endif() diff --git a/apps/amm/README.md b/apps/amm/README.md index e1d8ff1..ee48b87 100644 --- a/apps/amm/README.md +++ b/apps/amm/README.md @@ -27,6 +27,16 @@ Account/keystore sharing follows the runtime: startup the backend **adopts** the already-open wallet (see `openOrAdoptWallet()`), surfacing **shared** accounts across apps. +Deployment config is chain-aware. `config/supported-chains.json` stores each +supported chain identity, including the deterministic block-1 fingerprint +(`genesisBlockHash` + `genesisBlockSignature`). Each `config/*-programs.json` +deployment uses a short `chainRef` plus program-specific IDs and transaction +hashes. When a wallet connects, the backend reads the wallet's current +`sequencer_addr`, probes block 1, selects the matching chain deployment, and +verifies configured deployment transactions. If no matching AMM deployment is +configured or deployed on that chain, the UI shows **Unsupported chain** instead +of submitting transactions. + > 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()` / diff --git a/apps/amm/config/amm-programs.json b/apps/amm/config/amm-programs.json new file mode 100644 index 0000000..a4a78bc --- /dev/null +++ b/apps/amm/config/amm-programs.json @@ -0,0 +1,31 @@ +{ + "chains": [ + { + "chainRef": "local.v0.2.0-rc5", + "programs": [ + { + "name": "AMM Program", + "id": "EEkCMCyv7at1incttPxmc6Y1cgwmnzNPKhMwB5ypnKZv", + "imageIdHex": "c4ad5271f1d1a076b6c9bcb5ca4e62812c88728b6c3cd00f6634f7305ca5ddf5", + "deploymentTransaction": "35c8b75d26e2d1e84f81a77f69e1d17fe31b1e57f824a43599fa5f84926385cf", + "tokenProgram": "2NPgqw4JxSrkV4MiMAQ4MgW6vcbxXrWkGoWmMMxSaYRV", + "twapOracleProgram": "AjAJzR2rrEyCvwpemcjC9tbzre38WnXTX14YuT6cWugF", + "abi": "legacy-v0.2.0-rc3", + "configs": [], + "pools": [ + { + "name": "AMM197A/AMM197B", + "account": "FPCZi416PrSbWBo8gMAuDqXk3DH8nmXw5PFn3NbCsod6", + "tokenA": "AMM197A", + "tokenB": "AMM197B", + "vaultA": "DmoYscM1fKv5fur9kw6wtmVGkxLvjsBvD7L3Tdjiaxoe", + "vaultB": "EkVPMUmGQbfo7zZ7dWnwnjMZCQqySEZ16vjN99PEC3dN", + "lpDefinitionAccount": "2P5kBN1SfVcjJYCGFggzCDUHKZvVaMsbM9tFG4xj4NVy", + "creationTransaction": "4ff22fcc9b70ae0b748b1cb186b547a1b35f76fd55eb96ff21a780b660322ac6" + } + ] + } + ] + } + ] +} diff --git a/apps/amm/config/ata-programs.json b/apps/amm/config/ata-programs.json new file mode 100644 index 0000000..17e73b6 --- /dev/null +++ b/apps/amm/config/ata-programs.json @@ -0,0 +1,16 @@ +{ + "chains": [ + { + "chainRef": "local.v0.2.0-rc5", + "programs": [ + { + "name": "Associated Token Account Program", + "id": "D7TdWT1wVpDiH9wfLBdLfE2R1Kgy8FNW9PWP62uRqu9x", + "imageIdHex": "b3f3d685c4bc2ed261503262fd650be78d104c1179ce30ef35738b36d7e11d97", + "deploymentTransaction": "988f6f08ac72df65c479e06d475ec08d0dc42c4dc060c4b179f2754094821449", + "tokenProgram": "2NPgqw4JxSrkV4MiMAQ4MgW6vcbxXrWkGoWmMMxSaYRV" + } + ] + } + ] +} diff --git a/apps/amm/config/stablecoin-programs.json b/apps/amm/config/stablecoin-programs.json new file mode 100644 index 0000000..afbf609 --- /dev/null +++ b/apps/amm/config/stablecoin-programs.json @@ -0,0 +1,17 @@ +{ + "chains": [ + { + "chainRef": "local.v0.2.0-rc5", + "programs": [ + { + "name": "Stablecoin Program", + "id": "2hMHPg6CRwyNqcou3sP6RTnNpJXnVPBWE8Za8aUFAMQu", + "imageIdHex": "1931da57b56bc15a1c26a7965e1632f59dd2895affbf1258ce49e0268265a322", + "deploymentTransaction": "85ee89661f478cdad87b4516b381b800f9185b55276c21d37666b30682e171be", + "tokenProgram": "2NPgqw4JxSrkV4MiMAQ4MgW6vcbxXrWkGoWmMMxSaYRV", + "twapOracleProgram": "AjAJzR2rrEyCvwpemcjC9tbzre38WnXTX14YuT6cWugF" + } + ] + } + ] +} diff --git a/apps/amm/config/supported-chains.json b/apps/amm/config/supported-chains.json new file mode 100644 index 0000000..87b6f46 --- /dev/null +++ b/apps/amm/config/supported-chains.json @@ -0,0 +1,14 @@ +{ + "chains": [ + { + "alias": "local.v0.2.0-rc5", + "name": "LEZ local", + "network": "http://127.0.0.1:3040", + "chainId": "lez:block-1:e437c44cac2511b603b96195e6b490217da6ba47a326a1fa6c939167ec3e8656:032f82b1ba318b0028f853d25bbc2da49ace15c100b7337fc06eddcf4bfa777906409810c8e09e57831000462a857c26631bb1b8087391e34a00ab3d2ebf8739", + "genesisBlockId": 1, + "genesisBlockHash": "e437c44cac2511b603b96195e6b490217da6ba47a326a1fa6c939167ec3e8656", + "genesisBlockSignature": "032f82b1ba318b0028f853d25bbc2da49ace15c100b7337fc06eddcf4bfa777906409810c8e09e57831000462a857c26631bb1b8087391e34a00ab3d2ebf8739", + "default": true + } + ] +} diff --git a/apps/amm/config/token-programs.json b/apps/amm/config/token-programs.json new file mode 100644 index 0000000..f32e867 --- /dev/null +++ b/apps/amm/config/token-programs.json @@ -0,0 +1,35 @@ +{ + "chains": [ + { + "chainRef": "local.v0.2.0-rc5", + "programs": [ + { + "name": "Token Program", + "id": "2NPgqw4JxSrkV4MiMAQ4MgW6vcbxXrWkGoWmMMxSaYRV", + "imageIdHex": "14568940ba11698d23703dff8d9df6703c32e2412f89ff32efc46a5cad70f500", + "deploymentTransaction": "27401f74353c9863fecb27889d2e0fdaccb7e0a652a284929c67bf0fdc6c5470", + "definitions": [ + { + "symbol": "AMM197A", + "name": "AMM197A", + "color": "#2775ca", + "letter": "A", + "usdPrice": 1, + "definitionAccount": "HjkLvjKqUt7PhUCs6CUEbqmrjAAbUJmeQg6TGkTAzLHS", + "mintTransaction": "ec430fa48a6990dd31fa3f075eb16f842392bf62d0d6701b46574a3fd4549187" + }, + { + "symbol": "AMM197B", + "name": "AMM197B", + "color": "#26a17b", + "letter": "B", + "usdPrice": 1, + "definitionAccount": "2b4UuHsax4pELYzQBjQe5DieHWN3zn9BrrtTGhpbYX71", + "mintTransaction": "2edf40aefe53a8dcb2e3df8e22426841c4c9503d42179665cb8ab94434365e2a" + } + ] + } + ] + } + ] +} diff --git a/apps/amm/config/twap-oracle-programs.json b/apps/amm/config/twap-oracle-programs.json new file mode 100644 index 0000000..51d5e08 --- /dev/null +++ b/apps/amm/config/twap-oracle-programs.json @@ -0,0 +1,15 @@ +{ + "chains": [ + { + "chainRef": "local.v0.2.0-rc5", + "programs": [ + { + "name": "TWAP Oracle Program", + "id": "AjAJzR2rrEyCvwpemcjC9tbzre38WnXTX14YuT6cWugF", + "imageIdHex": "90861a75ed743edaf5d2ab7dcc7563157435072b6c10f3db5cf50aa93322018c", + "deploymentTransaction": "54bc196c66beefe4b8ea5ab8564743332cc6b690c0b1908c55aa1407347af488" + } + ] + } + ] +} diff --git a/apps/amm/flake.lock b/apps/amm/flake.lock index 9bc7175..000a3f4 100644 --- a/apps/amm/flake.lock +++ b/apps/amm/flake.lock @@ -26763,7 +26763,15 @@ "root": { "inputs": { "logos-module-builder": "logos-module-builder", - "logos_execution_zone": "logos_execution_zone" + "logos-standalone-app": [ + "logos-module-builder", + "logos-standalone-app" + ], + "logos_execution_zone": "logos_execution_zone", + "nixpkgs": [ + "logos-module-builder", + "nixpkgs" + ] } }, "rust-overlay": { diff --git a/apps/amm/flake.nix b/apps/amm/flake.nix index 0811f1b..c97f5bf 100644 --- a/apps/amm/flake.nix +++ b/apps/amm/flake.nix @@ -3,18 +3,79 @@ inputs = { logos-module-builder.url = "github:logos-co/logos-module-builder"; + nixpkgs.follows = "logos-module-builder/nixpkgs"; + logos-standalone-app.follows = "logos-module-builder/logos-standalone-app"; # 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 + # it as a module dependency. This rev pins LEZ (lssa) at d2e9400, 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"; }; - outputs = inputs@{ logos-module-builder, ... }: + outputs = inputs@{ logos-module-builder, logos-standalone-app, nixpkgs, ... }: + let + systems = [ "aarch64-darwin" "x86_64-darwin" "aarch64-linux" "x86_64-linux" ]; + forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f (import nixpkgs { inherit system; })); + + fixedStandalonePackages = forAllSystems (pkgs: + let + base = logos-standalone-app.packages.${pkgs.system}; + qtLibPath = pkgs.lib.makeLibraryPath ([ + pkgs.qt6.qtbase + pkgs.qt6.qtremoteobjects + pkgs.qt6.qtdeclarative + pkgs.qt6.qtwebview + pkgs.zstd + pkgs.krb5 + pkgs.zlib + pkgs.glib + pkgs.stdenv.cc.cc + pkgs.freetype + pkgs.fontconfig + pkgs.boost + pkgs.openssl + ] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ + pkgs.libglvnd + pkgs.mesa + pkgs.xorg.libX11 + pkgs.xorg.libXext + pkgs.xorg.libXrender + pkgs.xorg.libXrandr + pkgs.xorg.libXcursor + pkgs.xorg.libXi + pkgs.xorg.libXfixes + pkgs.xorg.libxcb + ]); + fixedApp = base.default.overrideAttrs (old: { + inherit qtLibPath; + qtWrapperArgs = (old.qtWrapperArgs or [ ]) ++ [ + "--prefix" "LD_LIBRARY_PATH" ":" qtLibPath + "--prefix" "QT_PLUGIN_PATH" ":" old.qtPluginPath + "--prefix" "QML_IMPORT_PATH" ":" old.qmlImportPath + "--prefix" "QML2_IMPORT_PATH" ":" old.qmlImportPath + ]; + }); + in base // { + app = fixedApp; + default = fixedApp; + }); + + fixedStandalone = logos-standalone-app // { + packages = fixedStandalonePackages; + }; + in logos-module-builder.lib.mkLogosQmlModule { src = ./.; configFile = ./metadata.json; flakeInputs = inputs; + logosStandalone = fixedStandalone; + preConfigure = '' + export AMM_UI_PROGRAM_DIR=${inputs.logos_execution_zone.inputs."logos-execution-zone"}/artifacts/program_methods + ''; + postInstall = '' + mkdir -p "$out/lib/config" + cp -r ${./config}/. "$out/lib/config/" + ''; }; } diff --git a/apps/amm/qml/Main.qml b/apps/amm/qml/Main.qml index 792f011..09d4a92 100644 --- a/apps/amm/qml/Main.qml +++ b/apps/amm/qml/Main.qml @@ -14,6 +14,18 @@ Item { readonly property var accountModel: logos.model("amm_ui", "accountModel") property bool ready: false + readonly property bool deploymentNetworkMatched: !root.ready || !root.backend || root.backend.deploymentNetworkMatched + readonly property bool deploymentIdentityPending: root.ready + && root.backend + && root.backend.isWalletOpen + && root.backend.deploymentIdentityPending + readonly property bool unsupportedChain: root.ready + && root.backend + && root.backend.isWalletOpen + && !root.backend.deploymentIdentityPending + && !root.backend.deploymentNetworkMatched + readonly property var deploymentTokens: root.deploymentNetworkMatched && root.backend ? root.backend.deploymentTokens : [] + readonly property var deploymentPoolConfig: root.deploymentNetworkMatched && root.backend ? root.backend.deploymentPool : ({}) Connections { target: logos @@ -36,20 +48,25 @@ Item { anchors.right: parent.right z: 101 - readonly property bool show: root.ready - && root.backend - && root.backend.isWalletOpen - && root.backend.sequencerAddr.length > 0 - && !root.backend.sequencerReachable + readonly property bool show: root.deploymentIdentityPending + || root.unsupportedChain + || (root.ready + && root.backend + && root.backend.isWalletOpen + && root.backend.sequencerAddr.length > 0 + && !root.backend.sequencerReachable) height: show ? 32 : 0 visible: height > 0 clip: true color: Theme.palette.warning + Accessible.role: Accessible.AlertMessage + Accessible.name: bannerText.text Behavior on height { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } } Text { + id: bannerText anchors.centerIn: parent width: parent.width - 40 horizontalAlignment: Text.AlignHCenter @@ -57,7 +74,11 @@ Item { font.pixelSize: 12 font.weight: Font.Medium color: Theme.palette.background - text: qsTr("Unable to connect to network") + text: root.deploymentIdentityPending + ? qsTr("Checking chain") + : root.unsupportedChain + ? qsTr("Unsupported chain") + : qsTr("Unable to connect to network") } } @@ -82,11 +103,20 @@ Item { SwapPage { anchors.fill: parent + backend: root.ready ? root.backend : null + tokens: root.deploymentTokens + poolConfig: root.deploymentPoolConfig + unsupportedChain: root.unsupportedChain + selectedWalletAccount: navbar.selectedAddress visible: navbar.currentIndex === 0 } LiquidityPage { anchors.fill: parent + backend: root.ready ? root.backend : null + poolConfig: root.deploymentPoolConfig + unsupportedChain: root.unsupportedChain + selectedWalletAccount: navbar.selectedAddress visible: navbar.currentIndex === 1 } } diff --git a/apps/amm/qml/NavBar.qml b/apps/amm/qml/NavBar.qml index f5b7857..3378134 100644 --- a/apps/amm/qml/NavBar.qml +++ b/apps/amm/qml/NavBar.qml @@ -24,6 +24,11 @@ Item { implicitHeight: 56 + function selectTab(index) { + root.currentIndex = index + root.tabChanged(index) + } + Rectangle { anchors.fill: parent color: Theme.palette.background @@ -63,12 +68,20 @@ Item { delegate: Rectangle { readonly property bool active: root.currentIndex === index + activeFocusOnTab: true height: 36 width: tabLabel.implicitWidth + 28 radius: 18 color: active ? Theme.palette.backgroundSecondary : "transparent" + border.width: activeFocus ? 1 : 0 + border.color: Theme.palette.overlayOrange + Accessible.role: Accessible.PageTab + Accessible.name: modelData + Accessible.checked: active Behavior on color { ColorAnimation { duration: 150 } } + Keys.onReturnPressed: root.selectTab(index) + Keys.onSpacePressed: root.selectTab(index) Text { id: tabLabel @@ -84,10 +97,7 @@ Item { MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor - onClicked: { - root.currentIndex = index - root.tabChanged(index) - } + onClicked: root.selectTab(index) } } } diff --git a/apps/amm/qml/components/liquidity/AddLiquidityForm.qml b/apps/amm/qml/components/liquidity/AddLiquidityForm.qml index db8c59b..9b5ce7e 100644 --- a/apps/amm/qml/components/liquidity/AddLiquidityForm.qml +++ b/apps/amm/qml/components/liquidity/AddLiquidityForm.qml @@ -7,7 +7,7 @@ import "../../state" Rectangle { id: root - required property DummyPoolState poolState + required property PoolState poolState property real slippageTolerancePercent: 0.5 property string amountA: "" @@ -24,7 +24,7 @@ Rectangle { 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 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) @@ -207,13 +207,16 @@ Rectangle { return { "action": "add", "actualA": root.preview.actualA, + "actualAValue": Math.floor(root.preview.actualA), "actualB": root.preview.actualB, + "actualBValue": Math.floor(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), + "minLpReceivedAmount": root.minLpReceived, "slippageTolerance": root.poolState.formatPercent(root.slippageTolerancePercent), "tokenA": root.poolState.tokenA, "tokenB": root.poolState.tokenB diff --git a/apps/amm/qml/components/liquidity/LiquidityConfirmationDialog.qml b/apps/amm/qml/components/liquidity/LiquidityConfirmationDialog.qml index 16b0dc6..c4dd276 100644 --- a/apps/amm/qml/components/liquidity/LiquidityConfirmationDialog.qml +++ b/apps/amm/qml/components/liquidity/LiquidityConfirmationDialog.qml @@ -206,7 +206,7 @@ FocusScope { activeFocusOnTab: true focusPolicy: Qt.StrongFocus hoverEnabled: true - text: qsTr("Confirm") + text: qsTr("Submit") Accessible.name: confirmButton.text diff --git a/apps/amm/qml/components/liquidity/PoolPositionSummary.qml b/apps/amm/qml/components/liquidity/PoolPositionSummary.qml index 2466ae7..937359b 100644 --- a/apps/amm/qml/components/liquidity/PoolPositionSummary.qml +++ b/apps/amm/qml/components/liquidity/PoolPositionSummary.qml @@ -5,8 +5,8 @@ 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.") + required property PoolState poolState + readonly property string estimateHelp: qsTr("This value is estimated from the current testnet reserves and your share of total LP supply.") color: "#151515" implicitHeight: content.implicitHeight + 20 diff --git a/apps/amm/qml/components/liquidity/RemoveLiquidityForm.qml b/apps/amm/qml/components/liquidity/RemoveLiquidityForm.qml index b916a74..4d3e244 100644 --- a/apps/amm/qml/components/liquidity/RemoveLiquidityForm.qml +++ b/apps/amm/qml/components/liquidity/RemoveLiquidityForm.qml @@ -7,7 +7,7 @@ import "../../state" Rectangle { id: root - required property DummyPoolState poolState + required property PoolState poolState property real slippageTolerancePercent: 0.5 property int burnAmount: 0 @@ -23,7 +23,7 @@ Rectangle { 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") + 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) @@ -478,7 +478,9 @@ Rectangle { "burnPercent": root.poolState.formatPercent(root.removePercent), "burnText": root.poolState.formatLpAmount(root.preview.burnedLp), "minTokenAReceived": root.poolState.formatTokenAmount(root.minTokenAReceived, root.poolState.tokenA), + "minTokenAReceivedAmount": root.minTokenAReceived, "minTokenBReceived": root.poolState.formatTokenAmount(root.minTokenBReceived, root.poolState.tokenB), + "minTokenBReceivedAmount": root.minTokenBReceived, "postRemovalShare": root.poolState.formatPoolShare(root.preview.newUserShare), "slippageTolerance": root.poolState.formatPercent(root.slippageTolerancePercent), "tokenA": root.poolState.tokenA, diff --git a/apps/amm/qml/components/liquidity/TokenAmountInput.qml b/apps/amm/qml/components/liquidity/TokenAmountInput.qml index 120be57..cce723b 100644 --- a/apps/amm/qml/components/liquidity/TokenAmountInput.qml +++ b/apps/amm/qml/components/liquidity/TokenAmountInput.qml @@ -69,13 +69,13 @@ Rectangle { color: "#E7E1D8" font.bold: true font.pixelSize: 18 - inputMethodHints: Qt.ImhFormattedNumbersOnly + inputMethodHints: Qt.ImhDigitsOnly placeholderText: qsTr("0") selectByMouse: true selectedTextColor: "#151515" selectionColor: "#F26A21" validator: RegularExpressionValidator { - regularExpression: /[0-9]*([.][0-9]*)?/ + regularExpression: /[0-9]*/ } Accessible.name: root.label diff --git a/apps/amm/qml/components/shared/SuccessToast.qml b/apps/amm/qml/components/shared/SuccessToast.qml index 0c24253..1091d6e 100644 --- a/apps/amm/qml/components/shared/SuccessToast.qml +++ b/apps/amm/qml/components/shared/SuccessToast.qml @@ -1,4 +1,5 @@ import QtQuick 2.15 +import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 Item { @@ -6,8 +7,11 @@ Item { property string message: "" property string detail: "" + property string transactionId: "" + property string status: "success" + property bool copied: false property bool open: false - property int duration: 3600 + property int duration: 7000 height: implicitHeight implicitHeight: toast.implicitHeight @@ -15,9 +19,28 @@ Item { visible: root.open || fadeOut.running z: 30 - function show(nextMessage, nextDetail) { + TextEdit { id: clipboardProxy; visible: false } + + function shortTransactionId(value) { + return value.length > 18 ? value.substring(0, 10) + "..." + value.slice(-8) : value; + } + + function copyTransactionId() { + if (root.transactionId.length === 0) return; + clipboardProxy.text = root.transactionId; + clipboardProxy.selectAll(); + clipboardProxy.copy(); + clipboardProxy.deselect(); + clipboardProxy.text = ""; + root.copied = true; + } + + function show(nextMessage, nextDetail, nextTransactionId, nextStatus) { root.message = nextMessage; root.detail = nextDetail || ""; + root.transactionId = nextTransactionId || ""; + root.status = nextStatus || "success"; + root.copied = false; root.open = true; dismissTimer.restart(); } @@ -28,7 +51,12 @@ Item { interval: root.duration repeat: false - onTriggered: root.open = false + onTriggered: { + if (copyButton.activeFocus || hoverGuard.containsMouse) + restart() + else + root.open = false + } } Behavior on opacity { @@ -47,8 +75,19 @@ Item { color: "#20201F" implicitHeight: Math.max(50, toastContent.implicitHeight + 18) radius: 8 - border.color: "#4D3A2E" + border.color: root.status === "error" ? "#6A2E2E" : "#4D3A2E" border.width: 1 + Accessible.role: Accessible.AlertMessage + Accessible.name: root.detail.length > 0 + ? qsTr("%1. %2").arg(root.message).arg(root.detail) + : root.message + + MouseArea { + id: hoverGuard + anchors.fill: parent + acceptedButtons: Qt.NoButton + hoverEnabled: true + } RowLayout { id: toastContent @@ -62,7 +101,7 @@ Item { } Rectangle { - color: "#78C88D" + color: root.status === "error" ? "#D75C5C" : "#78C88D" radius: 6 Layout.alignment: Qt.AlignTop @@ -90,14 +129,59 @@ Item { Text { color: "#B8ADA3" - elide: Text.ElideRight font.pixelSize: 12 + maximumLineCount: 3 text: root.detail visible: root.detail.length > 0 + wrapMode: Text.WordWrap + + Layout.fillWidth: true + } + + Text { + color: "#F2D8C7" + elide: Text.ElideMiddle + font.pixelSize: 12 + text: qsTr("Tx %1").arg(root.shortTransactionId(root.transactionId)) + visible: root.transactionId.length > 0 Layout.fillWidth: true } } + + Button { + id: copyButton + + Accessible.name: root.copied ? qsTr("Transaction id copied") : qsTr("Copy transaction id") + Accessible.role: Accessible.Button + activeFocusOnTab: true + focusPolicy: Qt.StrongFocus + text: root.copied ? qsTr("Copied") : qsTr("Copy tx") + visible: root.transactionId.length > 0 + + Layout.alignment: Qt.AlignVCenter + Layout.preferredHeight: 30 + Layout.preferredWidth: copyText.implicitWidth + 18 + + contentItem: Text { + id: copyText + + color: "#E7E1D8" + font.bold: true + font.pixelSize: 11 + horizontalAlignment: Text.AlignHCenter + text: copyButton.text + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + border.color: copyButton.activeFocus ? "#F2D8C7" : "transparent" + color: copyButton.hovered ? "#F26A21" : "#2B2724" + radius: 6 + } + + onClicked: root.copyTransactionId() + } } } } diff --git a/apps/amm/qml/components/swap/SwapCard.qml b/apps/amm/qml/components/swap/SwapCard.qml index 5a3f601..dc6dce8 100644 --- a/apps/amm/qml/components/swap/SwapCard.qml +++ b/apps/amm/qml/components/swap/SwapCard.qml @@ -15,10 +15,11 @@ Rectangle { property string buyInput: "" property string editingSide: "sell" property real slippageTolerancePercent: 0.5 + property int feeBps: 30 - DummySwapState { + SwapState { id: swapState - feeBps: 30 + feeBps: root.feeBps } signal requestTokenSelect(string side) @@ -77,9 +78,11 @@ Rectangle { } function formatAmountValue(val) { - if (val >= 1) return val.toFixed(2) - if (val >= 0.0001) return val.toFixed(6) - return val.toFixed(8) + return Math.floor(val).toString() + } + + function formatMaxSentValue(val) { + return Math.ceil(val).toString() } readonly property string sellDisplay: editingSide === "sell" @@ -107,8 +110,12 @@ Rectangle { "sellToken": sellToken ? sellToken.symbol : "", "buyToken": buyToken ? buyToken.symbol : "", "sellAmount": formatAmountValue(parsedSellAmount), + "sellAmountValue": formatAmountValue(parsedSellAmount), "buyAmount": formatAmountValue(parsedBuyAmount), + "buyAmountValue": formatAmountValue(parsedBuyAmount), "minReceived": formatAmountValue(minReceivedAmount), + "minReceivedAmountValue": formatAmountValue(minReceivedAmount), + "maxSentAmountValue": formatMaxSentValue(swapState.maxSent(parsedSellAmount, slippageTolerancePercent)), "feeAmount": swapState.formatTokenAmount(feeAmount, sellToken ? sellToken.symbol : ""), "priceImpactPercent": swapState.formatPercent(priceImpactPercent), "priceImpactPercentValue": priceImpactPercent, diff --git a/apps/amm/qml/components/swap/SwapConfirmationDialog.qml b/apps/amm/qml/components/swap/SwapConfirmationDialog.qml index 54f61b4..9a633a3 100644 --- a/apps/amm/qml/components/swap/SwapConfirmationDialog.qml +++ b/apps/amm/qml/components/swap/SwapConfirmationDialog.qml @@ -195,7 +195,7 @@ FocusScope { activeFocusOnTab: true focusPolicy: Qt.StrongFocus hoverEnabled: true - text: qsTr("Confirm Swap") + text: qsTr("Submit") Layout.fillWidth: true Layout.minimumHeight: 48 onClicked: root.confirm() diff --git a/apps/amm/qml/components/swap/TokenInput.qml b/apps/amm/qml/components/swap/TokenInput.qml index 07b2362..06ebf12 100644 --- a/apps/amm/qml/components/swap/TokenInput.qml +++ b/apps/amm/qml/components/swap/TokenInput.qml @@ -46,7 +46,7 @@ Rectangle { Item { Layout.fillWidth: true - height: 44 + Layout.preferredHeight: 44 TextInput { id: tiInput @@ -58,7 +58,7 @@ Rectangle { clip: true onTextEdited: root.inputEdited(text) validator: RegularExpressionValidator { - regularExpression: /^[0-9]*\.?[0-9]*$/ + regularExpression: /^[0-9]*$/ } } @@ -81,10 +81,10 @@ Rectangle { } Rectangle { - height: 40 + Layout.preferredHeight: 40 radius: 20 color: tokenBtnHover.containsMouse ? theme.colors.panelHoverBg : theme.colors.panelBg - implicitWidth: tokenBtnRow.implicitWidth + 24 + Layout.preferredWidth: tokenBtnRow.implicitWidth + 24 Behavior on color { ColorAnimation { duration: 120 } } RowLayout { @@ -93,7 +93,9 @@ Rectangle { spacing: 6 Rectangle { - width: 24; height: 24; radius: 12 + Layout.preferredWidth: 24 + Layout.preferredHeight: 24 + radius: 12 color: root.token ? root.token.color : theme.colors.noTokenCircle visible: root.token !== null Text { diff --git a/apps/amm/qml/components/wallet/AccountControl.qml b/apps/amm/qml/components/wallet/AccountControl.qml index 0c7d068..b02e33b 100644 --- a/apps/amm/qml/components/wallet/AccountControl.qml +++ b/apps/amm/qml/components/wallet/AccountControl.qml @@ -1,7 +1,7 @@ -import QtQuick -import QtQml -import QtQuick.Controls -import QtQuick.Layouts +import QtQuick 2.15 +import QtQml 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 import Logos.Theme import Logos.Controls @@ -12,8 +12,8 @@ import Logos.Controls // 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. +// selectedAddress stays in the wallet module's raw hex format for backend +// calls; selectedDisplayAddress is the base58 format shown/copied in the UI. Item { id: root @@ -22,11 +22,13 @@ Item { property var accountModel: null readonly property bool connected: backend !== null && backend.isWalletOpen + readonly property real viewportMargin: Theme.spacing.medium // 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 + property string selectedAccountId: "" // Non-visual mirror of the account model: realizes every row regardless of // popup visibility, so the active account is addressable by index at all @@ -36,10 +38,13 @@ Item { model: root.accountModel delegate: QtObject { readonly property string address: model.address ?? "" + readonly property string displayAddress: model.displayAddress ?? "" readonly property string name: model.name ?? "" readonly property string balance: model.balance ?? "" readonly property bool isPublic: model.isPublic ?? false } + onObjectAdded: root.clampSelection() + onObjectRemoved: root.clampSelection() } function entryAt(i) { @@ -48,26 +53,75 @@ Item { readonly property string selectedAddress: { const e = root.entryAt(root.selectedIndex) - return e ? e.address : "" + return e && e.isPublic ? e.address : "" + } + readonly property string selectedDisplayAddress: { + const e = root.entryAt(root.selectedIndex) + return e && e.isPublic ? e.displayAddress : "" } readonly property string selectedName: { const e = root.entryAt(root.selectedIndex) - return e ? e.name : "" + return e && e.isPublic ? e.name : qsTr("No public account") } readonly property string selectedBalance: { const e = root.entryAt(root.selectedIndex) - return e ? e.balance : "" + return e && e.isPublic ? 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 firstPublicIndex() { + for (let i = 0; i < accounts.count; ++i) { + const e = root.entryAt(i) + if (e && e.isPublic) + return i + } + return -1 + } + + function publicIndexForAddress(address) { + if (!address) + return -1 + for (let i = 0; i < accounts.count; ++i) { + const e = root.entryAt(i) + if (e && e.isPublic && e.address === address) + return i + } + return -1 + } + + function selectIndex(index) { + const e = root.entryAt(index) + root.selectedIndex = index + root.selectedAccountId = e.address } - // Keep the selection within bounds as accounts are added/removed. function clampSelection() { - if (accounts.count === 0) { root.selectedIndex = 0; return } + if (accounts.count === 0) { + root.selectedIndex = 0 + root.selectedAccountId = "" + return + } + + const rememberedIndex = root.publicIndexForAddress(root.selectedAccountId) + if (rememberedIndex >= 0) { + root.selectedIndex = rememberedIndex + return + } + if (root.selectedIndex < 0) root.selectedIndex = 0 else if (root.selectedIndex >= accounts.count) root.selectedIndex = accounts.count - 1 + + const selected = root.entryAt(root.selectedIndex) + if (selected.isPublic) { + root.selectedAccountId = selected.address + return + } + + const firstPublic = root.firstPublicIndex() + if (firstPublic >= 0) { + root.selectIndex(firstPublic) + } else { + root.selectedAccountId = "" + } } Connections { target: root.accountModel @@ -97,9 +151,25 @@ Item { clipboardProxy.text = "" } + function handleOpenExistingFailure(error) { + connectErrorDialog.message = error || qsTr("Could not open the existing wallet at %1.") + .arg(root.backend ? root.backend.walletHome : "") + if (root.backend && root.backend.walletExists) + connectErrorDialog.open() + else + createWalletDialog.open() + } + implicitWidth: root.connected ? connectedButton.width : connectButton.width implicitHeight: 40 + Component.onCompleted: root.clampSelection() + + function clampedOverlayWidth(maxWidth) { + const overlay = Overlay.overlay + return Math.min(maxWidth, Math.max(0, overlay ? overlay.width - root.viewportMargin * 2 : maxWidth)) + } + // ── Disconnected: Connect ──────────────────────────────────────────── LogosButton { id: connectButton @@ -110,36 +180,37 @@ Item { 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() + if (!root.backend) return + logos.watch(root.backend.openExisting(), + function(ok) { + if (!ok) root.handleOpenExistingFailure("") + }, + function(error) { + console.warn("openExisting error:", error) + root.handleOpenExistingFailure(qsTr("Could not open the existing wallet: %1").arg(error)) + }) } } // ── Connected: address pill that toggles the wallet menu ───────────── - Rectangle { + Button { 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" + implicitWidth: connectedRow.implicitWidth + leftPadding + rightPadding + leftPadding: Theme.spacing.medium + rightPadding: Theme.spacing.medium + topPadding: 0 + bottomPadding: 0 + text: root.selectedDisplayAddress.length > 0 + ? root.truncated(root.selectedDisplayAddress) + : qsTr("No public account") + Accessible.name: text - RowLayout { + contentItem: RowLayout { id: connectedRow - anchors.centerIn: parent spacing: Theme.spacing.small Rectangle { @@ -149,7 +220,7 @@ Item { color: "#39c06a" } LogosText { - text: root.truncated(root.selectedAddress) || qsTr("Connected") + text: connectedButton.text font.pixelSize: Theme.typography.secondaryText color: Theme.palette.text } @@ -160,19 +231,26 @@ Item { } } - 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() - } + background: Rectangle { + 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 || connectedButton.activeFocus ? Theme.palette.overlayOrange : "transparent" + } + + // 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() } } @@ -181,8 +259,18 @@ Item { id: walletMenu parent: connectedButton y: connectedButton.height + Theme.spacing.small - x: connectedButton.width - width // right-align under the button - width: 360 + x: { + const overlay = Overlay.overlay + if (!overlay) + return connectedButton.width - width + const buttonLeft = connectedButton.mapToItem(overlay, 0, 0).x + const buttonRight = buttonLeft + connectedButton.width + const desiredOverlayX = buttonRight - width + const minX = root.viewportMargin + const maxX = Math.max(minX, overlay.width - width - root.viewportMargin) + return Math.max(minX, Math.min(desiredOverlayX, maxX)) - buttonLeft + } + width: root.clampedOverlayWidth(360) padding: Theme.spacing.medium closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside @@ -239,14 +327,17 @@ Item { WalletIconButton { iconSource: Qt.resolvedUrl("icons/account.svg") + accessibleName: qsTr("Show accounts") onClicked: viewStack.push(accountsView) } WalletIconButton { iconSource: Qt.resolvedUrl("icons/settings.svg") + accessibleName: qsTr("Open wallet settings") onClicked: viewStack.push(settingsView) } WalletIconButton { iconSource: Qt.resolvedUrl("icons/power.svg") + accessibleName: qsTr("Disconnect wallet") onClicked: { walletMenu.close() if (root.backend) root.backend.disconnectWallet() @@ -279,12 +370,13 @@ Item { Rectangle { Layout.preferredWidth: tagLabel.implicitWidth + Theme.spacing.small * 2 Layout.preferredHeight: tagLabel.implicitHeight + 4 + visible: root.selectedAddress.length > 0 radius: 4 color: Theme.palette.backgroundSecondary LogosText { id: tagLabel anchors.centerIn: parent - text: root.selectedIsPublic ? qsTr("Public") : qsTr("Private") + text: qsTr("Public") font.pixelSize: Theme.typography.secondaryText color: Theme.palette.textSecondary } @@ -302,7 +394,7 @@ Item { LogosText { Layout.fillWidth: true verticalAlignment: Text.AlignVCenter - text: root.selectedAddress + text: root.selectedDisplayAddress font.pixelSize: Theme.typography.secondaryText color: Theme.palette.textMuted elide: Text.ElideMiddle @@ -310,8 +402,9 @@ Item { LogosCopyButton { Layout.preferredHeight: 40 Layout.preferredWidth: 40 - visible: root.selectedAddress.length > 0 - onCopyText: root.copyToClipboard(root.selectedAddress) + accessibleName: qsTr("Copy selected account address") + visible: root.selectedDisplayAddress.length > 0 + onCopyText: root.copyToClipboard(root.selectedDisplayAddress) icon.color: Theme.palette.textMuted } } @@ -334,6 +427,7 @@ Item { WalletIconButton { iconSource: Qt.resolvedUrl("icons/back.svg") + accessibleName: qsTr("Back") onClicked: viewStack.pop() } LogosText { @@ -356,9 +450,12 @@ Item { delegate: AccountDelegate { width: ListView.view.width - highlighted: index === root.selectedIndex + selectable: model.isPublic ?? false + highlighted: selectable && index === root.selectedIndex onClicked: { - root.selectedIndex = index + if (!selectable) + return + root.selectIndex(index) viewStack.pop() } onCopyRequested: (text) => root.copyToClipboard(text) @@ -367,7 +464,7 @@ Item { LogosButton { Layout.fillWidth: true - height: 40 + Layout.preferredHeight: 40 text: qsTr("Add") // Leave the wallet menu open behind the (modal) dialog. onClicked: createAccountDialog.open() @@ -389,6 +486,7 @@ Item { WalletIconButton { iconSource: Qt.resolvedUrl("icons/back.svg") + accessibleName: qsTr("Back") onClicked: viewStack.pop() } LogosText { @@ -422,11 +520,13 @@ Item { font.pixelSize: Theme.typography.secondaryText property bool ok: false color: ok ? Theme.palette.success : Theme.palette.error + Accessible.role: Accessible.AlertMessage + Accessible.name: text } LogosButton { Layout.fillWidth: true - height: 40 + Layout.preferredHeight: 40 text: qsTr("Save") onClicked: { if (!root.backend) return @@ -454,16 +554,95 @@ Item { onCreateWallet: function(password) { if (!root.backend) return logos.watch(root.backend.createNewDefault(password), - function(ok) { - if (ok) createWalletDialog.close() - else createWalletDialog.createError = qsTr("Failed to create wallet. Please try again.") + function(result) { + const mnemonic = String(result || "") + if (mnemonic.length > 0) { + createWalletDialog.close() + backupWalletDialog.mnemonic = mnemonic + backupWalletDialog.open() + } else { + createWalletDialog.createError = qsTr("Failed to create wallet. Please try again.") + } }, function(error) { createWalletDialog.createError = qsTr("Error creating wallet: %1").arg(error) - }) + }) } } + Popup { + id: connectErrorDialog + + property string message: "" + + modal: true + dim: true + padding: Theme.spacing.large + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + parent: Overlay.overlay + anchors.centerIn: parent + width: root.clampedOverlayWidth(380) + + background: Rectangle { + color: Theme.palette.backgroundSecondary + radius: Theme.spacing.radiusXlarge + border.color: Theme.palette.backgroundElevated + } + + contentItem: ColumnLayout { + width: connectErrorDialog.availableWidth + spacing: Theme.spacing.large + + LogosText { + Layout.fillWidth: true + text: qsTr("Wallet connection failed") + font.pixelSize: Theme.typography.titleText + font.weight: Theme.typography.weightBold + color: Theme.palette.text + } + + LogosText { + Layout.fillWidth: true + text: connectErrorDialog.message + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + wrapMode: Text.WordWrap + } + + LogosText { + Layout.fillWidth: true + text: qsTr("Creating a new wallet will use the same wallet home. Continue only if you do not need the existing wallet files.") + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.error + wrapMode: Text.WordWrap + } + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing.medium + + LogosButton { + Layout.fillWidth: true + text: qsTr("Cancel") + onClicked: connectErrorDialog.close() + } + + LogosButton { + Layout.fillWidth: true + text: qsTr("Create New") + onClicked: { + connectErrorDialog.close() + createWalletDialog.open() + } + } + } + } + } + + BackupWalletDialog { + id: backupWalletDialog + } + CreateAccountDialog { id: createAccountDialog onCreatePublicRequested: { diff --git a/apps/amm/qml/components/wallet/AccountDelegate.qml b/apps/amm/qml/components/wallet/AccountDelegate.qml index 14c03ee..427b83a 100644 --- a/apps/amm/qml/components/wallet/AccountDelegate.qml +++ b/apps/amm/qml/components/wallet/AccountDelegate.qml @@ -1,6 +1,6 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 import Logos.Theme import Logos.Controls @@ -13,6 +13,18 @@ ItemDelegate { // to its QML-side clipboard helper (AccountControl.copyToClipboard). signal copyRequested(string text) + property bool selectable: model.isPublic ?? false + readonly property string displayAddress: model.displayAddress ?? "" + + focusPolicy: root.selectable ? Qt.StrongFocus : Qt.NoFocus + activeFocusOnTab: root.selectable + Accessible.role: root.selectable ? Accessible.RadioButton : Accessible.ListItem + Accessible.name: root.selectable + ? qsTr("Select account %1").arg(root.displayAddress) + : qsTr("Private account %1").arg(root.displayAddress) + Accessible.description: root.selectable ? "" : qsTr("Private accounts cannot be used for AMM actions") + Accessible.checked: root.highlighted + leftPadding: Theme.spacing.medium rightPadding: Theme.spacing.medium topPadding: Theme.spacing.medium @@ -67,7 +79,7 @@ ItemDelegate { id: addressLabel Layout.fillWidth: true verticalAlignment: Text.AlignVCenter - text: model.address ?? "" + text: root.displayAddress font.pixelSize: Theme.typography.secondaryText color: Theme.palette.textMuted elide: Text.ElideMiddle @@ -75,8 +87,9 @@ ItemDelegate { LogosCopyButton { Layout.preferredHeight: 40 Layout.preferredWidth: 40 - onCopyText: root.copyRequested(model.address) - visible: addressLabel.text + accessibleName: qsTr("Copy account address") + onCopyText: root.copyRequested(root.displayAddress) + visible: root.displayAddress.length > 0 icon.color: Theme.palette.textMuted } } diff --git a/apps/amm/qml/components/wallet/BackupWalletDialog.qml b/apps/amm/qml/components/wallet/BackupWalletDialog.qml new file mode 100644 index 0000000..fb1f225 --- /dev/null +++ b/apps/amm/qml/components/wallet/BackupWalletDialog.qml @@ -0,0 +1,93 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +import Logos.Theme +import Logos.Controls + +Popup { + id: root + + property string mnemonic: "" + readonly property real viewportMargin: Theme.spacing.large + + modal: true + dim: true + padding: Theme.spacing.large + closePolicy: Popup.NoAutoClose + parent: Overlay.overlay + anchors.centerIn: parent + width: Math.min(460, Math.max(0, parent ? parent.width - root.viewportMargin * 2 : 460)) + + onOpened: savedCheck.checked = false + onClosed: root.mnemonic = "" + + background: Rectangle { + color: Theme.palette.backgroundSecondary + radius: Theme.spacing.radiusXlarge + border.color: Theme.palette.backgroundElevated + } + + contentItem: ColumnLayout { + width: root.availableWidth + spacing: Theme.spacing.large + + LogosText { + text: qsTr("Back up your wallet") + font.pixelSize: Theme.typography.titleText + font.weight: Theme.typography.weightBold + color: Theme.palette.text + } + + LogosText { + Layout.fillWidth: true + text: qsTr("Save this recovery phrase now. You need it to restore this wallet.") + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.textSecondary + wrapMode: Text.WordWrap + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: phraseText.implicitHeight + Theme.spacing.medium * 2 + radius: Theme.spacing.radiusLarge + color: Theme.palette.backgroundMuted + border.color: Theme.palette.backgroundElevated + + TextEdit { + id: phraseText + anchors.fill: parent + anchors.margins: Theme.spacing.medium + readOnly: true + selectByMouse: true + textFormat: TextEdit.PlainText + wrapMode: Text.WordWrap + text: root.mnemonic + color: Theme.palette.text + font.pixelSize: Theme.typography.secondaryText + background: null + Accessible.name: qsTr("Recovery phrase") + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing.medium + + CheckBox { + id: savedCheck + Layout.fillWidth: true + text: qsTr("I saved this recovery phrase") + checked: false + } + } + + LogosButton { + Layout.fillWidth: true + height: 40 + text: qsTr("Continue") + enabled: savedCheck.checked + onClicked: root.close() + } + } +} diff --git a/apps/amm/qml/components/wallet/CreateAccountDialog.qml b/apps/amm/qml/components/wallet/CreateAccountDialog.qml index 8070795..b2a8227 100644 --- a/apps/amm/qml/components/wallet/CreateAccountDialog.qml +++ b/apps/amm/qml/components/wallet/CreateAccountDialog.qml @@ -1,6 +1,6 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 import Logos.Theme import Logos.Controls @@ -9,6 +9,8 @@ import Logos.Controls Popup { id: root + readonly property real viewportMargin: Theme.spacing.large + signal createPublicRequested() signal createPrivateRequested() @@ -21,7 +23,7 @@ Popup { // this popup is declared inside. parent: Overlay.overlay anchors.centerIn: parent - width: 360 + width: Math.min(360, Math.max(0, parent ? parent.width - root.viewportMargin * 2 : 360)) background: Rectangle { color: Theme.palette.backgroundSecondary @@ -74,6 +76,8 @@ Popup { LogosSwitch { id: privateSwitch checked: false + Accessible.name: qsTr("Create private account") + Accessible.description: qsTr("Private balance and activity.") } } diff --git a/apps/amm/qml/components/wallet/CreateWalletDialog.qml b/apps/amm/qml/components/wallet/CreateWalletDialog.qml index ac40e8d..719f357 100644 --- a/apps/amm/qml/components/wallet/CreateWalletDialog.qml +++ b/apps/amm/qml/components/wallet/CreateWalletDialog.qml @@ -1,19 +1,20 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 import Logos.Theme import Logos.Controls -// Password-only wallet creation modal. Storage/config live at the per-app -// default (backend.walletHome) — no path picking. Opened from the navbar -// "Connect" button. +// Password-only wallet creation modal. Storage/config live at the canonical +// LEZ wallet home (backend.walletHome), with 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: "" + readonly property real viewportMargin: Theme.spacing.large signal createWallet(string password) @@ -25,7 +26,7 @@ Popup { // this popup is declared inside. parent: Overlay.overlay anchors.centerIn: parent - width: 380 + width: Math.min(380, Math.max(0, parent ? parent.width - root.viewportMargin * 2 : 380)) onOpened: { passwordField.text = "" diff --git a/apps/amm/qml/components/wallet/LogosCopyButton.qml b/apps/amm/qml/components/wallet/LogosCopyButton.qml index 7c1d7bd..3d7cfda 100644 --- a/apps/amm/qml/components/wallet/LogosCopyButton.qml +++ b/apps/amm/qml/components/wallet/LogosCopyButton.qml @@ -1,5 +1,5 @@ -import QtQuick -import QtQuick.Controls +import QtQuick 2.15 +import QtQuick.Controls 2.15 import Logos.Theme @@ -8,13 +8,17 @@ Button { signal copyText() + property string accessibleName: "" + property string iconSource: Qt.resolvedUrl("icons/copy.svg") + property bool copied: false + implicitWidth: 24 implicitHeight: 24 + text: root.copied ? qsTr("Copied") : root.accessibleName + Accessible.name: text display: AbstractButton.IconOnly flat: true - property string iconSource: Qt.resolvedUrl("icons/copy.svg") - icon.source: root.iconSource icon.width: 24 icon.height: 24 @@ -22,6 +26,7 @@ Button { function reset() { iconSource = Qt.resolvedUrl("icons/copy.svg") + copied = false } Timer { @@ -34,6 +39,7 @@ Button { onClicked: { root.copyText() root.iconSource = Qt.resolvedUrl("icons/checkmark.svg") + root.copied = true resetTimer.restart() } } diff --git a/apps/amm/qml/components/wallet/WalletIconButton.qml b/apps/amm/qml/components/wallet/WalletIconButton.qml index 61545e0..da21abe 100644 --- a/apps/amm/qml/components/wallet/WalletIconButton.qml +++ b/apps/amm/qml/components/wallet/WalletIconButton.qml @@ -1,5 +1,5 @@ -import QtQuick -import QtQuick.Controls +import QtQuick 2.15 +import QtQuick.Controls 2.15 import Logos.Theme @@ -12,9 +12,12 @@ Button { property url iconSource property color iconColor: Theme.palette.textSecondary property int iconSize: 18 + property string accessibleName: "" implicitWidth: 32 implicitHeight: 32 + text: root.accessibleName + Accessible.name: root.accessibleName display: AbstractButton.IconOnly flat: true diff --git a/apps/amm/qml/pages/LiquidityPage.qml b/apps/amm/qml/pages/LiquidityPage.qml index f471952..beafb68 100644 --- a/apps/amm/qml/pages/LiquidityPage.qml +++ b/apps/amm/qml/pages/LiquidityPage.qml @@ -7,19 +7,27 @@ import "../state" Item { id: root + property var poolConfig: ({}) + property var backend: null + property bool unsupportedChain: false + property string selectedWalletAccount: "" property int activeLiquidityTab: 0 property real slippageTolerancePercent: 0.5 + readonly property bool hasPoolConfig: !root.unsupportedChain && !!root.poolConfig.account 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 + onPoolConfigChanged: poolState.loadConfig(root.poolConfig) + width: parent ? parent.width : implicitWidth height: parent ? parent.height : implicitHeight implicitWidth: root.preferredCardWidth + root.pageMargin * 2 implicitHeight: pageCard.implicitHeight + root.pageMargin * 2 - DummyPoolState { + PoolState { id: poolState + Component.onCompleted: loadConfig(root.poolConfig) } Rectangle { @@ -32,6 +40,7 @@ Item { anchors.fill: parent clip: true + visible: !root.unsupportedChain contentHeight: Math.max(height, pageCard.y + pageCard.implicitHeight + root.pageMargin) contentWidth: width enabled: !confirmationDialog.visible @@ -54,6 +63,7 @@ Item { anchors.fill: parent anchors.margins: 12 + enabled: root.hasPoolConfig spacing: 10 RowLayout { @@ -162,6 +172,15 @@ Item { } } + Text { + anchors.centerIn: parent + visible: root.unsupportedChain + text: qsTr("Unsupported chain") + color: "#E7E1D8" + font.pixelSize: 18 + font.bold: true + } + LiquidityConfirmationDialog { id: confirmationDialog @@ -173,17 +192,69 @@ Item { } function confirmLiquidityAction(snapshot) { + if (!root.backend) { + successToast.show(qsTr("Transaction failed"), qsTr("Backend is not ready"), "", "error"); + return; + } + + snapshot.selectedWalletAccount = root.selectedWalletAccount; + const expectedSubmissionToken = root.submissionToken(); + logos.watch(root.backend.submitLiquidity(snapshot), + function (resultJson) { + if (root.submissionToken() !== expectedSubmissionToken) + return; + const result = root.parseTransactionResult(resultJson); + if (!result.success) { + successToast.show(qsTr("Transaction failed"), + result.error || qsTr("Transaction rejected"), + "", + "error"); + return; + } + + root.applyConfirmedLiquidityAction(snapshot, result.tx_hash || ""); + }, + function (error) { + if (root.submissionToken() !== expectedSubmissionToken) + return; + successToast.show(qsTr("Transaction failed"), String(error), "", "error"); + }); + } + + function submissionToken() { + if (!root.backend) + return ""; + return [ + root.backend.isWalletOpen ? "open" : "closed", + root.backend.sequencerAddr || "", + root.backend.deploymentNetworkMatched ? "matched" : "unmatched", + root.selectedWalletAccount || "", + root.activeLiquidityTab + ].join("|"); + } + + function parseTransactionResult(resultJson) { + try { + return JSON.parse(resultJson); + } catch (err) { + return { "success": false, "tx_hash": "", "error": String(err) }; + } + } + + function applyConfirmedLiquidityAction(snapshot, transactionId) { if (snapshot.action === "add") { - poolState.applyAddLiquidity(snapshot.actualA, snapshot.actualB, snapshot.deltaLp); addLiquidityForm.resetForm(); - successToast.show(qsTr("Liquidity added"), qsTr("Position updated")); + successToast.show(qsTr("Add liquidity submitted"), + qsTr("Position refreshed from chain"), + transactionId); return; } if (snapshot.action === "remove") { - poolState.applyRemoveLiquidity(snapshot.withdrawA, snapshot.withdrawB, snapshot.burnAmount); removeLiquidityForm.resetForm(); - successToast.show(qsTr("Liquidity removed"), qsTr("Position updated")); + successToast.show(qsTr("Remove liquidity submitted"), + qsTr("Position refreshed from chain"), + transactionId); } } } diff --git a/apps/amm/qml/pages/SwapPage.qml b/apps/amm/qml/pages/SwapPage.qml index 75b42d4..6351011 100644 --- a/apps/amm/qml/pages/SwapPage.qml +++ b/apps/amm/qml/pages/SwapPage.qml @@ -1,21 +1,61 @@ import QtQuick 2.15 -import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 import "../components/shared" import "../components/swap" -import "../state" Item { id: root - property var tokens: [ - { symbol: "TOK1", name: "Token 1", color: "#627eea", letter: "E", address: "0x0000000000000000000000000000000000000000", usdPrice: 2392.70, balance: 4.25, reserve: 850 }, - { symbol: "TOK2", name: "Token 2", color: "#2775ca", letter: "$", address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", usdPrice: 1.00, balance: 12480, reserve: 2400000 }, - { symbol: "TOK3", name: "Token 3", color: "#26a17b", letter: "T", address: "0xdac17f958d2ee523a2206206994597c13d831ec7", usdPrice: 1.00, balance: 320, reserve: 1800000 }, - { symbol: "TOK4", name: "Token 4", color: "#f7931a", letter: "B", address: "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599", usdPrice: 63500, balance: 0.18, reserve: 42 }, - { symbol: "TOK5", name: "Token 5", color: "#627eea", letter: "E", address: "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", usdPrice: 2392.70, balance: 0, reserve: 600 }, - { symbol: "TOK6", name: "Token 6", color: "#9b59b6", letter: "L", address: "0x1337000000000000000000000000000000000cafe", usdPrice: 0.42, balance: 5400, reserve: 950000 } - ] + property var tokens: [] + property var poolConfig: ({}) + property var backend: null + property bool unsupportedChain: false + property string selectedWalletAccount: "" + readonly property string poolAccount: poolConfig.account || "" + readonly property string poolAccountShort: poolAccount.length > 14 + ? poolAccount.substring(0, 8) + "..." + poolAccount.slice(-6) + : poolAccount + + onTokensChanged: Qt.callLater(selectDefaultTokens) + + Component.onCompleted: Qt.callLater(selectDefaultTokens) + + function selectDefaultTokens() { + if (root.tokens.length < 2) { + swapCard.setToken("sell", null); + swapCard.setToken("buy", null); + swapCard.resetAmounts(); + return; + } + if (!swapCard.sellToken || swapCard.sellToken.address !== root.tokens[0].address) + swapCard.setToken("sell", root.tokens[0]); + if (!swapCard.buyToken || swapCard.buyToken.address !== root.tokens[1].address) + swapCard.setToken("buy", root.tokens[1]); + } + + function parseTransactionResult(resultJson) { + try { + return JSON.parse(resultJson); + } catch (err) { + return { "success": false, "tx_hash": "", "error": String(err) }; + } + } + + function withSelectedWalletAccount(snapshot) { + snapshot.selectedWalletAccount = root.selectedWalletAccount + return snapshot + } + + function submissionToken() { + if (!root.backend) + return "" + return [ + root.backend.isWalletOpen ? "open" : "closed", + root.backend.sequencerAddr || "", + root.backend.deploymentNetworkMatched ? "matched" : "unmatched", + root.selectedWalletAccount || "" + ].join("|") + } QtObject { id: theme @@ -92,10 +132,12 @@ Item { SwapCard { id: swapCard + visible: !root.unsupportedChain Layout.alignment: Qt.AlignHCenter theme: theme tokens: root.tokens - width: Math.min(480, root.width - 32) + feeBps: Number(root.poolConfig.feeBps) || 0 + Layout.preferredWidth: Math.min(480, root.width - 32) onRequestTokenSelect: function(side) { tokenModal.targetSide = side @@ -108,13 +150,26 @@ Item { } Text { + visible: !root.unsupportedChain Layout.alignment: Qt.AlignHCenter - text: "Buy and sell crypto on LEZ." + text: "Pool " + + root.poolAccountShort + + "" textFormat: Text.RichText color: theme.colors.textSecondary font.pixelSize: 15 horizontalAlignment: Text.AlignHCenter } + + Text { + visible: root.unsupportedChain + Layout.alignment: Qt.AlignHCenter + text: qsTr("Unsupported chain") + color: theme.colors.textPrimary + font.pixelSize: 18 + font.bold: true + horizontalAlignment: Text.AlignHCenter + } } TokenSelectorModal { @@ -150,13 +205,39 @@ Item { theme: theme onConfirmed: function(snapshot) { - swapCard.resetAmounts() - swapToast.show(qsTr("Swap submitted"), - qsTr("%1 %2 → %3 %4") - .arg(snapshot.sellAmount) - .arg(snapshot.sellToken) - .arg(snapshot.minReceived) - .arg(snapshot.buyToken)) + if (!root.backend) { + swapToast.show(qsTr("Swap failed"), qsTr("Backend is not ready"), "", "error") + return + } + + const expectedSubmissionToken = root.submissionToken() + logos.watch(root.backend.submitSwap(root.withSelectedWalletAccount(snapshot)), + function(resultJson) { + if (root.submissionToken() !== expectedSubmissionToken) + return + const result = root.parseTransactionResult(resultJson) + if (!result.success) { + swapToast.show(qsTr("Swap failed"), + result.error || qsTr("Transaction rejected"), + "", + "error") + return + } + + swapCard.resetAmounts() + swapToast.show(qsTr("Swap submitted"), + qsTr("%1 %2 → %3 %4") + .arg(snapshot.sellAmount) + .arg(snapshot.sellToken) + .arg(snapshot.minReceived) + .arg(snapshot.buyToken), + result.tx_hash || "") + }, + function(error) { + if (root.submissionToken() !== expectedSubmissionToken) + return + swapToast.show(qsTr("Swap failed"), String(error), "", "error") + }) } } } diff --git a/apps/amm/qml/state/DummyPoolState.qml b/apps/amm/qml/state/PoolState.qml similarity index 71% rename from apps/amm/qml/state/DummyPoolState.qml rename to apps/amm/qml/state/PoolState.qml index 7e4d573..b545369 100644 --- a/apps/amm/qml/state/DummyPoolState.qml +++ b/apps/amm/qml/state/PoolState.qml @@ -3,15 +3,15 @@ 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 + property string tokenA: "" + property string tokenB: "" + property string feeTier: "0%" + property real userLpBalance: 0 + property real reserveA: 0 + property real reserveB: 0 + property real totalLpSupply: 0 + property real walletBalanceA: 0 + property real walletBalanceB: 0 readonly property real minimumLiquidity: 1000 readonly property real poolShare: totalLpSupply > 0 ? userLpBalance / totalLpSupply : 0 @@ -19,38 +19,16 @@ QtObject { 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 loadConfig(config) { + tokenA = config.tokenA || ""; + tokenB = config.tokenB || ""; + feeTier = config.feeTier || "0%"; + userLpBalance = Number(config.userLpBalance) || 0; + reserveA = Number(config.reserveA) || 0; + reserveB = Number(config.reserveB) || 0; + totalLpSupply = Number(config.totalLpSupply) || 0; + walletBalanceA = Number(config.walletBalanceA) || 0; + walletBalanceB = Number(config.walletBalanceB) || 0; } function parseAmount(value) { @@ -66,7 +44,7 @@ QtObject { return 0; } - return reserveB * parseAmount(amountA) / reserveA; + return Math.floor(reserveB * parseAmount(amountA) / reserveA); } function amountAForB(amountB) { @@ -74,16 +52,16 @@ QtObject { return 0; } - return reserveA * parseAmount(amountB) / reserveB; + return Math.floor(reserveA * parseAmount(amountB) / reserveB); } function addLiquidityPreview(maxA, maxB) { - const safeMaxA = parseAmount(maxA); - const safeMaxB = parseAmount(maxB); + const safeMaxA = floorAmount(maxA); + const safeMaxB = floorAmount(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 actualA = Math.floor(Math.min(idealA, safeMaxA)); + const actualB = Math.floor(Math.min(idealB, safeMaxB)); const lpFromA = reserveA > 0 ? Math.floor(totalLpSupply * actualA / reserveA) : 0; const lpFromB = reserveB > 0 ? Math.floor(totalLpSupply * actualB / reserveB) : 0; @@ -101,7 +79,7 @@ QtObject { } function clampBurnAmount(value) { - return Math.min(floorAmount(value), Math.max(0, floorAmount(userLpBalance))); + return Math.min(floorAmount(value), floorAmount(userLpBalance)); } function clampSlippageTolerancePercent(value) { @@ -118,10 +96,6 @@ QtObject { 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)); } diff --git a/apps/amm/qml/state/DummySwapState.qml b/apps/amm/qml/state/SwapState.qml similarity index 100% rename from apps/amm/qml/state/DummySwapState.qml rename to apps/amm/qml/state/SwapState.qml diff --git a/apps/amm/result b/apps/amm/result deleted file mode 120000 index 9cf2dfb..0000000 --- a/apps/amm/result +++ /dev/null @@ -1 +0,0 @@ -/nix/store/05xmkf4hdg4dpk4hjanq8ik8pl7r74ym-logos-amm_ui-module \ No newline at end of file diff --git a/apps/amm/src/AccountModel.cpp b/apps/amm/src/AccountModel.cpp index 63992f8..c22f57e 100644 --- a/apps/amm/src/AccountModel.cpp +++ b/apps/amm/src/AccountModel.cpp @@ -16,26 +16,28 @@ int AccountModel::rowCount(const QModelIndex& parent) const QVariant AccountModel::data(const QModelIndex& index, int role) const { - if (!index.isValid() || index.row() < 0 || index.row() >= m_entries.size()) + if (!index.isValid() || 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(); + case NameRole: return e.name; + case AddressRole: return e.address; + case DisplayAddressRole: return e.displayAddress; + 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" } + { NameRole, "name" }, + { AddressRole, "address" }, + { DisplayAddressRole, "displayAddress" }, + { BalanceRole, "balance" }, + { IsPublicRole, "isPublic" } }; } @@ -52,9 +54,13 @@ void AccountModel::replaceFromJsonArray(const QJsonArray& arr) if (v.isObject()) { const QJsonObject obj = v.toObject(); e.address = obj.value(QStringLiteral("account_id")).toString(); + e.displayAddress = obj.value(QStringLiteral("display_account_id")).toString(); + if (e.displayAddress.isEmpty()) + e.displayAddress = e.address; e.isPublic = obj.value(QStringLiteral("is_public")).toBool(true); } else { e.address = v.toString(); + e.displayAddress = e.address; e.isPublic = true; } m_entries.append(e); diff --git a/apps/amm/src/AccountModel.h b/apps/amm/src/AccountModel.h index 918839c..038eeb9 100644 --- a/apps/amm/src/AccountModel.h +++ b/apps/amm/src/AccountModel.h @@ -11,6 +11,7 @@ struct AccountEntry { QString name; QString address; + QString displayAddress; QString balance; bool isPublic = true; }; @@ -24,6 +25,7 @@ public: enum Role { NameRole = Qt::UserRole + 1, AddressRole, + DisplayAddressRole, BalanceRole, IsPublicRole }; diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index b8d9a05..e5538b8 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -1,21 +1,35 @@ #include "AmmUiBackend.h" -#include +#include +#include +#include + +#include #include #include #include #include #include -#include #include #include #include +#include +#include +#include #include #include #include +#include #include +#include #include #include +#include +#include + +#ifdef Q_OS_UNIX +#include +#endif #include "logos_api.h" #include "logos_sdk.h" @@ -28,15 +42,870 @@ namespace { 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"; + // Wallet home env override. Prefer the wallet CLI var, but keep the LEZ UI + // var as a compatibility fallback. + const char WALLET_HOME_ENV[] = "NSSA_WALLET_HOME_DIR"; + const char LEGACY_WALLET_HOME_ENV[] = "LEE_WALLET_HOME_DIR"; + const char DEPLOYMENT_CONFIG_DIR_ENV[] = "AMM_UI_CONFIG_DIR"; + const char DEPLOYMENT_PROGRAM_DIR_ENV[] = "AMM_UI_PROGRAM_DIR"; + const char DEFAULT_SEQUENCER[] = "https://testnet.lez.logos.co"; + const char LEGACY_AMM_ABI[] = "legacy-v0.2.0-rc3"; + const char ACCOUNT_ID_KEY[] = "account_id"; + const char DISPLAY_ACCOUNT_ID_KEY[] = "display_account_id"; + const double MAX_SAFE_QML_INTEGER = 9007199254740991.0; + const int CHAIN_IDENTITY_BLOCK = 1; + const int BLOCK_HASH_OFFSET = 40; + const int BLOCK_HASH_SIZE = 32; + const int BLOCK_SIGNATURE_OFFSET = 80; + const int BLOCK_SIGNATURE_SIZE = 64; - // 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; + struct ChainFingerprint { + QString blockHash; + QString blockSignature; + + bool isValid() const + { + return !blockHash.isEmpty() && !blockSignature.isEmpty(); + } + }; + + QString txResultJson(bool success, const QString& txHash, const QString& error) + { + QJsonObject object; + object.insert(QStringLiteral("success"), success); + object.insert(QStringLiteral("tx_hash"), txHash); + object.insert(QStringLiteral("error"), error); + return QString::fromUtf8(QJsonDocument(object).toJson(QJsonDocument::Compact)); + } + + QString txError(const QString& error) + { + return txResultJson(false, {}, error); + } + + // Normalise file:// URLs to a plain local path; leave other inputs intact so + // invalid URLs don't silently collapse to an empty path. + QString toLocalPath(const QString& path) + { + const QString trimmed = path.trimmed(); + const QUrl url(trimmed); + if (url.scheme().compare(QStringLiteral("file"), Qt::CaseInsensitive) == 0) { + const QString local = url.toLocalFile(); + return local.isEmpty() ? trimmed : local; + } + return trimmed; + } + + QString pluginDirPath() + { +#ifdef Q_OS_UNIX + Dl_info info; + if (dladdr(reinterpret_cast(&pluginDirPath), &info) != 0 && info.dli_fname != nullptr) + return QFileInfo(QString::fromLocal8Bit(info.dli_fname)).absolutePath(); +#endif + return QCoreApplication::applicationDirPath(); + } + + QString assetDirPath() + { +#ifdef AMM_UI_ASSET_DIR + return QStringLiteral(AMM_UI_ASSET_DIR); +#else + return {}; +#endif + } + + QJsonObject loadConfigObject(const QString& fileName) + { + QStringList candidates; + const QString envConfigDir = QString::fromLocal8Bit(qgetenv(DEPLOYMENT_CONFIG_DIR_ENV)); + if (!envConfigDir.isEmpty()) + candidates.append(QDir(envConfigDir).filePath(fileName)); + const QString assetDir = assetDirPath(); + if (!assetDir.isEmpty()) + candidates.append(QDir(assetDir).filePath(QStringLiteral("config/") + fileName)); + candidates.append({ + QDir(pluginDirPath()).filePath(QStringLiteral("config/") + fileName), + QDir(QCoreApplication::applicationDirPath()).filePath(QStringLiteral("config/") + fileName), + QDir(QCoreApplication::applicationDirPath()).filePath(QStringLiteral("../lib/config/") + fileName), + QDir(QCoreApplication::applicationDirPath()).filePath(QStringLiteral("../lib64/config/") + fileName), + }); + + for (const QString& path : candidates) { + QFile file(path); + if (!file.exists()) + continue; + if (!file.open(QIODevice::ReadOnly)) { + qWarning() << "AmmUiBackend: cannot open deployment config" << path; + continue; + } + + QJsonParseError error; + const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &error); + if (error.error != QJsonParseError::NoError || !doc.isObject()) { + qWarning() << "AmmUiBackend: invalid deployment config" << path << error.errorString(); + continue; + } + + return doc.object(); + } + + qWarning() << "AmmUiBackend: deployment config not found" << fileName; + return {}; + } + + QByteArray loadProgramBinary(const QString& fileName, QString* error) + { + QStringList candidates; + const QString envProgramDir = QString::fromLocal8Bit(qgetenv(DEPLOYMENT_PROGRAM_DIR_ENV)); + if (!envProgramDir.isEmpty()) + candidates.append(QDir(envProgramDir).filePath(fileName)); + const QString assetDir = assetDirPath(); + if (!assetDir.isEmpty()) + candidates.append(QDir(assetDir).filePath(QStringLiteral("programs/") + fileName)); + candidates.append({ + QDir(pluginDirPath()).filePath(QStringLiteral("programs/") + fileName), + QDir(QCoreApplication::applicationDirPath()).filePath(QStringLiteral("programs/") + fileName), + QDir(QCoreApplication::applicationDirPath()).filePath(QStringLiteral("../lib/programs/") + fileName), + QDir(QCoreApplication::applicationDirPath()).filePath(QStringLiteral("../lib64/programs/") + fileName), + }); + + for (const QString& path : candidates) { + QFile file(path); + if (!file.exists()) + continue; + if (!file.open(QIODevice::ReadOnly)) { + qWarning() << "AmmUiBackend: cannot open program binary" << path; + continue; + } + + const QByteArray bytes = file.readAll(); + if (!bytes.isEmpty()) + return bytes; + } + + if (error) + *error = QStringLiteral("Program binary not found: %1").arg(fileName); + return {}; + } + + QJsonObject objectAt(const QJsonArray& array, int index) + { + if (index < 0 || index >= array.size()) + return {}; + return array.at(index).toObject(); + } + + QJsonObject firstObject(const QJsonObject& object, const QString& arrayKey) + { + return objectAt(object.value(arrayKey).toArray(), 0); + } + + QString stringValue(const QJsonObject& object, const QString& key, const QString& fallback = {}) + { + const QString value = object.value(key).toString().trimmed(); + return value.isEmpty() ? fallback : value; + } + + QJsonObject tokenDefinition(const QJsonArray& definitions, const QString& symbol, int fallbackIndex) + { + for (const QJsonValue& value : definitions) { + const QJsonObject definition = value.toObject(); + if (stringValue(definition, QStringLiteral("symbol")) == symbol) + return definition; + } + return objectAt(definitions, fallbackIndex); + } + + QString normalizedUrl(QString url) + { + url = url.trimmed(); + while (url.endsWith(QLatin1Char('/'))) + url.chop(1); + return url; + } + + QString canonicalHex(QString value) + { + value = value.trimmed().toLower(); + if (value.startsWith(QStringLiteral("0x"))) + value.remove(0, 2); + return value; + } + + void appendUnique(QStringList* values, const QString& value) + { + const QString normalized = canonicalHex(value); + if (!normalized.isEmpty() && !values->contains(normalized)) + values->append(normalized); + } + + QStringList deploymentTransactionHashes(const QJsonObject& program) + { + QStringList hashes; + appendUnique(&hashes, stringValue(program, QStringLiteral("deploymentTransaction"))); + appendUnique(&hashes, stringValue(program, QStringLiteral("deploymentTx"))); + appendUnique(&hashes, stringValue(program, QStringLiteral("transaction"))); + + const QJsonArray array = program.value(QStringLiteral("deploymentTransactions")).toArray(); + for (const QJsonValue& value : array) + appendUnique(&hashes, value.toString()); + + return hashes; + } + + QStringList poolCreationTransactionHashes(const QJsonObject& pool) + { + QStringList hashes; + appendUnique(&hashes, stringValue(pool, QStringLiteral("creationTransaction"))); + appendUnique(&hashes, stringValue(pool, QStringLiteral("creationTx"))); + appendUnique(&hashes, stringValue(pool, QStringLiteral("transaction"))); + + const QJsonArray array = pool.value(QStringLiteral("creationTransactions")).toArray(); + for (const QJsonValue& value : array) + appendUnique(&hashes, value.toString()); + + return hashes; + } + + void appendDeploymentTransactions(const QJsonObject& chain, QStringList* hashes) + { + const QJsonArray programs = chain.value(QStringLiteral("programs")).toArray(); + for (const QJsonValue& value : programs) { + const QJsonObject program = value.toObject(); + for (const QString& hash : deploymentTransactionHashes(program)) + appendUnique(hashes, hash); + const QJsonArray pools = program.value(QStringLiteral("pools")).toArray(); + for (const QJsonValue& poolValue : pools) { + for (const QString& hash : poolCreationTransactionHashes(poolValue.toObject())) + appendUnique(hashes, hash); + } + } + } + + QString hexSlice(const QByteArray& bytes, int offset, int size) + { + if (bytes.size() < offset + size) + return {}; + return QString::fromLatin1(bytes.mid(offset, size).toHex()); + } + + QByteArray jsonRpcBody(const QString& method, const QJsonArray& params) + { + QJsonObject body; + body.insert(QStringLiteral("jsonrpc"), QStringLiteral("2.0")); + body.insert(QStringLiteral("id"), 1); + body.insert(QStringLiteral("method"), method); + body.insert(QStringLiteral("params"), params); + return QJsonDocument(body).toJson(QJsonDocument::Compact); + } + + ChainFingerprint chainFingerprintFromGetBlockResponse(const QByteArray& payload) + { + QJsonParseError error; + const QJsonDocument doc = QJsonDocument::fromJson(payload, &error); + if (error.error != QJsonParseError::NoError || !doc.isObject()) + return {}; + + const QString block = doc.object().value(QStringLiteral("result")).toString(); + if (block.isEmpty()) + return {}; + + const QByteArray raw = QByteArray::fromBase64(block.toLatin1()); + return { + hexSlice(raw, BLOCK_HASH_OFFSET, BLOCK_HASH_SIZE), + hexSlice(raw, BLOCK_SIGNATURE_OFFSET, BLOCK_SIGNATURE_SIZE), + }; + } + + enum class TransactionLookupStatus { + Found, + Missing, + TransientFailure, + }; + + TransactionLookupStatus transactionLookupStatus(const QByteArray& payload) + { + QJsonParseError error; + const QJsonDocument doc = QJsonDocument::fromJson(payload, &error); + if (error.error != QJsonParseError::NoError || !doc.isObject()) + return TransactionLookupStatus::TransientFailure; + const QJsonObject object = doc.object(); + if (object.contains(QStringLiteral("error"))) + return TransactionLookupStatus::TransientFailure; + const QJsonValue result = object.value(QStringLiteral("result")); + return !result.isNull() && !result.isUndefined() + ? TransactionLookupStatus::Found + : TransactionLookupStatus::Missing; + } + + QJsonObject supportedChainByRef(const QJsonArray& supportedChains, const QString& chainRef) + { + const QString expected = chainRef.trimmed(); + for (const QJsonValue& value : supportedChains) { + const QJsonObject chain = value.toObject(); + if (stringValue(chain, QStringLiteral("alias")) == expected) + return chain; + } + + return {}; + } + + QJsonObject resolveSupportedChain(const QJsonObject& chain, const QJsonArray& supportedChains) + { + const QString chainRef = stringValue(chain, QStringLiteral("chainRef")); + if (chainRef.isEmpty()) + return chain; + + QJsonObject resolved = supportedChainByRef(supportedChains, chainRef); + if (resolved.isEmpty()) { + qWarning() << "AmmUiBackend: unsupported chainRef in deployment config" << chainRef; + return {}; + } + + for (auto it = chain.begin(); it != chain.end(); ++it) { + if (it.key() != QStringLiteral("chainRef")) + resolved.insert(it.key(), it.value()); + } + + return resolved; + } + + QJsonArray deploymentChains(const QJsonObject& root, + const QString& fallbackNetwork, + const QJsonArray& supportedChains) + { + const QJsonArray chains = root.value(QStringLiteral("chains")).toArray(); + if (!chains.isEmpty()) { + QJsonArray result; + for (const QJsonValue& value : chains) { + const QJsonObject chain = + resolveSupportedChain(value.toObject(), supportedChains); + if (!chain.isEmpty()) + result.append(chain); + } + return result; + } + + const QJsonArray programs = root.value(QStringLiteral("programs")).toArray(); + if (programs.isEmpty()) + return {}; + + QJsonObject chain; + const QString chainRef = stringValue(root, QStringLiteral("chainRef")); + const QString network = stringValue( + root, QStringLiteral("network"), chainRef.isEmpty() ? fallbackNetwork : QString{}); + if (!network.isEmpty()) + chain.insert(QStringLiteral("network"), network); + chain.insert(QStringLiteral("programs"), programs); + if (!chainRef.isEmpty()) + chain.insert(QStringLiteral("chainRef"), chainRef); + + QJsonArray result; + const QJsonObject resolved = resolveSupportedChain(chain, supportedChains); + if (!resolved.isEmpty()) + result.append(resolved); + return result; + } + + QString chainNetwork(const QJsonObject& chain) + { + const QString network = normalizedUrl(stringValue(chain, QStringLiteral("network"))); + if (!network.isEmpty()) + return network; + + const QJsonArray sequencers = chain.value(QStringLiteral("sequencers")).toArray(); + for (const QJsonValue& value : sequencers) { + const QString sequencer = normalizedUrl(value.toString()); + if (!sequencer.isEmpty()) + return sequencer; + } + + return {}; + } + + bool chainMatchesNetwork(const QJsonObject& chain, const QString& network) + { + const QString expected = normalizedUrl(network); + if (chainNetwork(chain) == expected) + return true; + + for (const QString& key : { QStringLiteral("networks"), QStringLiteral("sequencers") }) { + const QJsonArray values = chain.value(key).toArray(); + for (const QJsonValue& value : values) { + if (normalizedUrl(value.toString()) == expected) + return true; + } + } + + return false; + } + + QString configuredChainFingerprint(const QJsonObject& chain) + { + const QString explicitFingerprint = + stringValue(chain, QStringLiteral("chainFingerprint")).toLower(); + if (!explicitFingerprint.isEmpty()) + return explicitFingerprint; + + const QString hash = canonicalHex(stringValue(chain, QStringLiteral("genesisBlockHash"))); + const QString signature = + canonicalHex(stringValue(chain, QStringLiteral("genesisBlockSignature"))); + if (!hash.isEmpty() && !signature.isEmpty()) + return hash + QStringLiteral(":") + signature; + + return {}; + } + + bool chainHasDeterministicIdentity(const QJsonObject& chain) + { + return !configuredChainFingerprint(chain).isEmpty() + || !stringValue(chain, QStringLiteral("genesisBlockHash")).isEmpty() + || !stringValue(chain, QStringLiteral("genesisBlockSignature")).isEmpty(); + } + + bool chainsHaveDeterministicIdentity(const QJsonArray& chains) + { + for (const QJsonValue& value : chains) { + if (chainHasDeterministicIdentity(value.toObject())) + return true; + } + return false; + } + + bool chainMatchesFingerprint(const QJsonObject& chain, + const QString& blockHash, + const QString& blockSignature) + { + const QString observedHash = canonicalHex(blockHash); + const QString observedSignature = canonicalHex(blockSignature); + const QString fingerprint = configuredChainFingerprint(chain); + if (!fingerprint.isEmpty()) + return fingerprint == observedHash + QStringLiteral(":") + observedSignature; + + const QString expectedHash = + canonicalHex(stringValue(chain, QStringLiteral("genesisBlockHash"))); + if (!expectedHash.isEmpty() && expectedHash != observedHash) + return false; + + const QString expectedSignature = + canonicalHex(stringValue(chain, QStringLiteral("genesisBlockSignature"))); + if (!expectedSignature.isEmpty() && expectedSignature != observedSignature) + return false; + + return chainHasDeterministicIdentity(chain); + } + + QJsonObject chainForNetwork(const QJsonArray& chains, const QString& network) + { + const QString requested = normalizedUrl(network); + if (requested.isEmpty()) { + for (const QJsonValue& value : chains) { + const QJsonObject chain = value.toObject(); + if (chain.value(QStringLiteral("default")).toBool()) + return chain; + } + return chains.isEmpty() ? QJsonObject{} : chains.at(0).toObject(); + } + + for (const QJsonValue& value : chains) { + const QJsonObject chain = value.toObject(); + if (chainMatchesNetwork(chain, requested)) + return chain; + } + + return {}; + } + + QJsonObject chainForFingerprint(const QJsonArray& chains, + const QString& network, + const QString& blockHash, + const QString& blockSignature) + { + const QString requested = normalizedUrl(network); + if (!blockHash.isEmpty() || !blockSignature.isEmpty()) { + for (const QJsonValue& value : chains) { + const QJsonObject chain = value.toObject(); + if (chainMatchesFingerprint(chain, blockHash, blockSignature)) + return chain; + } + return {}; + } + + // Legacy configs had only URLs. Once a config declares deterministic + // chain identity, never silently trust the endpoint URL alone. + if (!requested.isEmpty() && chainsHaveDeterministicIdentity(chains)) + return {}; + + return chainForNetwork(chains, requested); + } + + double numberValue(const QJsonObject& object, const QString& key, double fallback = 0) + { + const QJsonValue value = object.value(key); + return value.isDouble() ? value.toDouble() : fallback; + } + + double u128LeToDouble(const QByteArray& bytes) + { + long double value = 0; + long double multiplier = 1; + for (int i = 0; i < 16; ++i) { + value += static_cast(bytes.at(i)) * multiplier; + multiplier *= 256; + if (value > MAX_SAFE_QML_INTEGER) + return MAX_SAFE_QML_INTEGER; + } + return static_cast(value); + } + + struct DecodedPoolDefinition { + QString definitionTokenAIdHex; + QString definitionTokenBIdHex; + QString vaultAIdHex; + QString vaultBIdHex; + QString liquidityPoolIdHex; + double liquidityPoolSupply = 0; + double reserveA = 0; + double reserveB = 0; + double fees = 0; + bool active = true; + }; + + bool decodePoolDefinitionData(const QString& dataHex, DecodedPoolDefinition& pool) + { + const QString trimmed = dataHex.trimmed(); + // PoolDefinition Borsh struct: + // 5 AccountId fields + liquidity_pool_supply/reserve_a/reserve_b/fees as u128. + // The deployed legacy AMM also has a trailing active bool. + if (trimmed.size() != 448 && trimmed.size() != 450) + return false; + const QByteArray data = QByteArray::fromHex(trimmed.toLatin1()); + if (data.size() != 224 && data.size() != 225) + return false; + + pool.definitionTokenAIdHex = QString::fromLatin1(data.mid(0, 32).toHex()); + pool.definitionTokenBIdHex = QString::fromLatin1(data.mid(32, 32).toHex()); + pool.vaultAIdHex = QString::fromLatin1(data.mid(64, 32).toHex()); + pool.vaultBIdHex = QString::fromLatin1(data.mid(96, 32).toHex()); + pool.liquidityPoolIdHex = QString::fromLatin1(data.mid(128, 32).toHex()); + pool.liquidityPoolSupply = u128LeToDouble(data.mid(160, 16)); + pool.reserveA = u128LeToDouble(data.mid(176, 16)); + pool.reserveB = u128LeToDouble(data.mid(192, 16)); + pool.fees = u128LeToDouble(data.mid(208, 16)); + pool.active = data.size() == 224 || data.at(224) != 0; + return true; + } + + bool decodeFungibleHoldingData(const QString& dataHex, QString& definitionIdHex, double& balance) + { + const QString trimmed = dataHex.trimmed(); + // Borsh enum discriminant (u8) + AccountId (32 bytes) + u128 balance. + if (trimmed.size() != 98) + return false; + const QByteArray data = QByteArray::fromHex(trimmed.toLatin1()); + if (data.size() != 49 || static_cast(data.at(0)) != 0) + return false; + definitionIdHex = QString::fromLatin1(data.mid(1, 32).toHex()); + balance = u128LeToDouble(data.mid(33, 16)); + return true; + } + + QString feeTierText(double feeBps) + { + QString percent = QString::number(feeBps / 100.0, 'f', 2); + while (percent.contains(QLatin1Char('.')) && percent.endsWith(QLatin1Char('0'))) + percent.chop(1); + if (percent.endsWith(QLatin1Char('.'))) + percent.chop(1); + return percent + QStringLiteral("%"); + } + + QVariantMap tokenView(const QJsonObject& definition, + double reserve, + double balance, + const QString& holdingAccount) + { + const QString symbol = stringValue(definition, QStringLiteral("symbol")); + return { + { QStringLiteral("symbol"), symbol }, + { QStringLiteral("name"), stringValue(definition, QStringLiteral("name"), symbol) }, + { QStringLiteral("color"), stringValue(definition, QStringLiteral("color"), QStringLiteral("#627eea")) }, + { QStringLiteral("letter"), stringValue(definition, QStringLiteral("letter"), symbol.left(1)) }, + { QStringLiteral("address"), stringValue(definition, QStringLiteral("definitionAccount")) }, + { QStringLiteral("holdingAccount"), holdingAccount }, + { QStringLiteral("usdPrice"), numberValue(definition, QStringLiteral("usdPrice"), 1) }, + { QStringLiteral("balance"), balance }, + { QStringLiteral("reserve"), reserve } + }; + } + + bool isHexAccountId(const QString& value) + { + const QString trimmed = value.trimmed(); + if (trimmed.size() != 64) + return false; + for (const QChar ch : trimmed) { + if (!ch.isDigit() + && (ch < QLatin1Char('a') || ch > QLatin1Char('f')) + && (ch < QLatin1Char('A') || ch > QLatin1Char('F'))) { + return false; + } + } + return true; + } + + void appendU32(QVariantList& words, quint32 word) + { + words.append(QVariant::fromValue(word)); + } + + void appendU128(QVariantList& words, quint64 value) + { + appendU32(words, static_cast(value & 0xffffffffULL)); + appendU32(words, static_cast((value >> 32) & 0xffffffffULL)); + appendU32(words, 0); + appendU32(words, 0); + } + + void appendString(QVariantList& words, const QString& value) + { + const QByteArray bytes = value.toUtf8(); + appendU32(words, static_cast(bytes.size())); + for (int i = 0; i < bytes.size(); i += 4) { + quint32 word = 0; + for (int j = 0; j < 4 && i + j < bytes.size(); ++j) + word |= static_cast(static_cast(bytes.at(i + j))) << (j * 8); + appendU32(words, word); + } + } + + QVariantList boolList(std::initializer_list values) + { + QVariantList result; + for (const bool value : values) + result.append(value); + return result; + } + + QVariantList byteList(const QByteArray& bytes) + { + QVariantList result; + result.reserve(bytes.size()); + for (const char byte : bytes) + result.append(static_cast(static_cast(byte))); + return result; + } + + bool walletOwnsPublicAccount(const AccountModel& model, const QString& accountIdHex) + { + for (int i = 0; i < model.count(); ++i) { + const QModelIndex idx = model.index(i, 0); + const QString address = model.data(idx, AccountModel::AddressRole).toString(); + const bool isPublic = model.data(idx, AccountModel::IsPublicRole).toBool(); + if (isPublic && address.compare(accountIdHex, Qt::CaseInsensitive) == 0) + return true; + } + return false; + } + + bool writeFileAtomically(const QString& path, const QByteArray& bytes) + { + const QFileInfo info(path); + if (!QDir().mkpath(info.absolutePath())) + return false; + + QSaveFile file(path); + if (!file.open(QIODevice::WriteOnly)) + return false; + if (file.write(bytes) != bytes.size()) + return false; + return file.commit(); + } + + bool restoreFile(const QString& path, const QByteArray& bytes, bool existed) + { + if (existed) + return writeFileAtomically(path, bytes); + if (!QFileInfo::exists(path)) + return true; + return QFile::remove(path); + } + + QString canonicalTargetPath(const QString& path) + { + const QFileInfo fileInfo(path); + QDir existingDir = fileInfo.absoluteDir(); + QStringList missingDirs; + while (!existingDir.exists()) { + const QString dirName = QFileInfo(existingDir.path()).fileName(); + if (dirName.isEmpty() || !existingDir.cdUp()) + return {}; + missingDirs.prepend(dirName); + } + + QString targetPath = existingDir.canonicalPath(); + if (targetPath.isEmpty()) + return {}; + for (const QString& dirName : std::as_const(missingDirs)) + targetPath = QDir(targetPath).filePath(dirName); + return QDir::cleanPath(QDir(targetPath).filePath(fileInfo.fileName())); + } + + bool pathWithinDirectory(const QString& path, const QString& directory) + { + if (path == directory) + return true; + + QString prefix = directory; + if (!prefix.endsWith(QDir::separator())) + prefix.append(QDir::separator()); + return path.startsWith(prefix); + } + + QString validatedWalletFilePath(const QString& path, + const QString& walletHome, + const QString& label, + QString* error) + { + const QString localPath = toLocalPath(path); + if (localPath.isEmpty()) { + if (error) + *error = QStringLiteral("%1 path cannot be empty").arg(label); + return {}; + } + + if (!QDir().mkpath(walletHome)) { + if (error) + *error = QStringLiteral("Cannot create wallet home"); + return {}; + } + + const QString walletRoot = QDir(walletHome).canonicalPath(); + if (walletRoot.isEmpty()) { + if (error) + *error = QStringLiteral("Wallet home path is invalid"); + return {}; + } + + if (QFileInfo(localPath).isSymLink()) { + if (error) + *error = QStringLiteral("%1 path cannot be a symbolic link").arg(label); + return {}; + } + + const QString targetPath = canonicalTargetPath(localPath); + if (targetPath.isEmpty() || !pathWithinDirectory(targetPath, walletRoot)) { + if (error) + *error = QStringLiteral("%1 path must be inside wallet home").arg(label); + return {}; + } + + return targetPath; + } + + QString validatedSequencerUrl(const QString& input, QString* error) + { + const QString trimmed = normalizedUrl(input); + const QUrl url(trimmed, QUrl::StrictMode); + const QString scheme = url.scheme().toLower(); + if (trimmed.isEmpty()) { + if (error) + *error = QStringLiteral("sequencer_addr cannot be empty"); + return {}; + } + if (!url.isValid() || url.host().isEmpty()) { + if (error) + *error = QStringLiteral("sequencer_addr must be a valid URL with a host"); + return {}; + } + if (scheme != QStringLiteral("http") && scheme != QStringLiteral("https")) { + if (error) + *error = QStringLiteral("sequencer_addr must use http or https"); + return {}; + } + return trimmed; + } + + // Matches the legacy AMM instruction enum encoded by the pinned LEZ binary. + QVariantList addLiquidityInstruction(quint64 minLp, quint64 maxA, quint64 maxB) + { + QVariantList words; + appendU32(words, 1); + appendU128(words, minLp); + appendU128(words, maxA); + appendU128(words, maxB); + return words; + } + + QVariantList removeLiquidityInstruction(quint64 burnLp, quint64 minA, quint64 minB) + { + QVariantList words; + appendU32(words, 2); + appendU128(words, burnLp); + appendU128(words, minA); + appendU128(words, minB); + return words; + } + + QVariantList swapExactInputInstruction(quint64 amountIn, quint64 minOut, const QString& tokenDefinitionIn) + { + QVariantList words; + appendU32(words, 3); + appendU128(words, amountIn); + appendU128(words, minOut); + appendString(words, tokenDefinitionIn); + return words; + } + + QVariantList swapExactOutputInstruction(quint64 exactOut, quint64 maxIn, const QString& tokenDefinitionIn) + { + QVariantList words; + appendU32(words, 4); + appendU128(words, exactOut); + appendU128(words, maxIn); + appendString(words, tokenDefinitionIn); + return words; + } + + quint64 snapshotAmount(const QVariantMap& snapshot, const QString& key, QString* error) + { + bool ok = false; + + const QVariant value = snapshot.value(key); + if (value.userType() == QMetaType::QString) { + const QString text = value.toString().trimmed(); + if (text.isEmpty()) { + ok = false; + } else { + ok = true; + for (const QChar ch : text) { + if (!ch.isDigit()) { + ok = false; + break; + } + } + } + + if (ok) { + const quint64 amount = text.toULongLong(&ok); + if (ok && amount <= static_cast(MAX_SAFE_QML_INTEGER)) + return amount; + } + } else { + const double amount = value.toDouble(&ok); + if (ok + && std::isfinite(amount) + && amount >= 0 + && amount <= MAX_SAFE_QML_INTEGER + && std::floor(amount) == amount) { + return static_cast(amount); + } + } + + if (error) + *error = QStringLiteral("Invalid transaction amount: %1").arg(key); + return 0; } } @@ -45,6 +914,9 @@ QString AmmUiBackend::defaultWalletHome() const QByteArray override = qgetenv(WALLET_HOME_ENV); if (!override.isEmpty()) return QString::fromLocal8Bit(override); + const QByteArray legacyOverride = qgetenv(LEGACY_WALLET_HOME_ENV); + if (!legacyOverride.isEmpty()) + return QString::fromLocal8Bit(legacyOverride); // 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"); @@ -52,12 +924,16 @@ QString AmmUiBackend::defaultWalletHome() QString AmmUiBackend::defaultConfigPath() const { - return defaultWalletHome() + QStringLiteral("/wallet_config.json"); + const QString path = defaultWalletHome() + QStringLiteral("/wallet_config.json"); + const QString canonical = canonicalTargetPath(path); + return canonical.isEmpty() ? QDir::cleanPath(path) : canonical; } QString AmmUiBackend::defaultStoragePath() const { - return defaultWalletHome() + QStringLiteral("/storage.json"); + const QString path = defaultWalletHome() + QStringLiteral("/storage.json"); + const QString canonical = canonicalTargetPath(path); + return canonical.isEmpty() ? QDir::cleanPath(path) : canonical; } AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent) @@ -75,23 +951,28 @@ AmmUiBackend::AmmUiBackend(LogosAPI* logosAPI, QObject* parent) setWalletHome(defaultWalletHome()); // Assume reachable until a probe proves otherwise (avoids a startup flash). setSequencerReachable(true); + setDeploymentTokens({}); + setDeploymentPool({}); + setDeploymentNetworkMatched(true); + setDeploymentIdentityPending(false); + loadDeploymentConfig(); // 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 + // Always resolve against the canonical wallet home (NSSA_WALLET_HOME_DIR, + // 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 + // A wallet exists on disk if either canonical file is present (drives whether // the navbar "Connect" reconnects or offers to create a wallet). + const QString effectiveConfig = configPath().isEmpty() ? defaultConfigPath() : configPath(); const QString effectiveStorage = storagePath().isEmpty() ? defaultStoragePath() : storagePath(); - setWalletExists(QFileInfo::exists(effectiveStorage)); + setWalletExists(QFileInfo::exists(effectiveConfig) || QFileInfo::exists(effectiveStorage)); // ui-host runs our constructor inside initLogos(), synchronously, BEFORE // it enables remoting and emits READY. Any blocking RPC here would stall @@ -116,29 +997,14 @@ void AmmUiBackend::openOrAdoptWallet() 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: if list_accounts() - // returns anything, treat the wallet as open and just mirror its state. - const QJsonArray existing = QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts()); - if (!existing.isEmpty()) { - 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). + // If no local storage exists, still try adopting a shared Basecamp wallet. 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". + if (!QFileInfo::exists(stg)) { + adoptOpenWallet(); + return; + } qDebug() << "AmmUiBackend: opening wallet with config" << cfg << "storage" << stg; const int err = m_logos->logos_execution_zone.open(cfg, stg); @@ -146,31 +1012,68 @@ void AmmUiBackend::openOrAdoptWallet() persistConfigPath(cfg); persistStoragePath(stg); setIsWalletOpen(true); - refreshAccounts(); - refreshBlockHeights(); + if (!m_reachabilityTimer->isActive()) + m_reachabilityTimer->start(); refreshSequencerAddr(); - } else { - qWarning() << "AmmUiBackend: wallet open failed, code" << err; + refreshAccounts(); + return; } + + // In Basecamp the logos_execution_zone module may already have an open + // wallet. If opening the same disk wallet failed, try mirroring that state. + if (adoptOpenWallet()) + return; + + qWarning() << "AmmUiBackend: wallet open failed, code" << err; } -bool AmmUiBackend::createNewDefault(QString password) +bool AmmUiBackend::adoptOpenWallet() +{ + const QJsonArray existing = listAccounts(); + if (existing.isEmpty()) + return false; + + qDebug() << "AmmUiBackend: adopting already-open shared wallet" + << existing.size() << "accounts"; + setIsWalletOpen(true); + if (!m_reachabilityTimer->isActive()) + m_reachabilityTimer->start(); + m_accountModel->replaceFromJsonArray(existing); + refreshSequencerAddr(); + refreshBalances(); + QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, false); + return true; +} + +QString AmmUiBackend::createNewDefault(QString password) { - QDir().mkpath(defaultWalletHome()); return createNew(defaultConfigPath(), defaultStoragePath(), password); } -bool AmmUiBackend::createNew(QString configPath, QString storagePath, QString password) +QString AmmUiBackend::createNew(QString configPath, QString storagePath, QString password) { - const QString localConfig = toLocalPath(configPath); - const QString localStorage = toLocalPath(storagePath); - // create_new now returns the new wallet's BIP39 mnemonic (empty on failure), - // not an int status code. TODO: surface this seed phrase to the user for - // backup — it is currently discarded, so the wallet can't be recovered. + QString pathError; + const QString localConfig = + validatedWalletFilePath(configPath, defaultWalletHome(), QStringLiteral("Config"), &pathError); + const QString localStorage = + validatedWalletFilePath(storagePath, defaultWalletHome(), QStringLiteral("Storage"), &pathError); + if (!pathError.isEmpty()) { + qWarning() << "AmmUiBackend: refusing wallet path:" << pathError; + return QString(); + } + if (localConfig != defaultConfigPath() || localStorage != defaultStoragePath()) { + qWarning() << "AmmUiBackend: refusing non-canonical wallet paths"; + return QString(); + } + if (QFileInfo::exists(localConfig) || QFileInfo::exists(localStorage)) { + qWarning() << "AmmUiBackend: refusing to create wallet over existing files"; + return QString(); + } + const QString mnemonic = m_logos->logos_execution_zone.create_new(localConfig, localStorage, password); if (mnemonic.isEmpty()) { qWarning() << "AmmUiBackend: create_new failed (empty mnemonic)"; - return false; + return QString(); } persistConfigPath(localConfig); @@ -178,42 +1081,35 @@ bool AmmUiBackend::createNew(QString configPath, QString storagePath, QString pa setWalletExists(true); QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, false); setIsWalletOpen(true); - refreshAccounts(); - refreshBlockHeights(); + if (!m_reachabilityTimer->isActive()) + m_reachabilityTimer->start(); refreshSequencerAddr(); - return true; + refreshAccounts(); + return mnemonic; } bool AmmUiBackend::openExisting() { - // Adopt a shared open wallet (Basecamp), else open our own from disk. - const QJsonArray existing = QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts()); - if (!existing.isEmpty()) { - 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; + return adoptOpenWallet(); const int err = m_logos->logos_execution_zone.open(cfg, stg); if (err != WALLET_FFI_SUCCESS) { + if (adoptOpenWallet()) + return true; qWarning() << "AmmUiBackend: openExisting failed, code" << err; return false; } persistConfigPath(cfg); persistStoragePath(stg); setIsWalletOpen(true); + if (!m_reachabilityTimer->isActive()) + m_reachabilityTimer->start(); QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, false); - refreshAccounts(); - refreshBlockHeights(); refreshSequencerAddr(); + refreshAccounts(); return true; } @@ -223,8 +1119,15 @@ void AmmUiBackend::disconnectWallet() // the choice. We do NOT close the core module's wallet handle — in Basecamp // that instance is shared with other apps. saveWallet(); + ++m_reachabilityProbeGeneration; + ++m_chainIdentityProbeGeneration; + ++m_deploymentCheckGeneration; + m_reachabilityTimer->stop(); setIsWalletOpen(false); + setSequencerAddr({}); + setSequencerReachable(true); m_accountModel->replaceFromJsonArray(QJsonArray()); + selectDeploymentForNetwork({}); QSettings(SETTINGS_ORG, SETTINGS_APP).setValue(DISCONNECTED_KEY, true); } @@ -246,7 +1149,7 @@ QString AmmUiBackend::createAccountPrivate() void AmmUiBackend::refreshAccounts() { - const QJsonArray arr = QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts()); + const QJsonArray arr = listAccounts(); m_accountModel->replaceFromJsonArray(arr); refreshBalances(); } @@ -263,6 +1166,7 @@ void AmmUiBackend::refreshBalances() const bool isPub = m_accountModel->data(idx, AccountModel::IsPublicRole).toBool(); m_accountModel->setBalanceByAddress(addr, getBalance(addr, isPub)); } + refreshDeploymentWalletState(); saveWallet(); } @@ -283,31 +1187,736 @@ void AmmUiBackend::refreshBlockHeights() void AmmUiBackend::refreshSequencerAddr() { + if (!isWalletOpen()) { + if (!sequencerAddr().isEmpty()) + setSequencerAddr({}); + setSequencerReachable(true); + selectDeploymentForNetwork({}); + return; + } + 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(); + if (addr.isEmpty()) { + selectDeploymentForNetwork({}); + return; + } + clearDeploymentSelection(addr); + // Probe right away so the banner reflects the connected chain without + // trusting the endpoint URL as identity. + probeChainIdentity(addr); +} + +void AmmUiBackend::loadDeploymentConfig() +{ + const QJsonObject supportedChainsRoot = + loadConfigObject(QStringLiteral("supported-chains.json")); + const QJsonArray supportedChains = + supportedChainsRoot.value(QStringLiteral("chains")).toArray(); + const QJsonObject tokenRoot = loadConfigObject(QStringLiteral("token-programs.json")); + const QJsonObject ammRoot = loadConfigObject(QStringLiteral("amm-programs.json")); + const QJsonObject ataRoot = loadConfigObject(QStringLiteral("ata-programs.json")); + const QJsonObject twapOracleRoot = + loadConfigObject(QStringLiteral("twap-oracle-programs.json")); + const QJsonObject stablecoinRoot = + loadConfigObject(QStringLiteral("stablecoin-programs.json")); + + const QString legacyNetwork = normalizedUrl( + stringValue(tokenRoot, QStringLiteral("network"), QString::fromLatin1(DEFAULT_SEQUENCER))); + m_tokenChains = deploymentChains(tokenRoot, legacyNetwork, supportedChains); + m_ammChains = deploymentChains(ammRoot, legacyNetwork, supportedChains); + m_programChainGroups = {}; + for (const QJsonObject& root : + { tokenRoot, ammRoot, ataRoot, twapOracleRoot, stablecoinRoot }) { + const QJsonArray chains = deploymentChains(root, legacyNetwork, supportedChains); + if (!chains.isEmpty()) + m_programChainGroups.append(chains); + } + selectDeploymentForNetwork({}); +} + +void AmmUiBackend::selectDeploymentForNetwork(const QString& network) +{ + selectDeploymentForChain(network, {}, {}); +} + +void AmmUiBackend::clearDeploymentSelection(const QString& network) +{ + m_activeDeploymentConfigured = false; + m_activeDeploymentDeployed = false; + m_identityProbeInFlight = false; + m_activeDeploymentNetwork = normalizedUrl(network); + m_tokenDefinitions = {}; + m_poolConfig = {}; + m_requiredDeploymentTransactions = {}; + m_pendingDeploymentChecks = 0; + m_deploymentChecksFailed = false; + setDeploymentTokens({}); + setDeploymentPool({}); + setDeploymentIdentityPending(false); + updateDeploymentNetworkMatched(); +} + +void AmmUiBackend::setDeploymentIdentityPendingIfNeeded(bool pending) +{ + if (deploymentIdentityPending() != pending) { + setDeploymentIdentityPending(pending); + updateDeploymentNetworkMatched(); + } +} + +void AmmUiBackend::selectDeploymentForChain(const QString& network, + const QString& blockHash, + const QString& blockSignature) +{ + const QString requestedNetwork = normalizedUrl(network); + const QJsonObject tokenChain = + chainForFingerprint(m_tokenChains, requestedNetwork, blockHash, blockSignature); + const QJsonObject ammChain = + chainForFingerprint(m_ammChains, requestedNetwork, blockHash, blockSignature); + + m_activeDeploymentConfigured = false; + m_activeDeploymentDeployed = false; + m_identityProbeInFlight = false; + m_activeDeploymentNetwork = requestedNetwork.isEmpty() ? chainNetwork(ammChain) : requestedNetwork; + m_tokenDefinitions = {}; + m_poolConfig = {}; + m_requiredDeploymentTransactions = {}; + m_pendingDeploymentChecks = 0; + m_deploymentChecksFailed = false; + setDeploymentIdentityPending(false); + + if (tokenChain.isEmpty() || ammChain.isEmpty()) { + if (!requestedNetwork.isEmpty()) + qWarning() << "AmmUiBackend: no AMM deployment configured for chain" << requestedNetwork; + setDeploymentTokens({}); + setDeploymentPool({}); + updateDeploymentNetworkMatched(); + return; + } + + if (m_activeDeploymentNetwork.isEmpty()) + m_activeDeploymentNetwork = chainNetwork(tokenChain); + + const QJsonObject tokenProgram = firstObject(tokenChain, QStringLiteral("programs")); + const QJsonArray definitions = tokenProgram.value(QStringLiteral("definitions")).toArray(); + const QJsonObject ammProgram = firstObject(ammChain, QStringLiteral("programs")); + const QString abi = stringValue(ammProgram, QStringLiteral("abi")); + if (abi != QString::fromLatin1(LEGACY_AMM_ABI)) { + qWarning() << "AmmUiBackend: unsupported AMM ABI" << abi; + setDeploymentTokens({}); + setDeploymentPool({}); + updateDeploymentNetworkMatched(); + return; + } + const QJsonObject pool = firstObject(ammProgram, QStringLiteral("pools")); + if (definitions.isEmpty() || pool.isEmpty()) { + qWarning() << "AmmUiBackend: incomplete AMM deployment config for chain" + << m_activeDeploymentNetwork; + setDeploymentTokens({}); + setDeploymentPool({}); + updateDeploymentNetworkMatched(); + return; + } + + m_tokenDefinitions = definitions; + m_poolConfig = pool; + for (const QJsonValue& value : m_programChainGroups) { + const QJsonObject chain = + chainForFingerprint(value.toArray(), requestedNetwork, blockHash, blockSignature); + appendDeploymentTransactions(chain, &m_requiredDeploymentTransactions); + } + m_activeDeploymentConfigured = true; + verifyDeploymentTransactions(); + updateDeploymentNetworkMatched(); +} + +void AmmUiBackend::verifyDeploymentTransactions() +{ + const quint64 generation = ++m_deploymentCheckGeneration; + if (!isWalletOpen() || m_requiredDeploymentTransactions.isEmpty()) { + m_pendingDeploymentChecks = 0; + m_deploymentChecksFailed = false; + m_activeDeploymentDeployed = true; + refreshDeploymentWalletState(); + return; + } + + m_activeDeploymentDeployed = false; + m_deploymentChecksFailed = false; + m_pendingDeploymentChecks = m_requiredDeploymentTransactions.size(); + updateDeploymentNetworkMatched(); + + const QString requested = normalizedUrl(sequencerAddr()); + for (const QString& hash : std::as_const(m_requiredDeploymentTransactions)) { + QJsonArray params; + params.append(hash); + + QNetworkRequest req{QUrl(requested)}; + req.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); + req.setTransferTimeout(4000); + QNetworkReply* reply = + m_net->post(req, jsonRpcBody(QStringLiteral("getTransaction"), params)); + connect(reply, &QNetworkReply::finished, this, [this, reply, requested, generation, hash]() { + if (generation != m_deploymentCheckGeneration + || normalizedUrl(sequencerAddr()) != requested) { + reply->deleteLater(); + return; + } + + const bool gotHttpStatus = + reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).isValid(); + const bool transportOk = gotHttpStatus || reply->error() == QNetworkReply::NoError; + const TransactionLookupStatus status = transportOk + ? transactionLookupStatus(reply->readAll()) + : TransactionLookupStatus::TransientFailure; + if (status == TransactionLookupStatus::TransientFailure) { + qWarning() << "AmmUiBackend: deployment transaction check failed" + << hash << "on" << requested; + m_deploymentChecksFailed = true; + setDeploymentIdentityPendingIfNeeded(true); + } else if (status == TransactionLookupStatus::Missing) { + qWarning() << "AmmUiBackend: deployment transaction not found" + << hash << "on" << requested; + m_deploymentChecksFailed = true; + } + + --m_pendingDeploymentChecks; + if (m_pendingDeploymentChecks == 0) { + m_activeDeploymentDeployed = !m_deploymentChecksFailed; + if (m_activeDeploymentDeployed) + refreshDeploymentWalletState(); + else if (!deploymentIdentityPending()) { + setDeploymentTokens({}); + setDeploymentPool({}); + updateDeploymentNetworkMatched(); + } + } + reply->deleteLater(); + }); + } +} + +void AmmUiBackend::refreshDeploymentWalletState() +{ + const QJsonObject tokenA = configuredTokenDefinition( + stringValue(m_poolConfig, QStringLiteral("tokenA")), 0); + const QJsonObject tokenB = configuredTokenDefinition( + stringValue(m_poolConfig, QStringLiteral("tokenB")), 1); + if (m_poolConfig.isEmpty() || tokenA.isEmpty() || tokenB.isEmpty()) { + m_activeDeploymentDeployed = false; + setDeploymentTokens({}); + setDeploymentPool({}); + updateDeploymentNetworkMatched(); + return; + } + + const WalletFungibleHolding holdingA = walletFungibleHolding( + stringValue(tokenA, QStringLiteral("definitionAccount"))); + const WalletFungibleHolding holdingB = walletFungibleHolding( + stringValue(tokenB, QStringLiteral("definitionAccount"))); + const WalletFungibleHolding holdingLp = walletFungibleHolding( + stringValue(m_poolConfig, QStringLiteral("lpDefinitionAccount"))); + const PoolChainState pool = poolChainState(); + if (isWalletOpen() && !pool.found) { + qWarning() << "AmmUiBackend: AMM pool not deployed on chain" + << normalizedUrl(sequencerAddr()); + m_activeDeploymentDeployed = false; + setDeploymentTokens({}); + setDeploymentPool({}); + updateDeploymentNetworkMatched(); + return; + } + + m_activeDeploymentDeployed = true; + + QVariantList tokens; + tokens.append(tokenView(tokenA, pool.reserveA, holdingA.balance, holdingA.accountIdHex)); + tokens.append(tokenView(tokenB, pool.reserveB, holdingB.balance, holdingB.accountIdHex)); + setDeploymentTokens(tokens); + + const int feeBps = static_cast(pool.feeBps); + const QVariantMap poolView{ + { QStringLiteral("account"), stringValue(m_poolConfig, QStringLiteral("account")) }, + { QStringLiteral("network"), m_activeDeploymentNetwork }, + { QStringLiteral("tokenA"), stringValue(m_poolConfig, QStringLiteral("tokenA")) }, + { QStringLiteral("tokenB"), stringValue(m_poolConfig, QStringLiteral("tokenB")) }, + { QStringLiteral("feeBps"), feeBps }, + { QStringLiteral("feeTier"), feeTierText(pool.feeBps) }, + { QStringLiteral("userLpBalance"), holdingLp.balance }, + { QStringLiteral("reserveA"), pool.reserveA }, + { QStringLiteral("reserveB"), pool.reserveB }, + { QStringLiteral("totalLpSupply"), pool.totalLpSupply }, + { QStringLiteral("walletBalanceA"), holdingA.balance }, + { QStringLiteral("walletBalanceB"), holdingB.balance } + }; + setDeploymentPool(poolView); + updateDeploymentNetworkMatched(); +} + +void AmmUiBackend::updateDeploymentNetworkMatched() +{ + const bool matched = !isWalletOpen() + || deploymentIdentityPending() + || (m_activeDeploymentConfigured && m_activeDeploymentDeployed); + if (deploymentNetworkMatched() != matched) + setDeploymentNetworkMatched(matched); +} + +QJsonObject AmmUiBackend::configuredTokenDefinition(const QString& symbol, int fallbackIndex) const +{ + return tokenDefinition(m_tokenDefinitions, symbol, fallbackIndex); +} + +QString AmmUiBackend::accountIdHex(const QString& accountId) const +{ + const QString trimmed = accountId.trimmed(); + if (trimmed.isEmpty()) + return {}; + if (isHexAccountId(trimmed)) + return trimmed.toLower(); + return m_logos->logos_execution_zone.account_id_from_base58(trimmed); +} + +QStringList AmmUiBackend::accountIdHexList(const QStringList& accountIds, QString* error) const +{ + QStringList result; + result.reserve(accountIds.size()); + for (const QString& accountId : accountIds) { + const QString hex = accountIdHex(accountId); + if (hex.isEmpty()) { + if (error) + *error = QStringLiteral("Invalid account id in AMM config: %1").arg(accountId); + return {}; + } + result.append(hex); + } + return result; +} + +AmmUiBackend::PoolChainState AmmUiBackend::poolChainState() const +{ + PoolChainState result; + if (!isWalletOpen()) + return result; + + const QString poolAccountIdHex = accountIdHex(stringValue(m_poolConfig, QStringLiteral("account"))); + if (poolAccountIdHex.isEmpty()) + return result; + + const QString accountJson = m_logos->logos_execution_zone.get_account_public(poolAccountIdHex); + const QJsonDocument doc = QJsonDocument::fromJson(accountJson.toUtf8()); + if (!doc.isObject()) + return result; + + DecodedPoolDefinition decoded; + if (!decodePoolDefinitionData( + doc.object().value(QStringLiteral("data")).toString(), + decoded)) { + return result; + } + + const QJsonObject tokenA = configuredTokenDefinition( + stringValue(m_poolConfig, QStringLiteral("tokenA")), 0); + const QJsonObject tokenB = configuredTokenDefinition( + stringValue(m_poolConfig, QStringLiteral("tokenB")), 1); + const QString definitionAIdHex = accountIdHex(stringValue(tokenA, QStringLiteral("definitionAccount"))); + const QString definitionBIdHex = accountIdHex(stringValue(tokenB, QStringLiteral("definitionAccount"))); + const QString vaultAIdHex = accountIdHex(stringValue(m_poolConfig, QStringLiteral("vaultA"))); + const QString vaultBIdHex = accountIdHex(stringValue(m_poolConfig, QStringLiteral("vaultB"))); + const QString lpDefinitionIdHex = accountIdHex( + stringValue(m_poolConfig, QStringLiteral("lpDefinitionAccount"))); + + const bool matchesConfig = + decoded.definitionTokenAIdHex.compare(definitionAIdHex, Qt::CaseInsensitive) == 0 + && decoded.definitionTokenBIdHex.compare(definitionBIdHex, Qt::CaseInsensitive) == 0 + && decoded.vaultAIdHex.compare(vaultAIdHex, Qt::CaseInsensitive) == 0 + && decoded.vaultBIdHex.compare(vaultBIdHex, Qt::CaseInsensitive) == 0 + && decoded.liquidityPoolIdHex.compare(lpDefinitionIdHex, Qt::CaseInsensitive) == 0; + if (!matchesConfig) { + qWarning() << "AmmUiBackend: pool account state does not match deployment config"; + return result; + } + if (!decoded.active) { + qWarning() << "AmmUiBackend: pool account is inactive"; + return result; + } + + result.reserveA = decoded.reserveA; + result.reserveB = decoded.reserveB; + result.totalLpSupply = decoded.liquidityPoolSupply; + result.feeBps = decoded.fees; + result.found = true; + return result; +} + +AmmUiBackend::WalletFungibleHolding AmmUiBackend::walletFungibleHolding( + const QString& definitionAccountId, + const QString& accountIdFilterHex) const +{ + WalletFungibleHolding result; + const QString definitionIdHex = accountIdHex(definitionAccountId); + if (definitionIdHex.isEmpty()) + return result; + const QString requiredAccountIdHex = canonicalHex(accountIdFilterHex); + + for (int i = 0; i < m_accountModel->count(); ++i) { + const QModelIndex idx = m_accountModel->index(i, 0); + if (!m_accountModel->data(idx, AccountModel::IsPublicRole).toBool()) + continue; + + const QString accountIdHex = m_accountModel->data(idx, AccountModel::AddressRole).toString(); + if (accountIdHex.isEmpty()) + continue; + if (!requiredAccountIdHex.isEmpty() + && accountIdHex.compare(requiredAccountIdHex, Qt::CaseInsensitive) != 0) { + continue; + } + + const QString accountJson = m_logos->logos_execution_zone.get_account_public(accountIdHex); + const QJsonDocument doc = QJsonDocument::fromJson(accountJson.toUtf8()); + if (!doc.isObject()) + continue; + + QString holdingDefinitionIdHex; + double balance = 0; + if (!decodeFungibleHoldingData( + doc.object().value(QStringLiteral("data")).toString(), + holdingDefinitionIdHex, + balance)) { + continue; + } + + if (holdingDefinitionIdHex.compare(definitionIdHex, Qt::CaseInsensitive) != 0) + continue; + + if (result.found) { + result.ambiguous = true; + return result; + } + result.accountIdHex = accountIdHex; + result.balance = balance; + result.found = true; + } + + return result; +} + +QString AmmUiBackend::selectedWalletAccountIdHex(const QVariantMap& snapshot, QString* error) const +{ + const QString selected = + snapshot.value(QStringLiteral("selectedWalletAccount")).toString().trimmed(); + if (selected.isEmpty()) { + if (error) + *error = QStringLiteral("No wallet account selected"); + return {}; + } + + const QString selectedHex = accountIdHex(selected); + if (selectedHex.isEmpty()) { + if (error) + *error = QStringLiteral("Selected wallet account is invalid"); + return {}; + } + + if (!walletOwnsPublicAccount(*m_accountModel, selectedHex)) { + if (error) + *error = QStringLiteral("Selected wallet account is not a public account controlled by this wallet"); + return {}; + } + + return selectedHex; +} + +QString AmmUiBackend::submitAmmTransaction(const QStringList& accountIds, + const QVariantList& signingRequirements, + const QVariantList& instruction) +{ + if (!isWalletOpen()) + return txError(QStringLiteral("Wallet is not connected")); + if (!deploymentNetworkMatched()) + return txError(QStringLiteral("Unsupported chain")); + for (int i = 0; i < accountIds.size(); ++i) { + if (!signingRequirements.at(i).toBool()) + continue; + if (walletOwnsPublicAccount(*m_accountModel, accountIds.at(i))) + continue; + const QString displayAccountId = m_logos->logos_execution_zone.account_id_to_base58(accountIds.at(i)); + if (displayAccountId.isEmpty()) + return txError(QStringLiteral("Internal error: required signer account cannot be displayed")); + return txError(QStringLiteral("Wallet does not control required signer account: %1") + .arg(displayAccountId)); + } + + QString error; + const QByteArray ammElf = loadProgramBinary(QStringLiteral("amm.bin"), &error); + if (ammElf.isEmpty()) + return txError(error); + const QByteArray tokenElf = loadProgramBinary(QStringLiteral("token.bin"), &error); + if (tokenElf.isEmpty()) + return txError(error); + + QVariantList dependencies; + dependencies.append(QVariant::fromValue(byteList(tokenElf))); + const QString result = m_logos->logos_execution_zone.send_generic_public_transaction( + accountIds, signingRequirements, QVariant::fromValue(instruction), ammElf, + QVariant::fromValue(dependencies)); + if (result.isEmpty()) + return txError(QStringLiteral("Wallet returned an empty transaction result")); + + const QJsonDocument doc = QJsonDocument::fromJson(result.toUtf8()); + if (doc.isObject() && doc.object().value(QStringLiteral("success")).toBool()) + refreshBalances(); + return result; +} + +QString AmmUiBackend::submitSwap(QVariantMap snapshot) +{ + const QJsonObject tokenA = configuredTokenDefinition( + stringValue(m_poolConfig, QStringLiteral("tokenA")), 0); + const QJsonObject tokenB = configuredTokenDefinition( + stringValue(m_poolConfig, QStringLiteral("tokenB")), 1); + if (m_poolConfig.isEmpty() || tokenA.isEmpty() || tokenB.isEmpty()) + return txError(QStringLiteral("AMM deployment config is incomplete")); + + const QString sellSymbol = snapshot.value(QStringLiteral("sellToken")).toString(); + const bool sellA = sellSymbol == stringValue(tokenA, QStringLiteral("symbol")); + const bool sellB = sellSymbol == stringValue(tokenB, QStringLiteral("symbol")); + if (!sellA && !sellB) + return txError(QStringLiteral("Swap token is not part of the configured pool")); + + QString error; + const QString selectedAccountIdHex = selectedWalletAccountIdHex(snapshot, &error); + if (!error.isEmpty()) + return txError(error); + + const QString tokenDefinitionIn = stringValue( + sellA ? tokenA : tokenB, QStringLiteral("definitionAccount")); + if (tokenDefinitionIn.isEmpty()) + return txError(QStringLiteral("Token definition account missing from AMM config")); + QVariantList instruction; + if (snapshot.value(QStringLiteral("swapMode")).toString() == QStringLiteral("swap-exact-output")) { + instruction = swapExactOutputInstruction( + snapshotAmount(snapshot, QStringLiteral("buyAmountValue"), &error), + snapshotAmount(snapshot, QStringLiteral("maxSentAmountValue"), &error), + tokenDefinitionIn); + } else { + instruction = swapExactInputInstruction( + snapshotAmount(snapshot, QStringLiteral("sellAmountValue"), &error), + snapshotAmount(snapshot, QStringLiteral("minReceivedAmountValue"), &error), + tokenDefinitionIn); + } + if (!error.isEmpty()) + return txError(error); + + const WalletFungibleHolding selectedHolding = walletFungibleHolding( + tokenDefinitionIn, selectedAccountIdHex); + if (!selectedHolding.found) { + return txError(QStringLiteral("Selected account is not a %1 holding account").arg( + stringValue(sellA ? tokenA : tokenB, QStringLiteral("symbol")))); + } + + const WalletFungibleHolding holdingA = sellA + ? selectedHolding + : walletFungibleHolding(stringValue(tokenA, QStringLiteral("definitionAccount"))); + const WalletFungibleHolding holdingB = sellB + ? selectedHolding + : walletFungibleHolding(stringValue(tokenB, QStringLiteral("definitionAccount"))); + if (!holdingA.found) + return txError(QStringLiteral("Wallet has no %1 holding account").arg( + stringValue(tokenA, QStringLiteral("symbol")))); + if (!holdingB.found) + return txError(QStringLiteral("Wallet has no %1 holding account").arg( + stringValue(tokenB, QStringLiteral("symbol")))); + if (holdingA.ambiguous) + return txError(QStringLiteral("Wallet has multiple %1 holding accounts").arg( + stringValue(tokenA, QStringLiteral("symbol")))); + if (holdingB.ambiguous) + return txError(QStringLiteral("Wallet has multiple %1 holding accounts").arg( + stringValue(tokenB, QStringLiteral("symbol")))); + + QStringList swapAccountIds; + swapAccountIds << stringValue(m_poolConfig, QStringLiteral("account")) + << stringValue(m_poolConfig, QStringLiteral("vaultA")) + << stringValue(m_poolConfig, QStringLiteral("vaultB")) + << holdingA.accountIdHex + << holdingB.accountIdHex; + const QStringList accountIds = accountIdHexList(swapAccountIds, &error); + if (!error.isEmpty()) + return txError(error); + + return submitAmmTransaction( + accountIds, + boolList({ false, false, false, sellA, sellB }), + instruction); +} + +QString AmmUiBackend::submitLiquidity(QVariantMap snapshot) +{ + const QJsonObject tokenA = configuredTokenDefinition( + stringValue(m_poolConfig, QStringLiteral("tokenA")), 0); + const QJsonObject tokenB = configuredTokenDefinition( + stringValue(m_poolConfig, QStringLiteral("tokenB")), 1); + if (m_poolConfig.isEmpty() || tokenA.isEmpty() || tokenB.isEmpty()) + return txError(QStringLiteral("AMM deployment config is incomplete")); + + const QString action = snapshot.value(QStringLiteral("action")).toString(); + QString error; + const QString selectedAccountIdHex = selectedWalletAccountIdHex(snapshot, &error); + if (!error.isEmpty()) + return txError(error); + + QVariantList instruction; + QVariantList signingRequirements; + if (action == QStringLiteral("add")) { + instruction = addLiquidityInstruction( + snapshotAmount(snapshot, QStringLiteral("minLpReceivedAmount"), &error), + snapshotAmount(snapshot, QStringLiteral("actualAValue"), &error), + snapshotAmount(snapshot, QStringLiteral("actualBValue"), &error)); + signingRequirements = boolList({ false, false, false, false, true, true, false }); + } else if (action == QStringLiteral("remove")) { + instruction = removeLiquidityInstruction( + snapshotAmount(snapshot, QStringLiteral("burnAmount"), &error), + snapshotAmount(snapshot, QStringLiteral("minTokenAReceivedAmount"), &error), + snapshotAmount(snapshot, QStringLiteral("minTokenBReceivedAmount"), &error)); + signingRequirements = boolList({ false, false, false, false, false, false, true }); + } else { + return txError(QStringLiteral("Unknown liquidity action")); + } + if (!error.isEmpty()) + return txError(error); + + const WalletFungibleHolding selectedHoldingA = walletFungibleHolding( + stringValue(tokenA, QStringLiteral("definitionAccount")), selectedAccountIdHex); + const WalletFungibleHolding selectedHoldingB = walletFungibleHolding( + stringValue(tokenB, QStringLiteral("definitionAccount")), selectedAccountIdHex); + const WalletFungibleHolding holdingA = selectedHoldingA.found + ? selectedHoldingA + : walletFungibleHolding(stringValue(tokenA, QStringLiteral("definitionAccount"))); + const WalletFungibleHolding holdingB = selectedHoldingB.found + ? selectedHoldingB + : walletFungibleHolding(stringValue(tokenB, QStringLiteral("definitionAccount"))); + if (!holdingA.found) + return txError(QStringLiteral("Wallet has no %1 holding account").arg( + stringValue(tokenA, QStringLiteral("symbol")))); + if (!holdingB.found) + return txError(QStringLiteral("Wallet has no %1 holding account").arg( + stringValue(tokenB, QStringLiteral("symbol")))); + if (holdingA.ambiguous) + return txError(QStringLiteral("Wallet has multiple %1 holding accounts").arg( + stringValue(tokenA, QStringLiteral("symbol")))); + if (holdingB.ambiguous) + return txError(QStringLiteral("Wallet has multiple %1 holding accounts").arg( + stringValue(tokenB, QStringLiteral("symbol")))); + + const WalletFungibleHolding holdingLp = walletFungibleHolding( + stringValue(m_poolConfig, QStringLiteral("lpDefinitionAccount")), + action == QStringLiteral("remove") ? selectedAccountIdHex : QString{}); + QString lpHoldingAccountId = holdingLp.accountIdHex; + if (holdingLp.ambiguous) + return txError(QStringLiteral("Wallet has multiple LP holding accounts for this pool")); + if (action == QStringLiteral("add") && lpHoldingAccountId.isEmpty()) { + lpHoldingAccountId = createAccountPublic(); + if (lpHoldingAccountId.isEmpty()) + return txError(QStringLiteral("Could not create LP holding account")); + } else if (action == QStringLiteral("remove") && !holdingLp.found) { + return txError(QStringLiteral("Selected account is not an LP holding account for this pool")); + } + + QStringList liquidityAccountIds; + liquidityAccountIds << stringValue(m_poolConfig, QStringLiteral("account")) + << stringValue(m_poolConfig, QStringLiteral("vaultA")) + << stringValue(m_poolConfig, QStringLiteral("vaultB")) + << stringValue(m_poolConfig, QStringLiteral("lpDefinitionAccount")) + << holdingA.accountIdHex + << holdingB.accountIdHex + << lpHoldingAccountId; + const QStringList accountIds = accountIdHexList(liquidityAccountIds, &error); + if (!error.isEmpty()) + return txError(error); + + return submitAmmTransaction(accountIds, signingRequirements, instruction); } void AmmUiBackend::checkReachability() { - const QString addr = sequencerAddr(); - if (addr.isEmpty()) + const QString requested = normalizedUrl(sequencerAddr()); + if (requested.isEmpty()) return; + const quint64 generation = ++m_reachabilityProbeGeneration; - QNetworkRequest req{QUrl(addr)}; + QNetworkRequest req{QUrl(requested)}; req.setTransferTimeout(4000); QNetworkReply* reply = m_net->get(req); - connect(reply, &QNetworkReply::finished, this, [this, reply]() { + connect(reply, &QNetworkReply::finished, this, [this, reply, requested, generation]() { + if (generation != m_reachabilityProbeGeneration + || normalizedUrl(sequencerAddr()) != requested) { + reply->deleteLater(); + return; + } + // 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; + const bool wasReachable = sequencerReachable(); + if (wasReachable != reachable) + setSequencerReachable(reachable); + if (reachable + && (!wasReachable || deploymentIdentityPending()) + && !m_identityProbeInFlight) { + probeChainIdentity(requested); + } + reply->deleteLater(); + }); +} + +void AmmUiBackend::probeChainIdentity(const QString& network) +{ + const QString requested = normalizedUrl(network); + const quint64 generation = ++m_chainIdentityProbeGeneration; + clearDeploymentSelection(requested); + m_identityProbeInFlight = true; + setDeploymentIdentityPendingIfNeeded(true); + + QJsonArray params; + params.append(CHAIN_IDENTITY_BLOCK); + + QNetworkRequest req{QUrl(requested)}; + req.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); + req.setTransferTimeout(4000); + QNetworkReply* reply = + m_net->post(req, jsonRpcBody(QStringLiteral("getBlock"), params)); + connect(reply, &QNetworkReply::finished, this, [this, reply, requested, generation]() { + if (generation != m_chainIdentityProbeGeneration + || normalizedUrl(sequencerAddr()) != requested) { + if (generation == m_chainIdentityProbeGeneration) + m_identityProbeInFlight = false; + reply->deleteLater(); + return; + } + m_identityProbeInFlight = false; + const bool gotHttpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).isValid(); const bool reachable = gotHttpStatus || reply->error() == QNetworkReply::NoError; if (sequencerReachable() != reachable) setSequencerReachable(reachable); + + const QByteArray payload = reply->readAll(); + const ChainFingerprint fingerprint = chainFingerprintFromGetBlockResponse(payload); + if (!fingerprint.isValid()) { + qWarning() << "AmmUiBackend: could not read chain identity from" << requested; + setDeploymentIdentityPendingIfNeeded(false); + reply->deleteLater(); + return; + } + + setDeploymentIdentityPendingIfNeeded(false); + selectDeploymentForChain( + requested, fingerprint.blockHash, fingerprint.blockSignature); reply->deleteLater(); }); } @@ -332,40 +1941,95 @@ void AmmUiBackend::persistStoragePath(const QString& path) setStoragePath(toLocalPath(path)); } +QJsonArray AmmUiBackend::listAccounts() +{ + const QJsonArray raw = QJsonArray::fromVariantList(m_logos->logos_execution_zone.list_accounts()); + QJsonArray accounts; + + for (const QJsonValue& value : raw) { + QJsonObject account = value.isObject() ? value.toObject() : QJsonObject{}; + if (!value.isObject()) + account.insert(QString::fromLatin1(ACCOUNT_ID_KEY), value.toString()); + + const QString accountIdHex = account.value(QString::fromLatin1(ACCOUNT_ID_KEY)).toString(); + const QString accountIdBase58 = m_logos->logos_execution_zone.account_id_to_base58(accountIdHex); + account.insert(QString::fromLatin1(DISPLAY_ACCOUNT_ID_KEY), + accountIdBase58.isEmpty() ? accountIdHex : accountIdBase58); + accounts.append(account); + } + + return accounts; +} + bool AmmUiBackend::changeSequencerAddr(QString url) { - const QString trimmed = url.trimmed(); - if (trimmed.isEmpty()) { - qWarning() << "AmmUiBackend: refusing to set empty sequencer_addr"; + QString validationError; + const QString validated = validatedSequencerUrl(url, &validationError); + if (validated.isEmpty()) { + qWarning() << "AmmUiBackend: refusing sequencer_addr:" << validationError; return false; } - const QString cfg = configPath().isEmpty() ? defaultConfigPath() : configPath(); - + QString pathError; + const QString cfg = validatedWalletFilePath( + configPath().isEmpty() ? defaultConfigPath() : configPath(), + defaultWalletHome(), + QStringLiteral("Config"), + &pathError); + const QString stg = validatedWalletFilePath( + storagePath().isEmpty() ? defaultStoragePath() : storagePath(), + defaultWalletHome(), + QStringLiteral("Storage"), + &pathError); + if (!pathError.isEmpty()) { + qWarning() << "AmmUiBackend: refusing wallet path:" << pathError; + return false; + } // 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; + QByteArray oldConfigBytes; + const bool oldConfigExists = QFileInfo::exists(cfg); QFile in(cfg); - if (in.open(QIODevice::ReadOnly)) { - obj = QJsonDocument::fromJson(in.readAll()).object(); - in.close(); - } - obj.insert(QStringLiteral("sequencer_addr"), trimmed); + if (oldConfigExists) { + if (!in.open(QIODevice::ReadOnly)) { + qWarning() << "AmmUiBackend: cannot read wallet config" << cfg; + return false; + } - QFile out(cfg); - if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) { - qWarning() << "AmmUiBackend: cannot write wallet config" << cfg; + oldConfigBytes = in.readAll(); + QJsonParseError parseError; + const QJsonDocument doc = QJsonDocument::fromJson(oldConfigBytes, &parseError); + in.close(); + if (parseError.error != QJsonParseError::NoError || !doc.isObject()) { + qWarning() << "AmmUiBackend: invalid wallet config" << cfg + << parseError.errorString(); + return false; + } + obj = doc.object(); + } + obj.insert(QStringLiteral("sequencer_addr"), validated); + const QByteArray newConfigBytes = QJsonDocument(obj).toJson(QJsonDocument::Indented); + + if (!writeFileAtomically(cfg, newConfigBytes)) { + qWarning() << "AmmUiBackend: cannot atomically 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. + // Re-open from the final config path so later wallet saves keep using it. 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; + qWarning() << "AmmUiBackend: final reopen after sequencer change failed, code" << err; + if (!restoreFile(cfg, oldConfigBytes, oldConfigExists)) { + qWarning() << "AmmUiBackend: rollback after sequencer change failed"; + return false; + } + const int restoreErr = m_logos->logos_execution_zone.open(cfg, stg); + if (restoreErr != WALLET_FFI_SUCCESS) { + qWarning() << "AmmUiBackend: rollback reopen after sequencer change failed, code" + << restoreErr; + } return false; } refreshSequencerAddr(); @@ -373,9 +2037,3 @@ bool AmmUiBackend::changeSequencerAddr(QString url) } return true; } - -void AmmUiBackend::copyToClipboard(QString text) -{ - if (QGuiApplication::clipboard()) - QGuiApplication::clipboard()->setText(text); -} diff --git a/apps/amm/src/AmmUiBackend.h b/apps/amm/src/AmmUiBackend.h index bd6feac..d3a54c7 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -2,7 +2,11 @@ #define AMM_UI_BACKEND_H #include +#include +#include #include +#include +#include #include "rep_AmmUiBackend_source.h" @@ -34,35 +38,85 @@ public slots: void refreshAccounts() override; void refreshBalances() override; QString getBalance(QString accountIdHex, bool isPublic) override; - bool createNewDefault(QString password) override; - bool createNew(QString configPath, QString storagePath, QString password) override; + QString submitSwap(QVariantMap snapshot) override; + QString submitLiquidity(QVariantMap snapshot) override; + 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; 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). + // Canonical LEZ wallet home shared with the wallet UI and other apps. static QString defaultWalletHome(); QString defaultConfigPath() const; QString defaultStoragePath() const; void persistConfigPath(const QString& path); void persistStoragePath(const QString& path); + QJsonArray listAccounts(); void openOrAdoptWallet(); + bool adoptOpenWallet(); void refreshBlockHeights(); void refreshSequencerAddr(); + void loadDeploymentConfig(); + void selectDeploymentForNetwork(const QString& network); + void selectDeploymentForChain(const QString& network, + const QString& blockHash, + const QString& blockSignature); + void clearDeploymentSelection(const QString& network); + void setDeploymentIdentityPendingIfNeeded(bool pending); + void verifyDeploymentTransactions(); + void refreshDeploymentWalletState(); + void updateDeploymentNetworkMatched(); + QJsonObject configuredTokenDefinition(const QString& symbol, int fallbackIndex) const; + QString accountIdHex(const QString& accountId) const; + QStringList accountIdHexList(const QStringList& accountIds, QString* error) const; + struct PoolChainState { + double reserveA = 0; + double reserveB = 0; + double totalLpSupply = 0; + double feeBps = 0; + bool found = false; + }; + PoolChainState poolChainState() const; + struct WalletFungibleHolding { + QString accountIdHex; + double balance = 0; + bool found = false; + bool ambiguous = false; + }; + WalletFungibleHolding walletFungibleHolding(const QString& definitionAccountId, + const QString& accountIdFilterHex = {}) const; + QString selectedWalletAccountIdHex(const QVariantMap& snapshot, QString* error) const; + QString submitAmmTransaction(const QStringList& accountIds, + const QVariantList& signingRequirements, + const QVariantList& instruction); void saveWallet(); // Probe the configured sequencer over HTTP and update sequencerReachable. void checkReachability(); + void probeChainIdentity(const QString& network); AccountModel* m_accountModel; LogosAPI* m_logosAPI; LogosModules* m_logos; + QJsonArray m_tokenChains; + QJsonArray m_ammChains; + QJsonArray m_programChainGroups; + QString m_activeDeploymentNetwork; + bool m_activeDeploymentConfigured = false; + bool m_activeDeploymentDeployed = false; + bool m_identityProbeInFlight = false; + QStringList m_requiredDeploymentTransactions; + int m_pendingDeploymentChecks = 0; + quint64 m_deploymentCheckGeneration = 0; + quint64 m_reachabilityProbeGeneration = 0; + quint64 m_chainIdentityProbeGeneration = 0; + bool m_deploymentChecksFailed = false; + QJsonArray m_tokenDefinitions; + QJsonObject m_poolConfig; QNetworkAccessManager* m_net; QTimer* m_reachabilityTimer; diff --git a/apps/amm/src/AmmUiBackend.rep b/apps/amm/src/AmmUiBackend.rep index ef3297a..b3d6b5a 100644 --- a/apps/amm/src/AmmUiBackend.rep +++ b/apps/amm/src/AmmUiBackend.rep @@ -16,6 +16,16 @@ class AmmUiBackend // Defaults true so the UI doesn't flash a warning before the first check. PROP(bool sequencerReachable READONLY) + // Chain-backed deployment state for the configured AMM pool. + PROP(QVariantList deploymentTokens READONLY) + PROP(QVariantMap deploymentPool READONLY) + PROP(bool deploymentNetworkMatched READONLY) + PROP(bool deploymentIdentityPending READONLY) + + // AMM transactions. Returns wallet JSON: { success, tx_hash, error }. + SLOT(QString submitSwap(QVariantMap snapshot)) + SLOT(QString submitLiquidity(QVariantMap snapshot)) + // Account management SLOT(QString createAccountPublic()) SLOT(QString createAccountPrivate()) @@ -24,10 +34,12 @@ class AmmUiBackend SLOT(QString getBalance(QString accountIdHex, bool isPublic)) // Wallet lifecycle. createNewDefault() is the happy path: it creates a - // fresh per-app wallet at walletHome with no path picking. createNew() - // keeps explicit paths for an "advanced" flow. - SLOT(bool createNewDefault(QString password)) - SLOT(bool createNew(QString configPath, QString storagePath, QString password)) + // fresh wallet at the canonical LEZ wallet home with no path picking. + // createNew() exists for the QtRO contract but only accepts canonical + // config/storage filenames. Both return the new BIP39 mnemonic, or an + // empty string on failure. + SLOT(QString createNewDefault(QString password)) + SLOT(QString createNew(QString configPath, QString storagePath, QString password)) // Re-open the existing on-disk wallet after a disconnect. SLOT(bool openExisting()) @@ -38,7 +50,4 @@ class AmmUiBackend // 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)) }