feat(amm): config-driven token picker wired to on-chain swaps

This commit is contained in:
Andrea Franz 2026-07-14 00:07:12 +02:00 committed by r4bbit
parent a0c8983302
commit 906aa65b4f
12 changed files with 348 additions and 565 deletions

View File

@ -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`:

View File

@ -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);
}
}
}
}

View File

@ -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()
}
}
}
}
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)
}
}

View File

@ -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];
}

View File

@ -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()
}
}
}

View File

@ -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;
}

View File

@ -3,6 +3,7 @@
#include <QObject>
#include <QString>
#include <QVariantList>
#include <QVariantMap>
#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

View File

@ -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())
}