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 > 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. > `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 ## Updating Dependencies
To update the pinned versions of dependencies in `flake.lock`: 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 "../shared"
import "../../state" 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 { Rectangle {
id: root id: root
property var theme property var theme
property var tokens: [] property var tokens: []
// Real backend replica (logos.module("amm_ui")), wired from SwapPage.
property var backend: null
property var sellToken: null property var sellToken: null
property var buyToken: null property var buyToken: null
property string sellInput: "" property string sellInput: ""
@ -16,13 +23,33 @@ Rectangle {
property string editingSide: "sell" property string editingSide: "sell"
property real slippageTolerancePercent: 0.5 property real slippageTolerancePercent: 0.5
DummySwapState { // Pool resolution (backend.resolvePool)
id: swapState // sellToken is always passed as defA and buyToken as defB, so reserveA
feeBps: 30 // 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 requestTokenSelect(string side)
signal submitRequested(var snapshot) 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) { function setToken(side, token) {
if (side === "sell") root.sellToken = token if (side === "sell") root.sellToken = token
@ -35,8 +62,57 @@ Rectangle {
root.editingSide = "sell" root.editingSide = "sell"
} }
readonly property real sellReserve: sellToken ? (sellToken.reserve || 0) : 0 // Pool resolution
readonly property real buyReserve: buyToken ? (buyToken.reserve || 0) : 0 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: { readonly property real parsedSellInput: {
var amt = parseFloat(sellInput) var amt = parseFloat(sellInput)
@ -50,74 +126,144 @@ Rectangle {
readonly property real parsedSellAmount: editingSide === "sell" readonly property real parsedSellAmount: editingSide === "sell"
? parsedSellInput ? parsedSellInput
: swapState.amountInFor(parsedBuyInput, sellReserve, buyReserve) : swapState.amountInFor(parsedBuyInput, sellReserveNum, buyReserveNum)
readonly property real parsedBuyAmount: editingSide === "buy" readonly property real parsedBuyAmount: editingSide === "buy"
? parsedBuyInput ? parsedBuyInput
: swapState.amountOutFor(parsedSellInput, sellReserve, buyReserve) : swapState.amountOutFor(parsedSellInput, sellReserveNum, buyReserveNum)
readonly property real feeAmount: swapState.feeAmount(parsedSellAmount) readonly property real feeAmount: swapState.feeAmount(parsedSellAmount)
readonly property real minReceivedAmount: swapState.minReceived(parsedBuyAmount, slippageTolerancePercent) 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 (preview only)") : qsTr("Exact input")
readonly property string swapModeText: editingSide === "buy" ? qsTr("Exact output") : qsTr("Exact input")
readonly property bool hasAmount: editingSide === "sell" ? parsedSellInput > 0 : parsedBuyInput > 0 readonly property bool hasAmount: editingSide === "sell" ? parsedSellInput > 0 : parsedBuyInput > 0
readonly property bool tokensSelected: sellToken !== null && buyToken !== null readonly property bool tokensSelected: sellToken !== null && buyToken !== null
readonly property bool insufficientBalance: hasAmount && sellToken !== null && parsedSellAmount > (sellToken.balance || 0) readonly property bool insufficientLiquidity: hasAmount && root.poolExists && parsedBuyAmount > buyReserveNum
readonly property bool insufficientLiquidity: hasAmount && buyToken !== null && parsedBuyAmount > (buyToken.reserve || 0) // The backend only exposes swapExactInput, so only the "I know exactly
readonly property bool canSubmit: tokensSelected && hasAmount && parsedSellAmount > 0 && parsedBuyAmount > 0 && !insufficientBalance && !insufficientLiquidity // 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: { readonly property string submitButtonText: {
if (!hasAmount || !tokensSelected) return qsTr("Enter an amount") if (!tokensSelected) return qsTr("Select tokens")
if (insufficientBalance) return qsTr("Insufficient balance") 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 (insufficientLiquidity) return qsTr("Insufficient liquidity")
if (parsedBuyAmount <= 0) return qsTr("Amount too small")
return qsTr("Swap") 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) { function formatAmountValue(val) {
if (val >= 1) return val.toFixed(2) if (val >= 1) return val.toFixed(2)
if (val >= 0.0001) return val.toFixed(6) if (val >= 0.0001) return val.toFixed(6)
return val.toFixed(8) 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" readonly property string sellDisplay: editingSide === "sell"
? sellInput ? sellInput
: (parsedSellAmount > 0 ? formatAmountValue(parsedSellAmount) : "") : (parsedSellAmount > 0 ? formatBaseUnits(parsedSellAmount) : "")
readonly property string buyDisplay: editingSide === "buy" readonly property string buyDisplay: editingSide === "buy"
? buyInput ? buyInput
: (parsedBuyAmount > 0 ? formatAmountValue(parsedBuyAmount) : "") : (parsedBuyAmount > 0 ? formatBaseUnits(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, ",")
}
function buildSnapshot() { function buildSnapshot() {
return { return {
"sellToken": sellToken ? sellToken.symbol : "", "sellToken": sellToken ? sellToken.symbol : "",
"buyToken": buyToken ? buyToken.symbol : "", "buyToken": buyToken ? buyToken.symbol : "",
"sellAmount": formatAmountValue(parsedSellAmount), "sellAmount": formatBaseUnits(parsedSellAmount),
"buyAmount": formatAmountValue(parsedBuyAmount), "buyAmount": formatBaseUnits(parsedBuyAmount),
"minReceived": formatAmountValue(minReceivedAmount), "minReceived": formatBaseUnits(minReceivedAmount),
"feeAmount": swapState.formatTokenAmount(feeAmount, sellToken ? sellToken.symbol : ""), "feeAmount": swapState.formatTokenAmount(feeAmount, sellToken ? sellToken.symbol : ""),
"priceImpactPercent": swapState.formatPercent(priceImpactPercent), "priceImpactPercent": swapState.formatPercent(priceImpactPercent),
"priceImpactPercentValue": priceImpactPercent, "priceImpactPercentValue": priceImpactPercent,
"slippageTolerance": swapState.formatSlippagePercent(slippageTolerancePercent), "slippageTolerance": swapState.formatSlippagePercent(slippageTolerancePercent),
"swapMode": swapMode, "swapMode": "swap-exact-input",
"swapModeText": swapModeText "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 radius: 24
color: theme.colors.cardBg color: theme.colors.cardBg
border.color: theme.colors.border border.color: theme.colors.border
@ -140,7 +286,6 @@ Rectangle {
theme: root.theme theme: root.theme
label: "Sell" label: "Sell"
amount: root.sellDisplay amount: root.sellDisplay
usdValue: root.sellUsd
token: root.sellToken token: root.sellToken
active: root.editingSide === "sell" active: root.editingSide === "sell"
onInputEdited: function(v) { onInputEdited: function(v) {
@ -196,7 +341,6 @@ Rectangle {
theme: root.theme theme: root.theme
label: "Buy" label: "Buy"
amount: root.buyDisplay amount: root.buyDisplay
usdValue: root.buyUsd
token: root.buyToken token: root.buyToken
active: root.editingSide === "buy" active: root.editingSide === "buy"
onInputEdited: function(v) { onInputEdited: function(v) {
@ -206,6 +350,30 @@ Rectangle {
onTokenClicked: root.requestTokenSelect("buy") 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 { SwapSummary {
Layout.fillWidth: true Layout.fillWidth: true
Layout.topMargin: 12 Layout.topMargin: 12

View File

@ -1,5 +1,6 @@
import QtQuick 2.15 import QtQuick 2.15
import QtQuick.Layouts 1.15 import QtQuick.Layouts 1.15
import "TokenVisuals.js" as TokenVisuals
Rectangle { Rectangle {
id: root id: root
@ -94,11 +95,11 @@ Rectangle {
Rectangle { Rectangle {
width: 24; height: 24; radius: 12 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 visible: root.token !== null
Text { Text {
anchors.centerIn: parent anchors.centerIn: parent
text: root.token ? root.token.letter : "" text: root.token ? TokenVisuals.letterFor(root.token.symbol) : ""
color: "#ffffff" color: "#ffffff"
font.pixelSize: 10 font.pixelSize: 10
font.weight: Font.Bold font.weight: Font.Bold

View File

@ -1,5 +1,6 @@
import QtQuick 2.15 import QtQuick 2.15
import QtQuick.Layouts 1.15 import QtQuick.Layouts 1.15
import "TokenVisuals.js" as TokenVisuals
Item { Item {
id: root id: root
@ -7,9 +8,7 @@ Item {
property var theme property var theme
property string tokenName: "" property string tokenName: ""
property string tokenSymbol: "" property string tokenSymbol: ""
property string tokenAddress: "" property string tokenDefinitionId: ""
property string tokenColor: "#627eea"
property string tokenLetter: ""
signal clicked() signal clicked()
@ -29,10 +28,10 @@ Item {
Rectangle { Rectangle {
width: 36; height: 36; radius: 18 width: 36; height: 36; radius: 18
color: root.tokenColor color: TokenVisuals.colorFor(root.tokenSymbol)
Text { Text {
anchors.centerIn: parent anchors.centerIn: parent
text: root.tokenLetter text: TokenVisuals.letterFor(root.tokenSymbol)
color: "#ffffff" color: "#ffffff"
font.pixelSize: 14 font.pixelSize: 14
font.weight: Font.Bold font.weight: Font.Bold
@ -55,8 +54,8 @@ Item {
spacing: 6 spacing: 6
Text { text: root.tokenSymbol; color: theme.colors.textSecondary; font.pixelSize: 12 } Text { text: root.tokenSymbol; color: theme.colors.textSecondary; font.pixelSize: 12 }
Text { Text {
text: root.tokenAddress !== "" text: root.tokenDefinitionId !== ""
? root.tokenAddress.substring(0, 6) + "..." + root.tokenAddress.slice(-4) ? root.tokenDefinitionId.substring(0, 6) + "..." + root.tokenDefinitionId.slice(-4)
: "" : ""
color: theme.colors.textPlaceholder color: theme.colors.textPlaceholder
font.pixelSize: 12 font.pixelSize: 12

View File

@ -1,6 +1,7 @@
import QtQuick 2.15 import QtQuick 2.15
import QtQuick.Controls 2.15 import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15 import QtQuick.Layouts 1.15
import "TokenVisuals.js" as TokenVisuals
Item { Item {
id: root id: root
@ -127,8 +128,8 @@ Item {
spacing: 6 spacing: 6
Rectangle { Rectangle {
width: 22; height: 22; radius: 11 width: 22; height: 22; radius: 11
color: modelData.color color: TokenVisuals.colorFor(modelData.symbol)
Text { anchors.centerIn: parent; text: modelData.letter; color: "#ffffff"; font.pixelSize: 10; font.weight: Font.Bold } 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 } Text { text: modelData.symbol; color: theme.colors.textPrimary; font.pixelSize: 13; font.weight: Font.Medium }
} }
@ -162,9 +163,7 @@ Item {
theme: root.theme theme: root.theme
tokenName: modelData.name tokenName: modelData.name
tokenSymbol: modelData.symbol tokenSymbol: modelData.symbol
tokenAddress: modelData.address tokenDefinitionId: modelData.definitionId
tokenColor: modelData.color
tokenLetter: modelData.letter
onClicked: root.tokenSelected(modelData) 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 id: root
// Real backend replica (logos.module("amm_ui")), wired from Main.qml. // 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 backend: null
property var tokens: [ // Config-driven token list, loaded from AmmUiBackend::tokenList() (which
{ symbol: "TOK1", name: "Token 1", color: "#627eea", letter: "E", address: "0x0000000000000000000000000000000000000000", usdPrice: 2392.70, balance: 4.25, reserve: 850 }, // reads the TOKENS_CONFIG JSON file see apps/amm/README.md). Empty
{ symbol: "TOK2", name: "Token 2", color: "#2775ca", letter: "$", address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", usdPrice: 1.00, balance: 12480, reserve: 2400000 }, // until the backend is ready and the call resolves.
{ symbol: "TOK3", name: "Token 3", color: "#26a17b", letter: "T", address: "0xdac17f958d2ee523a2206206994597c13d831ec7", usdPrice: 1.00, balance: 320, reserve: 1800000 }, property var tokens: []
{ 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 }, onBackendChanged: {
{ symbol: "TOK6", name: "Token 6", color: "#9b59b6", letter: "L", address: "0x1337000000000000000000000000000000000cafe", usdPrice: 0.42, balance: 5400, reserve: 950000 } if (root.backend) {
] logos.watch(root.backend.tokenList(),
function(list) { root.tokens = list },
function(err) { console.warn("tokenList error:", err) })
}
}
QtObject { QtObject {
id: theme id: theme
@ -100,6 +102,7 @@ Item {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
theme: theme theme: theme
tokens: root.tokens tokens: root.tokens
backend: root.backend
width: Math.min(480, root.width - 32) width: Math.min(480, root.width - 32)
onRequestTokenSelect: function(side) { onRequestTokenSelect: function(side) {
@ -110,22 +113,14 @@ Item {
onSubmitRequested: function(snapshot) { onSubmitRequested: function(snapshot) {
swapConfirmationDialog.openWithSnapshot(snapshot) swapConfirmationDialog.openWithSnapshot(snapshot)
} }
}
ManualSwapPanel { onSwapSucceeded: function(result) {
id: manualSwapPanel
Layout.alignment: Qt.AlignHCenter
theme: theme
backend: root.backend
width: swapCard.width
onSwapSucceeded: function(snapshot) {
swapToast.show(qsTr("Swap submitted"), swapToast.show(qsTr("Swap submitted"),
qsTr("tx %1").arg(snapshot.txHash)) qsTr("tx %1").arg(result.txHash))
} }
onSwapFailed: function(message) { onSwapFailed: function(message) {
console.warn("Manual swap failed:", message) console.warn("Swap failed:", message)
} }
} }
@ -172,13 +167,9 @@ Item {
theme: theme theme: theme
onConfirmed: function(snapshot) { onConfirmed: function(snapshot) {
swapCard.resetAmounts() // The dialog only shows a preview snapshot; the actual
swapToast.show(qsTr("Swap submitted"), // on-chain swap runs here, against SwapCard's live state.
qsTr("%1 %2 → %3 %4") swapCard.executeSwap()
.arg(snapshot.sellAmount)
.arg(snapshot.sellToken)
.arg(snapshot.minReceived)
.arg(snapshot.buyToken))
} }
} }
} }

View File

@ -43,6 +43,11 @@ namespace {
// (and therefore every PDA derived from it). See apps/amm/README.md. // (and therefore every PDA derived from it). See apps/amm/README.md.
const char AMM_PROGRAM_BIN_ENV[] = "AMM_PROGRAM_BIN"; 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. // Normalise file:// URLs and OS paths to a plain local path.
QString toLocalPath(const QString& path) { QString toLocalPath(const QString& path) {
if (path.startsWith("file://") || path.contains("/")) if (path.startsWith("file://") || path.contains("/"))
@ -674,3 +679,47 @@ QString AmmUiBackend::swapExactInput(QString defAHex, QString defBHex, QString u
refreshBalances(); refreshBalances();
return obj.value(QStringLiteral("tx_hash")).toString(); 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 <QObject>
#include <QString> #include <QString>
#include <QVariantList>
#include <QVariantMap> #include <QVariantMap>
#include "rep_AmmUiBackend_source.h" #include "rep_AmmUiBackend_source.h"
@ -53,6 +54,9 @@ public slots:
QString swapExactInput(QString defAHex, QString defBHex, QString userInputHoldingHex, QString swapExactInput(QString defAHex, QString defBHex, QString userInputHoldingHex,
QString userOutputHoldingHex, QString amountInDecimal, QString userOutputHoldingHex, QString amountInDecimal,
QString minOutDecimal, QString deadlineDecimal) override; 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: private:
// Per-app wallet home (kept distinct from the wallet's canonical // 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 // unix timestamp. Returns the tx hash, or an empty string on failure
// (no pool, unreadable AMM_PROGRAM_BIN, bad inputs, or a failed tx). // (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)) 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())
} }