mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-07-25 08:13:12 +00:00
feat(amm): config-driven token picker wired to on-chain swaps
This commit is contained in:
parent
a0c8983302
commit
906aa65b4f
@ -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`:
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
27
apps/amm/qml/components/swap/TokenVisuals.js
Normal file
27
apps/amm/qml/components/swap/TokenVisuals.js
Normal 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];
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user