mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-07-25 16:23:16 +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
|
> 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`:
|
||||||
|
|||||||
@ -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 "../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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user