diff --git a/apps/amm/README.md b/apps/amm/README.md index 4a457a7..19f6683 100644 --- a/apps/amm/README.md +++ b/apps/amm/README.md @@ -157,6 +157,33 @@ Swap view stays disabled (no pool can be resolved). > and *every* derived PDA. After any redeploy, point `AMM_PROGRAM_BIN` at the new > `amm.bin` — never mix a stale binary with a fresh deployment. +### Token list config (required for the Swap token picker) + +The Swap view's token picker is config-driven: it doesn't derive tokens from +chain state, it reads a flat JSON list from the `TOKENS_CONFIG` environment +variable (absolute path). Each entry needs, at minimum, the token's +`definitionId` and **your own** `holding` account address for that token (the +account the wallet will sign transfers from/to for that token): + +```json +[ + { + "symbol": "TKA", + "name": "Token A", + "definitionId": "9qbX…", + "holding": "4T69…", + "decimals": 18 + } +] +``` + +If `TOKENS_CONFIG` is unset, unreadable, or not a valid JSON array, the token +picker stays empty (a `qWarning` is logged; no swap can be started). + +```bash +AMM_PROGRAM_BIN=/abs/path/to/amm.bin TOKENS_CONFIG=/abs/path/to/tokens.json nix run .#amm-ui +``` + ## Updating Dependencies To update the pinned versions of dependencies in `flake.lock`: diff --git a/apps/amm/qml/components/swap/ManualField.qml b/apps/amm/qml/components/swap/ManualField.qml deleted file mode 100644 index 97cc207..0000000 --- a/apps/amm/qml/components/swap/ManualField.qml +++ /dev/null @@ -1,72 +0,0 @@ -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Layouts 1.15 - -// A single labeled text field, styled to match the rest of the swap UI's -// theme. Used by ManualSwapPanel for the manual token id / holding address / -// amount inputs. -ColumnLayout { - id: root - - property var theme - property string label: "" - property string placeholder: "" - property string text: "" - // When true, non-digit characters are stripped as the user types (used - // for the base-units amount field). - property bool numericOnly: false - - signal edited(string newValue) - - spacing: 4 - - Text { - text: root.label - color: theme.colors.textSecondary - font.pixelSize: 12 - } - - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 40 - radius: 10 - color: theme.colors.inputBg - border.color: field.activeFocus ? theme.colors.ctaBg : theme.colors.border - border.width: 1 - Behavior on border.color { ColorAnimation { duration: 120 } } - - TextField { - id: field - anchors.fill: parent - anchors.leftMargin: 10 - anchors.rightMargin: 10 - verticalAlignment: TextInput.AlignVCenter - color: theme.colors.textPrimary - placeholderTextColor: theme.colors.textPlaceholder - placeholderText: root.placeholder - font.pixelSize: 13 - selectByMouse: true - background: Item {} - - Binding { - target: field - property: "text" - value: root.text - when: !field.activeFocus - } - - onTextEdited: { - var v = text; - if (root.numericOnly) { - var stripped = v.replace(/[^0-9]/g, ""); - if (stripped !== v) { - field.text = stripped; - return; // onTextEdited fires again for the corrected text - } - v = stripped; - } - root.edited(v); - } - } - } -} diff --git a/apps/amm/qml/components/swap/ManualSwapPanel.qml b/apps/amm/qml/components/swap/ManualSwapPanel.qml deleted file mode 100644 index 2688839..0000000 --- a/apps/amm/qml/components/swap/ManualSwapPanel.qml +++ /dev/null @@ -1,415 +0,0 @@ -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Layouts 1.15 -import "../shared" -import "../../state" - -// Minimal, functional "manual" swap flow: the app can't yet enumerate token -// holdings with their definition ids, so the user pastes the real ids/holding -// addresses directly instead of picking from the (dummy) token list. Wires -// straight to the real backend slots (resolvePool / swapExactInput) — see -// apps/amm/src/AmmUiBackend.rep for the exact contract. -Rectangle { - id: root - - property var theme - property var backend: null - - property bool expanded: true - - property string defAHex: "" - property string defBHex: "" - property string holdingAHex: "" - property string holdingBHex: "" - property string amountInText: "" - // "aToB": selling A for B. "bToA": selling B for A. - property string direction: "aToB" - property real slippageTolerancePercent: 0.5 - - property bool poolLoading: false - property bool poolResolved: false - property bool poolExists: false - property string poolReserveA: "0" - property string poolReserveB: "0" - property int poolFeeBps: 30 - property string poolError: "" - - property bool swapInProgress: false - property string swapError: "" - - signal swapSucceeded(var snapshot) - signal swapFailed(string message) - - DummySwapState { - id: swapState - feeBps: root.poolFeeBps - } - - Timer { - id: resolveDebounce - interval: 500 - repeat: false - onTriggered: root.doResolvePool() - } - - function requestResolve() { - root.poolResolved = false; - root.poolExists = false; - root.poolError = ""; - if (root.defAHex.length > 0 && root.defBHex.length > 0) - resolveDebounce.restart(); - else - resolveDebounce.stop(); - } - - onDefAHexChanged: root.requestResolve() - onDefBHexChanged: root.requestResolve() - - function doResolvePool() { - if (!root.backend) { - root.poolError = qsTr("Wallet backend not ready."); - return; - } - if (root.defAHex.length === 0 || root.defBHex.length === 0) - return; - - root.poolLoading = true; - logos.watch(root.backend.resolvePool(root.defAHex, root.defBHex), - function (pool) { - root.poolLoading = false; - root.poolResolved = true; - root.poolExists = !!(pool && pool.exists); - root.poolReserveA = (pool && pool.reserveA) || "0"; - root.poolReserveB = (pool && pool.reserveB) || "0"; - root.poolFeeBps = (pool && pool.feeBps) || 30; - root.poolError = ""; - }, - function (error) { - console.warn("resolvePool error:", error); - root.poolLoading = false; - root.poolResolved = true; - root.poolExists = false; - root.poolError = qsTr("Failed to resolve pool: %1").arg(error); - }); - } - - // JS doubles lose precision far below u128 range; this is only used to - // format an *estimate* (expected output / min received) as a plain - // decimal string for the backend call, never for the input amount itself - // (which is passed through from user text verbatim). - function formatDecimalInteger(value) { - if (!isFinite(value) || isNaN(value) || value <= 0) - return "0"; - - var rounded = Math.floor(value); - var s = rounded.toString(); - if (s.indexOf("e") === -1 && s.indexOf("E") === -1) - return s; - - var match = s.match(/^(\d)(?:\.(\d+))?e\+(\d+)$/i); - if (!match) - return s; - - var digits = match[1] + (match[2] || ""); - var exponent = parseInt(match[3], 10); - return digits + "0".repeat(Math.max(0, exponent - (match[2] ? match[2].length : 0))); - } - - readonly property real reserveInNum: Number(root.direction === "aToB" ? root.poolReserveA : root.poolReserveB) || 0 - readonly property real reserveOutNum: Number(root.direction === "aToB" ? root.poolReserveB : root.poolReserveA) || 0 - readonly property real amountInNum: { - var v = Number(root.amountInText); - return isNaN(v) || v < 0 ? 0 : v; - } - - readonly property real expectedOutNum: swapState.amountOutFor(root.amountInNum, root.reserveInNum, root.reserveOutNum) - readonly property real minReceivedNum: swapState.minReceived(root.expectedOutNum, root.slippageTolerancePercent) - readonly property real feeAmountNum: swapState.feeAmount(root.amountInNum) - readonly property real priceImpactPercentValue: swapState.priceImpactPercent(root.amountInNum, root.expectedOutNum, root.reserveInNum, root.reserveOutNum) - - readonly property string tokenDefInHex: root.direction === "aToB" ? root.defAHex : root.defBHex - readonly property string tokenDefOutHex: root.direction === "aToB" ? root.defBHex : root.defAHex - - readonly property bool fieldsFilled: root.defAHex.length > 0 && root.defBHex.length > 0 - && root.holdingAHex.length > 0 && root.holdingBHex.length > 0 - && root.amountInNum > 0 - - readonly property bool canPreview: root.fieldsFilled && root.poolResolved && root.poolExists - readonly property bool canSwap: root.canPreview && root.expectedOutNum > 0 && !root.swapInProgress - - readonly property string statusText: { - if (!root.backend) return qsTr("Wallet backend not ready."); - if (root.defAHex.length === 0 || root.defBHex.length === 0) return qsTr("Enter both token definition ids."); - if (root.poolLoading) return qsTr("Looking up pool…"); - if (root.poolError.length > 0) return root.poolError; - if (root.poolResolved && !root.poolExists) return qsTr("No pool / no liquidity for this pair."); - return ""; - } - - readonly property string submitButtonText: { - if (root.swapInProgress) return qsTr("Submitting…"); - if (!root.fieldsFilled) return qsTr("Fill in all fields"); - if (!root.poolResolved) return qsTr("Resolving pool…"); - if (!root.poolExists) return qsTr("No pool / no liquidity"); - if (root.expectedOutNum <= 0) return qsTr("Amount too small"); - return qsTr("Swap"); - } - - function resetAmount() { - root.amountInText = ""; - } - - function doSwap() { - if (!root.backend || !root.canSwap) - return; - - root.swapInProgress = true; - root.swapError = ""; - - var minOutStr = root.formatDecimalInteger(root.minReceivedNum); - // Max u64 sentinel: "ignore deadline", per AmmUiBackend.rep. - var deadline = "18446744073709551615"; - - logos.watch(root.backend.swapExactInput(root.defAHex, root.defBHex, - root.holdingAHex, root.holdingBHex, - root.amountInText, minOutStr, - root.tokenDefInHex, deadline), - function (txHash) { - root.swapInProgress = false; - if (txHash && txHash.length > 0) { - root.swapSucceeded({ - "txHash": txHash, - "amountIn": root.amountInText, - "sellDefHex": root.tokenDefInHex, - "buyDefHex": root.tokenDefOutHex, - "minReceived": minOutStr - }); - root.resetAmount(); - resolveDebounce.restart(); - } else { - root.swapError = qsTr("Swap failed (empty response from sequencer)."); - root.swapFailed(root.swapError); - } - }, - function (error) { - console.warn("swapExactInput error:", error); - root.swapInProgress = false; - root.swapError = qsTr("Swap error: %1").arg(error); - root.swapFailed(root.swapError); - }); - } - - radius: 24 - color: theme.colors.cardBg - border.color: theme.colors.border - border.width: 1 - implicitWidth: 480 - implicitHeight: panelLayout.implicitHeight + 32 - - Behavior on color { ColorAnimation { duration: 300 } } - - ColumnLayout { - id: panelLayout - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.margins: 16 - spacing: 12 - - RowLayout { - Layout.fillWidth: true - spacing: 8 - - Text { - Layout.fillWidth: true - text: qsTr("Manual swap (advanced)") - color: theme.colors.textPrimary - font.pixelSize: 16 - font.weight: Font.Medium - } - - Text { - text: root.expanded ? "▴" : "▾" - color: theme.colors.textSecondary - font.pixelSize: 14 - } - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: root.expanded = !root.expanded - } - } - - Text { - Layout.fillWidth: true - visible: root.expanded - text: qsTr("Paste the real token definition ids and your own holding addresses for this pair. Executes a real on-chain swap.") - color: theme.colors.textSecondary - font.pixelSize: 12 - wrapMode: Text.WordWrap - } - - ColumnLayout { - Layout.fillWidth: true - visible: root.expanded - spacing: 10 - - // ── Direction toggle ────────────────────────────────────────── - RowLayout { - Layout.fillWidth: true - spacing: 8 - - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 36 - radius: 12 - color: root.direction === "aToB" ? theme.colors.ctaBg : theme.colors.panelBg - Text { - anchors.centerIn: parent - text: qsTr("Sell A → Buy B") - color: root.direction === "aToB" ? "#ffffff" : theme.colors.textSecondary - font.pixelSize: 12 - font.weight: Font.Medium - } - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: root.direction = "aToB" - } - } - - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 36 - radius: 12 - color: root.direction === "bToA" ? theme.colors.ctaBg : theme.colors.panelBg - Text { - anchors.centerIn: parent - text: qsTr("Sell B → Buy A") - color: root.direction === "bToA" ? "#ffffff" : theme.colors.textSecondary - font.pixelSize: 12 - font.weight: Font.Medium - } - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: root.direction = "bToA" - } - } - } - - // ── Manual fields ────────────────────────────────────────────── - ManualField { - Layout.fillWidth: true - theme: root.theme - label: qsTr("Token A definition id") - placeholder: qsTr("hex or base58") - text: root.defAHex - onEdited: function (v) { root.defAHex = v; } - } - - ManualField { - Layout.fillWidth: true - theme: root.theme - label: qsTr("Token B definition id") - placeholder: qsTr("hex or base58") - text: root.defBHex - onEdited: function (v) { root.defBHex = v; } - } - - ManualField { - Layout.fillWidth: true - theme: root.theme - label: qsTr("Your Token A holding address") - placeholder: qsTr("hex or base58") - text: root.holdingAHex - onEdited: function (v) { root.holdingAHex = v; } - } - - ManualField { - Layout.fillWidth: true - theme: root.theme - label: qsTr("Your Token B holding address") - placeholder: qsTr("hex or base58") - text: root.holdingBHex - onEdited: function (v) { root.holdingBHex = v; } - } - - ManualField { - Layout.fillWidth: true - theme: root.theme - label: qsTr("Amount in (base units)") - placeholder: qsTr("0") - text: root.amountInText - numericOnly: true - onEdited: function (v) { root.amountInText = v; } - } - - SlippageToleranceControl { - Layout.fillWidth: true - tolerancePercent: root.slippageTolerancePercent - onToleranceChangeRequested: function (tolerancePercent) { - root.slippageTolerancePercent = swapState.clampSlippagePercent(tolerancePercent); - } - } - - Text { - Layout.fillWidth: true - visible: root.statusText.length > 0 - text: root.statusText - color: (root.poolError.length > 0 || (root.poolResolved && !root.poolExists)) ? "#F08A76" : theme.colors.textSecondary - font.pixelSize: 12 - wrapMode: Text.WordWrap - } - - SwapSummary { - Layout.fillWidth: true - visible: root.canPreview && root.amountInNum > 0 - theme: root.theme - feeText: swapState.formatTokenAmount(root.feeAmountNum, "") - priceImpactText: swapState.formatPercent(root.priceImpactPercentValue) - priceImpactPercent: root.priceImpactPercentValue - slippageText: swapState.formatSlippagePercent(root.slippageTolerancePercent) - minReceivedText: swapState.formatTokenAmount(root.minReceivedNum, "") + qsTr(" (base units, est.)") - } - - Text { - Layout.fillWidth: true - visible: root.swapError.length > 0 - text: root.swapError - color: "#F08A76" - font.pixelSize: 12 - wrapMode: Text.WordWrap - } - - Rectangle { - id: ctaBox - Layout.fillWidth: true - Layout.preferredHeight: 48 - radius: 16 - color: !root.canSwap ? theme.colors.panelBg - : ctaHover.containsMouse ? theme.colors.ctaHoverBg - : theme.colors.ctaBg - Behavior on color { ColorAnimation { duration: 120 } } - - Text { - anchors.centerIn: parent - text: root.submitButtonText - color: root.canSwap ? "#ffffff" : theme.colors.textSecondary - font.pixelSize: 15 - font.weight: Font.Medium - } - - MouseArea { - id: ctaHover - anchors.fill: parent - hoverEnabled: true - enabled: root.canSwap - cursorShape: root.canSwap ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: root.doSwap() - } - } - } - } -} diff --git a/apps/amm/qml/components/swap/SwapCard.qml b/apps/amm/qml/components/swap/SwapCard.qml index 5a3f601..5e19632 100644 --- a/apps/amm/qml/components/swap/SwapCard.qml +++ b/apps/amm/qml/components/swap/SwapCard.qml @@ -4,11 +4,18 @@ import QtQuick.Layouts 1.15 import "../shared" import "../../state" +// The real swap UI: two token inputs (sell/buy), a token picker (backed by +// AmmUiBackend::tokenList()/TOKENS_CONFIG via SwapPage), and a submit flow +// wired straight to the backend's resolvePool()/swapExactInput() slots — see +// apps/amm/src/AmmUiBackend.rep for the exact contract. Rectangle { id: root property var theme property var tokens: [] + // Real backend replica (logos.module("amm_ui")), wired from SwapPage. + property var backend: null + property var sellToken: null property var buyToken: null property string sellInput: "" @@ -16,13 +23,33 @@ Rectangle { property string editingSide: "sell" property real slippageTolerancePercent: 0.5 - DummySwapState { - id: swapState - feeBps: 30 - } + // ── Pool resolution (backend.resolvePool) ─────────────────────────────── + // sellToken is always passed as defA and buyToken as defB, so reserveA + // always corresponds to sellToken and reserveB to buyToken — no separate + // "direction" bookkeeping is needed (unlike a generic A/B picker): the + // sell/buy assignment *is* the direction. + property bool poolLoading: false + property bool poolResolved: false + property bool poolExists: false + property string poolReserveA: "0" + property string poolReserveB: "0" + property int poolFeeBps: 30 + property string poolError: "" + + // ── Swap submission (backend.swapExactInput) ──────────────────────────── + property bool swapInProgress: false + property string swapError: "" signal requestTokenSelect(string side) signal submitRequested(var snapshot) + // Emitted after a real swapExactInput call completes. + signal swapSucceeded(var result) + signal swapFailed(string message) + + DummySwapState { + id: swapState + feeBps: root.poolFeeBps + } function setToken(side, token) { if (side === "sell") root.sellToken = token @@ -35,8 +62,57 @@ Rectangle { root.editingSide = "sell" } - readonly property real sellReserve: sellToken ? (sellToken.reserve || 0) : 0 - readonly property real buyReserve: buyToken ? (buyToken.reserve || 0) : 0 + // ── Pool resolution ────────────────────────────────────────────────────── + Timer { + id: resolveDebounce + interval: 500 + repeat: false + onTriggered: root.doResolvePool() + } + + function requestResolve() { + root.poolResolved = false + root.poolExists = false + root.poolError = "" + if (root.sellToken && root.buyToken) + resolveDebounce.restart() + else + resolveDebounce.stop() + } + + onSellTokenChanged: root.requestResolve() + onBuyTokenChanged: root.requestResolve() + + function doResolvePool() { + if (!root.backend || !root.sellToken || !root.buyToken) + return + + root.poolLoading = true + logos.watch(root.backend.resolvePool(root.sellToken.definitionId, root.buyToken.definitionId), + function (pool) { + root.poolLoading = false + root.poolResolved = true + root.poolExists = !!(pool && pool.exists) + root.poolReserveA = (pool && pool.reserveA) || "0" + root.poolReserveB = (pool && pool.reserveB) || "0" + root.poolFeeBps = (pool && pool.feeBps) || 30 + root.poolError = "" + }, + function (error) { + console.warn("resolvePool error:", error) + root.poolLoading = false + root.poolResolved = true + root.poolExists = false + root.poolError = qsTr("Failed to resolve pool: %1").arg(error) + }) + } + + // JS doubles lose precision far below u128 range; these are only used to + // drive the *estimate* (expected output / min received / price impact), + // never the actual swap amount — the sell amount sent to the backend is + // the user's raw input text, passed through verbatim. + readonly property real sellReserveNum: Number(root.poolReserveA) || 0 + readonly property real buyReserveNum: Number(root.poolReserveB) || 0 readonly property real parsedSellInput: { var amt = parseFloat(sellInput) @@ -50,74 +126,144 @@ Rectangle { readonly property real parsedSellAmount: editingSide === "sell" ? parsedSellInput - : swapState.amountInFor(parsedBuyInput, sellReserve, buyReserve) + : swapState.amountInFor(parsedBuyInput, sellReserveNum, buyReserveNum) readonly property real parsedBuyAmount: editingSide === "buy" ? parsedBuyInput - : swapState.amountOutFor(parsedSellInput, sellReserve, buyReserve) + : swapState.amountOutFor(parsedSellInput, sellReserveNum, buyReserveNum) readonly property real feeAmount: swapState.feeAmount(parsedSellAmount) readonly property real minReceivedAmount: swapState.minReceived(parsedBuyAmount, slippageTolerancePercent) - readonly property real priceImpactPercent: swapState.priceImpactPercent(parsedSellAmount, parsedBuyAmount, sellReserve, buyReserve) + readonly property real priceImpactPercent: swapState.priceImpactPercent(parsedSellAmount, parsedBuyAmount, sellReserveNum, buyReserveNum) - readonly property string swapMode: editingSide === "buy" ? "swap-exact-output" : "swap-exact-input" - readonly property string swapModeText: editingSide === "buy" ? qsTr("Exact output") : qsTr("Exact input") + readonly property string swapModeText: editingSide === "buy" ? qsTr("Exact output (preview only)") : qsTr("Exact input") readonly property bool hasAmount: editingSide === "sell" ? parsedSellInput > 0 : parsedBuyInput > 0 readonly property bool tokensSelected: sellToken !== null && buyToken !== null - readonly property bool insufficientBalance: hasAmount && sellToken !== null && parsedSellAmount > (sellToken.balance || 0) - readonly property bool insufficientLiquidity: hasAmount && buyToken !== null && parsedBuyAmount > (buyToken.reserve || 0) - readonly property bool canSubmit: tokensSelected && hasAmount && parsedSellAmount > 0 && parsedBuyAmount > 0 && !insufficientBalance && !insufficientLiquidity + readonly property bool insufficientLiquidity: hasAmount && root.poolExists && parsedBuyAmount > buyReserveNum + // The backend only exposes swapExactInput, so only the "I know exactly + // how much I'm selling" direction can actually be submitted. Editing the + // buy field still previews an estimate (via amountInFor above) but can't + // be submitted — see doc comment on AmmUiBackend::swapExactInput. + readonly property bool canSubmit: tokensSelected && editingSide === "sell" && hasAmount + && parsedSellAmount > 0 && parsedBuyAmount > 0 + && root.poolResolved && root.poolExists + && !insufficientLiquidity && !root.swapInProgress readonly property string submitButtonText: { - if (!hasAmount || !tokensSelected) return qsTr("Enter an amount") - if (insufficientBalance) return qsTr("Insufficient balance") + if (!tokensSelected) return qsTr("Select tokens") + if (root.swapInProgress) return qsTr("Submitting…") + if (!hasAmount) return qsTr("Enter an amount") + if (editingSide === "buy") return qsTr("Enter a sell amount to swap") + if (root.poolLoading || !root.poolResolved) return qsTr("Resolving pool…") + if (!root.poolExists) return qsTr("No pool / no liquidity") if (insufficientLiquidity) return qsTr("Insufficient liquidity") + if (parsedBuyAmount <= 0) return qsTr("Amount too small") return qsTr("Swap") } + readonly property string poolStatusText: { + if (!root.backend) return qsTr("Wallet backend not ready.") + if (!tokensSelected) return "" + if (root.poolLoading) return qsTr("Looking up pool…") + if (root.poolError.length > 0) return root.poolError + if (root.poolResolved && !root.poolExists) return qsTr("No pool / no liquidity for this pair.") + return "" + } + function formatAmountValue(val) { if (val >= 1) return val.toFixed(2) if (val >= 0.0001) return val.toFixed(6) return val.toFixed(8) } + // Base units are integers; render the (estimate-only) computed side as a + // plain integer string rather than a fractional/scientific one. + function formatBaseUnits(value) { + if (!isFinite(value) || isNaN(value) || value <= 0) + return "0" + + var rounded = Math.floor(value) + var s = rounded.toString() + if (s.indexOf("e") === -1 && s.indexOf("E") === -1) + return s + + var match = s.match(/^(\d)(?:\.(\d+))?e\+(\d+)$/i) + if (!match) + return s + + var digits = match[1] + (match[2] || "") + var exponent = parseInt(match[3], 10) + return digits + "0".repeat(Math.max(0, exponent - (match[2] ? match[2].length : 0))) + } + readonly property string sellDisplay: editingSide === "sell" ? sellInput - : (parsedSellAmount > 0 ? formatAmountValue(parsedSellAmount) : "") + : (parsedSellAmount > 0 ? formatBaseUnits(parsedSellAmount) : "") readonly property string buyDisplay: editingSide === "buy" ? buyInput - : (parsedBuyAmount > 0 ? formatAmountValue(parsedBuyAmount) : "") - - readonly property string sellUsd: { - if (!sellToken || parsedSellAmount <= 0) return "" - var val = parsedSellAmount * sellToken.usdPrice - return "~$" + val.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",") - } - - readonly property string buyUsd: { - if (!buyToken || parsedBuyAmount <= 0) return "" - var val = parsedBuyAmount * buyToken.usdPrice - return "~$" + val.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",") - } + : (parsedBuyAmount > 0 ? formatBaseUnits(parsedBuyAmount) : "") function buildSnapshot() { return { "sellToken": sellToken ? sellToken.symbol : "", "buyToken": buyToken ? buyToken.symbol : "", - "sellAmount": formatAmountValue(parsedSellAmount), - "buyAmount": formatAmountValue(parsedBuyAmount), - "minReceived": formatAmountValue(minReceivedAmount), + "sellAmount": formatBaseUnits(parsedSellAmount), + "buyAmount": formatBaseUnits(parsedBuyAmount), + "minReceived": formatBaseUnits(minReceivedAmount), "feeAmount": swapState.formatTokenAmount(feeAmount, sellToken ? sellToken.symbol : ""), "priceImpactPercent": swapState.formatPercent(priceImpactPercent), "priceImpactPercentValue": priceImpactPercent, "slippageTolerance": swapState.formatSlippagePercent(slippageTolerancePercent), - "swapMode": swapMode, + "swapMode": "swap-exact-input", "swapModeText": swapModeText } } + // Called by SwapPage once the user confirms in SwapConfirmationDialog. + // Submits the real on-chain swap for the amounts/tokens selected when the + // CTA was pressed (canSubmit already guarantees editingSide === "sell"). + function executeSwap() { + if (!root.backend || !root.canSubmit) + return + + root.swapInProgress = true + root.swapError = "" + + var minOutStr = root.formatBaseUnits(root.minReceivedAmount) + // Max u64 sentinel: "ignore deadline", per AmmUiBackend.rep. + var deadline = "18446744073709551615" + + logos.watch(root.backend.swapExactInput( + root.sellToken.definitionId, root.buyToken.definitionId, + root.sellToken.holding, root.buyToken.holding, + root.sellInput, minOutStr, deadline), + function (txHash) { + root.swapInProgress = false + if (txHash && txHash.length > 0) { + root.swapSucceeded({ + "txHash": txHash, + "sellToken": root.sellToken.symbol, + "buyToken": root.buyToken.symbol, + "sellAmount": root.sellInput, + "minReceived": minOutStr + }) + root.resetAmounts() + resolveDebounce.restart() + } else { + root.swapError = qsTr("Swap failed (empty response from sequencer).") + root.swapFailed(root.swapError) + } + }, + function (error) { + console.warn("swapExactInput error:", error) + root.swapInProgress = false + root.swapError = qsTr("Swap error: %1").arg(error) + root.swapFailed(root.swapError) + }) + } + radius: 24 color: theme.colors.cardBg border.color: theme.colors.border @@ -140,7 +286,6 @@ Rectangle { theme: root.theme label: "Sell" amount: root.sellDisplay - usdValue: root.sellUsd token: root.sellToken active: root.editingSide === "sell" onInputEdited: function(v) { @@ -196,7 +341,6 @@ Rectangle { theme: root.theme label: "Buy" amount: root.buyDisplay - usdValue: root.buyUsd token: root.buyToken active: root.editingSide === "buy" onInputEdited: function(v) { @@ -206,6 +350,30 @@ Rectangle { onTokenClicked: root.requestTokenSelect("buy") } + Text { + Layout.fillWidth: true + Layout.topMargin: 8 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + visible: root.poolStatusText.length > 0 + text: root.poolStatusText + color: (root.poolError.length > 0 || (root.poolResolved && !root.poolExists)) + ? "#F08A76" : theme.colors.textSecondary + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + + Text { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + visible: root.swapError.length > 0 + text: root.swapError + color: "#F08A76" + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + SwapSummary { Layout.fillWidth: true Layout.topMargin: 12 diff --git a/apps/amm/qml/components/swap/TokenInput.qml b/apps/amm/qml/components/swap/TokenInput.qml index 07b2362..1e159d7 100644 --- a/apps/amm/qml/components/swap/TokenInput.qml +++ b/apps/amm/qml/components/swap/TokenInput.qml @@ -1,5 +1,6 @@ import QtQuick 2.15 import QtQuick.Layouts 1.15 +import "TokenVisuals.js" as TokenVisuals Rectangle { id: root @@ -94,11 +95,11 @@ Rectangle { Rectangle { width: 24; height: 24; radius: 12 - color: root.token ? root.token.color : theme.colors.noTokenCircle + color: root.token ? TokenVisuals.colorFor(root.token.symbol) : theme.colors.noTokenCircle visible: root.token !== null Text { anchors.centerIn: parent - text: root.token ? root.token.letter : "" + text: root.token ? TokenVisuals.letterFor(root.token.symbol) : "" color: "#ffffff" font.pixelSize: 10 font.weight: Font.Bold diff --git a/apps/amm/qml/components/swap/TokenListItem.qml b/apps/amm/qml/components/swap/TokenListItem.qml index 697341a..fba33ae 100644 --- a/apps/amm/qml/components/swap/TokenListItem.qml +++ b/apps/amm/qml/components/swap/TokenListItem.qml @@ -1,5 +1,6 @@ import QtQuick 2.15 import QtQuick.Layouts 1.15 +import "TokenVisuals.js" as TokenVisuals Item { id: root @@ -7,9 +8,7 @@ Item { property var theme property string tokenName: "" property string tokenSymbol: "" - property string tokenAddress: "" - property string tokenColor: "#627eea" - property string tokenLetter: "" + property string tokenDefinitionId: "" signal clicked() @@ -29,10 +28,10 @@ Item { Rectangle { width: 36; height: 36; radius: 18 - color: root.tokenColor + color: TokenVisuals.colorFor(root.tokenSymbol) Text { anchors.centerIn: parent - text: root.tokenLetter + text: TokenVisuals.letterFor(root.tokenSymbol) color: "#ffffff" font.pixelSize: 14 font.weight: Font.Bold @@ -55,8 +54,8 @@ Item { spacing: 6 Text { text: root.tokenSymbol; color: theme.colors.textSecondary; font.pixelSize: 12 } Text { - text: root.tokenAddress !== "" - ? root.tokenAddress.substring(0, 6) + "..." + root.tokenAddress.slice(-4) + text: root.tokenDefinitionId !== "" + ? root.tokenDefinitionId.substring(0, 6) + "..." + root.tokenDefinitionId.slice(-4) : "" color: theme.colors.textPlaceholder font.pixelSize: 12 diff --git a/apps/amm/qml/components/swap/TokenSelectorModal.qml b/apps/amm/qml/components/swap/TokenSelectorModal.qml index 99f80a0..bb487d8 100644 --- a/apps/amm/qml/components/swap/TokenSelectorModal.qml +++ b/apps/amm/qml/components/swap/TokenSelectorModal.qml @@ -1,6 +1,7 @@ import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 +import "TokenVisuals.js" as TokenVisuals Item { id: root @@ -127,8 +128,8 @@ Item { spacing: 6 Rectangle { width: 22; height: 22; radius: 11 - color: modelData.color - Text { anchors.centerIn: parent; text: modelData.letter; color: "#ffffff"; font.pixelSize: 10; font.weight: Font.Bold } + color: TokenVisuals.colorFor(modelData.symbol) + Text { anchors.centerIn: parent; text: TokenVisuals.letterFor(modelData.symbol); color: "#ffffff"; font.pixelSize: 10; font.weight: Font.Bold } } Text { text: modelData.symbol; color: theme.colors.textPrimary; font.pixelSize: 13; font.weight: Font.Medium } } @@ -162,9 +163,7 @@ Item { theme: root.theme tokenName: modelData.name tokenSymbol: modelData.symbol - tokenAddress: modelData.address - tokenColor: modelData.color - tokenLetter: modelData.letter + tokenDefinitionId: modelData.definitionId onClicked: root.tokenSelected(modelData) } } diff --git a/apps/amm/qml/components/swap/TokenVisuals.js b/apps/amm/qml/components/swap/TokenVisuals.js new file mode 100644 index 0000000..43e363c --- /dev/null +++ b/apps/amm/qml/components/swap/TokenVisuals.js @@ -0,0 +1,27 @@ +.pragma library + +// Shared derivation helpers for a token's display avatar (color + letter). +// The real token config (see AmmUiBackend::tokenList / TOKENS_CONFIG) only +// carries symbol/name/definitionId/holding/decimals — no color/letter — so +// every place that used to read token.color/token.letter derives them here +// instead, deterministically from the token's symbol. + +var PALETTE = [ + "#627eea", "#2775ca", "#26a17b", "#f7931a", + "#9b59b6", "#e91e63", "#00bcd4", "#8bc34a" +]; + +function letterFor(symbol) { + return (symbol && symbol.length > 0) ? symbol.charAt(0).toUpperCase() : "?"; +} + +function colorFor(symbol) { + if (!symbol || symbol.length === 0) + return PALETTE[0]; + + var hash = 0; + for (var i = 0; i < symbol.length; i++) + hash = (hash * 31 + symbol.charCodeAt(i)) >>> 0; + + return PALETTE[hash % PALETTE.length]; +} diff --git a/apps/amm/qml/pages/SwapPage.qml b/apps/amm/qml/pages/SwapPage.qml index 3cd21f5..344d47a 100644 --- a/apps/amm/qml/pages/SwapPage.qml +++ b/apps/amm/qml/pages/SwapPage.qml @@ -9,18 +9,20 @@ Item { id: root // Real backend replica (logos.module("amm_ui")), wired from Main.qml. - // Only used by ManualSwapPanel below — the dummy SwapCard above stays - // client-side/demo only until the token picker is backed by real data. property var backend: null - 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 } - ] + // Config-driven token list, loaded from AmmUiBackend::tokenList() (which + // reads the TOKENS_CONFIG JSON file — see apps/amm/README.md). Empty + // until the backend is ready and the call resolves. + property var tokens: [] + + onBackendChanged: { + if (root.backend) { + logos.watch(root.backend.tokenList(), + function(list) { root.tokens = list }, + function(err) { console.warn("tokenList error:", err) }) + } + } QtObject { id: theme @@ -100,6 +102,7 @@ Item { Layout.alignment: Qt.AlignHCenter theme: theme tokens: root.tokens + backend: root.backend width: Math.min(480, root.width - 32) onRequestTokenSelect: function(side) { @@ -110,22 +113,14 @@ Item { onSubmitRequested: function(snapshot) { swapConfirmationDialog.openWithSnapshot(snapshot) } - } - ManualSwapPanel { - id: manualSwapPanel - Layout.alignment: Qt.AlignHCenter - theme: theme - backend: root.backend - width: swapCard.width - - onSwapSucceeded: function(snapshot) { + onSwapSucceeded: function(result) { swapToast.show(qsTr("Swap submitted"), - qsTr("tx %1").arg(snapshot.txHash)) + qsTr("tx %1").arg(result.txHash)) } onSwapFailed: function(message) { - console.warn("Manual swap failed:", message) + console.warn("Swap failed:", message) } } @@ -172,13 +167,9 @@ 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)) + // The dialog only shows a preview snapshot; the actual + // on-chain swap runs here, against SwapCard's live state. + swapCard.executeSwap() } } } diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index b590349..922eb79 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -43,6 +43,11 @@ namespace { // (and therefore every PDA derived from it). See apps/amm/README.md. const char AMM_PROGRAM_BIN_ENV[] = "AMM_PROGRAM_BIN"; + // Absolute path to the JSON token-list config consumed by tokenList() + // (see apps/amm/README.md). Config-driven so the Swap view's token picker + // doesn't need a hardcoded/dummy token list. + const char TOKENS_CONFIG_ENV[] = "TOKENS_CONFIG"; + // Normalise file:// URLs and OS paths to a plain local path. QString toLocalPath(const QString& path) { if (path.startsWith("file://") || path.contains("/")) @@ -674,3 +679,47 @@ QString AmmUiBackend::swapExactInput(QString defAHex, QString defBHex, QString u refreshBalances(); return obj.value(QStringLiteral("tx_hash")).toString(); } + +QVariantList AmmUiBackend::tokenList() +{ + QVariantList out; + + const QByteArray path = qgetenv(TOKENS_CONFIG_ENV); + if (path.isEmpty()) { + qWarning() << "AmmUiBackend::tokenList: TOKENS_CONFIG not set"; + return out; + } + + QFile file(QString::fromLocal8Bit(path)); + if (!file.open(QIODevice::ReadOnly)) { + qWarning() << "AmmUiBackend::tokenList: cannot read TOKENS_CONFIG at" << file.fileName(); + return out; + } + const QByteArray json = file.readAll(); + file.close(); + + QJsonParseError parseError{}; + const QJsonDocument doc = QJsonDocument::fromJson(json, &parseError); + if (parseError.error != QJsonParseError::NoError || !doc.isArray()) { + qWarning() << "AmmUiBackend::tokenList: TOKENS_CONFIG at" << file.fileName() + << "is not a valid JSON array:" << parseError.errorString(); + return out; + } + + const QJsonArray arr = doc.array(); + for (const QJsonValue& entry : arr) { + if (!entry.isObject()) { + qWarning() << "AmmUiBackend::tokenList: skipping non-object entry in TOKENS_CONFIG"; + continue; + } + const QJsonObject obj = entry.toObject(); + QVariantMap token; + token[QStringLiteral("symbol")] = obj.value(QStringLiteral("symbol")).toString(); + token[QStringLiteral("name")] = obj.value(QStringLiteral("name")).toString(); + token[QStringLiteral("definitionId")] = obj.value(QStringLiteral("definitionId")).toString(); + token[QStringLiteral("holding")] = obj.value(QStringLiteral("holding")).toString(); + token[QStringLiteral("decimals")] = obj.value(QStringLiteral("decimals")).toInt(); + out.append(token); + } + return out; +} diff --git a/apps/amm/src/AmmUiBackend.h b/apps/amm/src/AmmUiBackend.h index d525ec6..8690937 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -3,6 +3,7 @@ #include #include +#include #include #include "rep_AmmUiBackend_source.h" @@ -53,6 +54,9 @@ public slots: QString swapExactInput(QString defAHex, QString defBHex, QString userInputHoldingHex, QString userOutputHoldingHex, QString amountInDecimal, QString minOutDecimal, QString deadlineDecimal) override; + // Reads the token list from TOKENS_CONFIG (see AmmUiBackend.cpp) so the + // Swap UI's token picker is config-driven instead of hardcoded. + QVariantList tokenList() override; private: // Per-app wallet home (kept distinct from the wallet's canonical diff --git a/apps/amm/src/AmmUiBackend.rep b/apps/amm/src/AmmUiBackend.rep index 7d3f2f5..d486463 100644 --- a/apps/amm/src/AmmUiBackend.rep +++ b/apps/amm/src/AmmUiBackend.rep @@ -57,4 +57,9 @@ class AmmUiBackend // unix timestamp. Returns the tx hash, or an empty string on failure // (no pool, unreadable AMM_PROGRAM_BIN, bad inputs, or a failed tx). SLOT(QString swapExactInput(QString defAHex, QString defBHex, QString userInputHoldingHex, QString userOutputHoldingHex, QString amountInDecimal, QString minOutDecimal, QString deadlineDecimal)) + // Reads the token list config at TOKENS_CONFIG (absolute path, JSON array + // of { symbol, name, definitionId, holding, decimals }) and returns it as + // a QVariantList of QVariantMap entries. Returns an empty list if + // TOKENS_CONFIG is unset/unreadable/invalid. + SLOT(QVariantList tokenList()) }