feat(amm): source liquidity tokens app-side + add custom tokens by id

Move the liquidity token selector off the module's stateful newPositionContext
onto a lean, app-owned surface, and let users add unlisted tokens by id.

FFI: new stateless `resolve_tokens` op — the app passes an explicit id set and
gets uniform selector rows `{ definitionId (base58), name, totalSupply, holdingId,
balance }`, held tokens first, unresolvable/non-fungible ids omitted. Reuses the
per-token definition/holding logic from `context`, without the network/status
envelope. Unit-tested.

Module: `resolveTokens(request, wallet_open)` reads the definitions + wallet and
calls the op (ids wrapped in a map — the universal-module glue only marshals
map/scalar inputs, not bare lists).

Backend: the app owns the id set — configured tokens (TOKENS_CONFIG) plus the
user's persisted custom ids. Held-but-unlisted tokens are NOT auto-listed (the
list mirrors the swap side); a token you hold still shows its balance once listed.
`addCustomToken` validates a pasted id by resolving its on-chain definition, then
persists it to CUSTOM_TOKEN_CONFIG (defaulting to the per-user app-data store, with
a HOME fallback so persistence never silently no-ops on an empty path).

QML: NewPositionForm/LiquidityPage take tokens/walletReady/loadingTokens as inputs
and drive selection + custom-token resolution through the backend; dropped all
newPositionContext reads and the selectable/status/code row fields.

Tests: custom-token.mjs creates token D on-chain (left out of the token config)
and verifies pasting its id resolves, selects, and persists it across a reload.
The setup script mints token D and initializes/prints the isolated
CUSTOM_TOKEN_CONFIG store
This commit is contained in:
r4bbit
2026-08-13 15:32:50 +02:00
parent cb1b457ad3
commit 13732cd7e2
18 changed files with 737 additions and 150 deletions
+4
View File
@@ -20,3 +20,7 @@ tests/testnet/amm-tokens.json
# Isolated known-pools config written by tests/testnet/setup-amm-testnet.sh
tests/testnet/amm-pools.json
# Isolated custom-token store (CUSTOM_TOKEN_CONFIG) — initialized by the setup script
# and written by the app during tests/custom-token.mjs
tests/testnet/custom-tokens.json
@@ -19,8 +19,10 @@ AmmActionCard {
id: fallbackTheme
}
property var newPositionContext: ({})
property var flowState: ({})
// True while the app is (re)loading the token selector rows (backend.resolveTokens());
// gates the selectors/spinner like the old context load did.
property bool loadingTokens: false
// Wallet token holdings (backend.tokenHoldings()) for the create-pool account
// selectors, narrowed per side by the selected token's base58 definitionId. The
// chosen holdings feed the createPool call via submissionSnapshot().
@@ -51,7 +53,7 @@ AmmActionCard {
property bool showRefreshAction: true
readonly property var quotePayload: root.flowState.quote || ({})
readonly property bool contextLoading: root.flowState.contextLoading === true
readonly property bool contextLoading: root.loadingTokens
readonly property bool quoteLoading: root.flowState.quoteLoading === true
readonly property bool submitting: root.flowState.submitting === true
readonly property bool quoteStale: root.flowState.quoteStale === true
@@ -61,12 +63,23 @@ AmmActionCard {
readonly property var emptyToken: ({
"definitionId": "",
"name": "",
"totalSupplyRaw": "0",
"balanceRaw": "0",
"selectable": false
"totalSupply": "0",
"balance": "0"
})
readonly property var tokens: root.newPositionContext && root.newPositionContext.tokens
? root.newPositionContext.tokens : []
// The liquidity token selector rows, injected from the app (backend.resolveTokens()):
// the union of configured tokens and persisted-custom tokens. Every row is
// { definitionId (base58), name, totalSupply, holdingId, balance } and already valid
// (unresolvable ids are omitted upstream), so every listed token is selectable.
property var tokens: []
// The ids currently offered by the selector — a readable projection of `tokens` (every
// listed token is selectable). Exposed as a property so it can be observed directly.
readonly property var selectableTokenIdList: root.selectableTokenIds()
// Same set as a comma-joined string — a form that serializes reliably over the QML
// inspector (var arrays may not), used by the custom-token test.
readonly property string selectableTokenIdsCsv: root.selectableTokenIds().join(",")
// Whether the wallet session is ready (from the flow); gates funding/selection like the
// old context "ready"/"no_wallet" status did, minus the network envelope.
property bool walletReady: false
// Supported fee tiers as raw bps, injected from backend.feeTiers() (amm_core's
// SUPPORTED_FEE_TIERS). The selector's delegate wants { feeBps } rows, so wrap
// each int; labels are derived locally via feeLabel().
@@ -149,13 +162,10 @@ AmmActionCard {
implicitWidth: 480
Component.onCompleted: Qt.callLater(root.reconcileSelection)
onNewPositionContextChanged: Qt.callLater(root.applyContextChange)
function applyContextChange() {
if (root.resolvingToken)
root.finishTokenResolution()
else
root.reconcileSelection()
}
// Re-reconcile the current selection whenever the app's token list changes (e.g. after a
// wallet toggle or a custom token is added). Resolution completion is driven externally
// (LiquidityPage calls finishTokenResolution once addCustomToken returns).
onTokensChanged: Qt.callLater(root.reconcileSelection)
onQuotePayloadChanged: {
if (root.quoteStale)
return
@@ -242,26 +252,6 @@ AmmActionCard {
}
}
Rectangle {
Layout.fillWidth: true
implicitHeight: networkMessage.implicitHeight + 20
radius: 6
color: root.theme.colors.panelBg
border.color: root.theme.colors.error
visible: root.contextBlocksForm()
Text {
id: networkMessage
anchors.fill: parent
anchors.margins: 10
text: root.contextErrorText()
color: root.theme.colors.textPrimary
font.pixelSize: 12
wrapMode: Text.Wrap
verticalAlignment: Text.AlignVCenter
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 0
@@ -578,26 +568,6 @@ AmmActionCard {
}
}
Rectangle {
Layout.fillWidth: true
implicitHeight: warningTextItem.implicitHeight + 20
radius: 6
color: root.theme.colors.panelBg
border.color: root.theme.colors.ctaBg
visible: root.warningText().length > 0
Text {
id: warningTextItem
anchors.fill: parent
anchors.margins: 10
text: root.warningText()
color: root.theme.colors.textPrimary
font.pixelSize: 12
wrapMode: Text.Wrap
verticalAlignment: Text.AlignVCenter
}
}
SubmittedTransaction {
Layout.fillWidth: true
title: qsTr("Position submitted")
@@ -712,9 +682,11 @@ AmmActionCard {
}
function selectableTokenIds() {
// Every injected row is already a valid, selectable token (resolveTokens omits
// anything unresolvable), so all listed ids are selectable.
var result = []
for (var i = 0; i < root.tokens.length; ++i) {
if (root.tokens[i].selectable === true)
if (root.tokens[i].definitionId)
result.push(root.tokens[i].definitionId)
}
return result
@@ -735,55 +707,33 @@ AmmActionCard {
return
}
// Already in the app's token list → select directly (every listed token is valid).
var current = root.tokenById(tokenId)
if (current.definitionId === tokenId) {
if (current.selectable === true)
root.selectToken(side, tokenId)
else {
root.tokenResolutionError = root.issueText(current.code || current.status)
root.tokenResolutionErrorSide = side
}
root.selectToken(side, tokenId)
return
}
// A custom/pasted id: ask the app to validate + persist it (backend.addCustomToken),
// which calls finishTokenResolution(token) on success or failTokenResolution(code) on
// an unresolvable / non-fungible id.
root.resolvingTokenId = tokenId
root.resolvingTokenSide = side
root.tokenResolveRequested(tokenId)
}
function finishTokenResolution(finalResponse) {
function finishTokenResolution(token) {
if (!root.resolvingToken)
return
var token = root.tokenById(root.resolvingTokenId)
if (!token || !token.definitionId) {
if (finalResponse === true) {
var currentStatus = String(root.newPositionContext.status || "")
var code = currentStatus !== "ready" && currentStatus !== "no_wallet"
&& currentStatus !== "loading"
? root.newPositionContext.code || currentStatus
: "token_definition_unreadable"
root.failTokenResolution(code)
}
return
}
var status = String(root.newPositionContext.status || "")
if (status === "loading")
return
if (status !== "ready" && status !== "no_wallet") {
root.failTokenResolution(root.newPositionContext.code || status)
root.failTokenResolution("token_definition_unreadable")
return
}
var side = root.resolvingTokenSide
root.resolvingTokenId = ""
root.resolvingTokenSide = ""
if (token.selectable !== true) {
root.tokenResolutionError = root.issueText(token.code || token.status)
root.tokenResolutionErrorSide = side
return
}
root.tokenResolutionMessage = qsTr("%1 - raw supply %2")
.arg(token.name || root.shortId(token.definitionId))
.arg(AmountMath.formatRaw(token.totalSupplyRaw || "0", 0))
.arg(AmountMath.formatRaw(token.totalSupply || "0", 0))
root.selectToken(side, token.definitionId)
}
@@ -798,9 +748,6 @@ AmmActionCard {
}
function reconcileSelection() {
var status = String(root.newPositionContext.status || "")
if (status !== "ready" && status !== "no_wallet")
return
var previousA = root.selectedTokenAId
var previousB = root.selectedTokenBId
var selectable = root.selectableTokenIds()
@@ -1070,7 +1017,7 @@ AmmActionCard {
}
function probeRaw(token, decimals) {
var balance = String(token.balanceRaw || "0")
var balance = String(token.balance || "0")
var simulated = AmountMath.multiply(AmountMath.pow10(decimals), "1000")
if (AmountMath.isUnsigned(balance) && AmountMath.compare(balance, simulated) > 0)
return balance
@@ -1258,8 +1205,8 @@ AmmActionCard {
var reserveB = root.poolReserve("B")
if (!reserveA || !reserveB || reserveA === "0" || reserveB === "0")
return
var balanceA = String(root.tokenA.balanceRaw || "0")
var balanceB = String(root.tokenB.balanceRaw || "0")
var balanceA = String(root.tokenA.balance || "0")
var balanceB = String(root.tokenB.balance || "0")
var fitA = AmountMath.mulDivFloor(balanceB, reserveA, reserveB)
var rawA = AmountMath.compare(balanceA, fitA) < 0 ? balanceA : fitA
var rawB = AmountMath.mulDivFloor(rawA, reserveB, reserveA)
@@ -1429,12 +1376,6 @@ AmmActionCard {
return ""
}
function warningText() {
// The lean quotes carry no warnings; only the token-sourcing context may.
var warnings = root.newPositionContext.warnings || []
return warnings.length > 0 ? root.issueText(warnings[0].code) : ""
}
function submissionSnapshot() {
var built = root.buildQuoteRequest()
return {
@@ -1466,7 +1407,7 @@ AmmActionCard {
}
function balanceText(token, decimals) {
return AmountMath.formatRaw(String(token.balanceRaw || "0"), decimals)
return AmountMath.formatRaw(String(token.balance || "0"), decimals)
}
function tokenBalanceDetail(token) {
@@ -1514,20 +1455,7 @@ AmmActionCard {
}
function contextStatusText() {
var network = String(root.newPositionContext.networkId || "")
if (root.newPositionContext.status === "no_wallet")
return qsTr("%1 · simulation only").arg(network || qsTr("Wallet disconnected"))
if (root.newPositionContext.status === "ready")
return qsTr("%1 · wallet ready").arg(network)
return network.length > 0 ? network : qsTr("Loading network")
}
function contextBlocksForm() {
var status = String(root.newPositionContext.status || "")
return status !== "" && status !== "ready" && status !== "no_wallet" && status !== "loading"
}
function contextErrorText() {
return root.issueText(root.newPositionContext.code || root.newPositionContext.status)
return root.walletReady ? qsTr("Wallet ready")
: qsTr("Wallet disconnected · simulation only")
}
}
+67 -17
View File
@@ -13,6 +13,8 @@ import "../state"
Item {
id: root
objectName: "liquidityPage"
property var backend: null
property var runtime: null
readonly property NewPositionFlow flow: newPositionFlow
@@ -26,6 +28,13 @@ Item {
// becomes available.
property var feeTiers: []
// The liquidity token selector rows (backend.resolveTokens()): the app-owned union of
// configured tokens and persisted-custom tokens. Refetched when the wallet opens/closes
// (holdingId/balance change) and after a custom token is added.
property var resolvedTokens: []
property bool tokensLoading: false
property int tokensGeneration: 0
function refreshHoldings() {
if (!root.backend || root.runtime === null)
return
@@ -42,13 +51,58 @@ Item {
function(err) { console.warn("feeTiers error:", err) })
}
onBackendChanged: { root.refreshHoldings(); root.refreshFeeTiers() }
onRuntimeChanged: { root.refreshHoldings(); root.refreshFeeTiers() }
Component.onCompleted: { root.refreshHoldings(); root.refreshFeeTiers() }
function refreshTokens() {
if (!root.backend || root.runtime === null)
return
// Tag each request; a wallet toggle can start overlapping resolveTokens calls whose
// replies arrive out of order — drop any superseded callback (mirrors SwapPage's
// holdings-generation guard).
const generation = ++root.tokensGeneration
root.tokensLoading = true
root.runtime.watch(root.backend.resolveTokens(),
function(list) {
if (generation !== root.tokensGeneration)
return
root.resolvedTokens = list
root.tokensLoading = false
},
function(err) {
if (generation !== root.tokensGeneration)
return
root.tokensLoading = false
console.warn("resolveTokens error:", err)
})
}
// Validates + persists a user-pasted custom token id, then refreshes the list and hands the
// resolved row back to the form to complete selection (or reports the failure).
function addCustomToken(tokenId) {
if (!root.backend || root.runtime === null) {
form.failTokenResolution("backend_error")
return
}
root.runtime.watch(root.backend.addCustomToken(tokenId),
function(result) {
if (result && result.ok === true) {
root.refreshTokens()
form.finishTokenResolution(result.token)
} else {
form.failTokenResolution(result && result.error ? result.error : "unresolved")
}
},
function(err) {
console.warn("addCustomToken error:", err)
form.failTokenResolution("backend_error")
})
}
onBackendChanged: { root.refreshHoldings(); root.refreshFeeTiers(); root.refreshTokens() }
onRuntimeChanged: { root.refreshHoldings(); root.refreshFeeTiers(); root.refreshTokens() }
Component.onCompleted: { root.refreshHoldings(); root.refreshFeeTiers(); root.refreshTokens() }
Connections {
target: root.backend
function onIsWalletOpenChanged() { root.refreshHoldings() }
function onIsWalletOpenChanged() { root.refreshHoldings(); root.refreshTokens() }
}
readonly property int pageMargin: width < 640 ? 16 : 24
@@ -130,11 +184,11 @@ onBackendChanged: { root.refreshHoldings(); root.refreshFeeTiers() }
iconSize: 18
Layout.preferredWidth: 40
Layout.preferredHeight: 40
enabled: !newPositionFlow.contextLoading && !newPositionFlow.submitting
enabled: !root.tokensLoading && !newPositionFlow.submitting
Accessible.name: qsTr("Refresh position data")
ToolTip.visible: hovered
ToolTip.text: Accessible.name
onClicked: newPositionFlow.refreshContext(true)
onClicked: { root.refreshTokens(); root.refreshHoldings() }
}
}
@@ -251,7 +305,9 @@ onBackendChanged: { root.refreshHoldings(); root.refreshFeeTiers() }
showRefreshAction: false
holdings: root.holdings
feeTiers: root.feeTiers
newPositionContext: newPositionFlow.newPositionContext
tokens: root.resolvedTokens
loadingTokens: root.tokensLoading
walletReady: newPositionFlow.walletStateReady
flowState: newPositionFlow.viewState
onQuoteRequested: function(immediate, quoteRequest) {
@@ -263,12 +319,12 @@ onBackendChanged: { root.refreshHoldings(); root.refreshFeeTiers() }
}
onTokenResolveRequested: function(tokenId) {
newPositionFlow.resolveToken(tokenId)
root.addCustomToken(tokenId)
}
onDraftChanged: newPositionFlow.draftChanged()
onPairReset: newPositionFlow.resetPoolExistence()
onRefreshRequested: newPositionFlow.refreshContext(true)
onRefreshRequested: { root.refreshTokens(); root.refreshHoldings() }
}
}
}
@@ -277,14 +333,8 @@ onBackendChanged: { root.refreshHoldings(); root.refreshFeeTiers() }
Connections {
target: newPositionFlow
function onTokenResolutionFinished(finalResponse) {
form.finishTokenResolution(finalResponse)
}
function onTokenResolutionFailed(code) {
form.failTokenResolution(code)
}
// Token resolution now goes app-side (addCustomToken → form callbacks); the flow only
// signals when a pool-existence change should re-request the quote.
function onQuoteRefreshRequested(immediate) {
form.requestQuote(immediate)
}
+120
View File
@@ -1,10 +1,16 @@
#include "AmmUiBackend.h"
#include <QByteArray>
#include <QDebug>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QJsonValue>
#include <QStandardPaths>
#include <QTimer>
#include "LogosWalletProvider.h"
@@ -322,6 +328,120 @@ QVariantList AmmUiBackend::feeTiers()
return m_logos->amm_module.feeTiers();
}
QVariantList AmmUiBackend::resolveTokens()
{
// The app owns the token set: the configured tokens (TOKENS_CONFIG) and the user's
// persisted custom ids — the same "known list" shape the swap side shows. Tokens the
// wallet merely holds are NOT auto-listed here; to provide liquidity with an unlisted
// token the user adds it by id (addCustomToken). The module still annotates
// holdingId/balance for whichever of these ids the wallet does hold.
const bool wallet_open = isWalletOpen();
QVariantList ids;
const QVariantList configured = m_logos->amm_module.tokenList();
for (const QVariant& entry : configured) {
const QString id = entry.toMap().value(QStringLiteral("definitionId")).toString();
if (!id.isEmpty())
ids.append(id);
}
const QStringList custom = loadCustomTokenIds();
for (const QString& id : custom)
ids.append(id);
QVariantMap request;
request.insert(QStringLiteral("tokenIds"), ids);
return m_logos->amm_module.resolveTokens(request, wallet_open);
}
QVariantMap AmmUiBackend::addCustomToken(QString tokenId)
{
const QString id = tokenId.trimmed();
if (id.isEmpty())
return QVariantMap{{QStringLiteral("ok"), false},
{QStringLiteral("error"), QStringLiteral("unresolved")}};
// Validate before persisting: resolve just this id and keep it only if it is a
// real fungible token (a non-fungible / unreadable id yields no row).
QVariantMap probe;
probe.insert(QStringLiteral("tokenIds"), QVariantList{id});
const QVariantMap token = rows.first().toMap();
const QString canonicalId = token.value(QStringLiteral("definitionId")).toString();
if (canonicalId.isEmpty())
return QVariantMap{{QStringLiteral("ok"), false},
{QStringLiteral("error"), QStringLiteral("unresolved")}};
QStringList custom = loadCustomTokenIds();
if (!custom.contains(canonicalId)) {
custom.append(canonicalId);
if (!saveCustomTokenIds(custom))
return QVariantMap{{QStringLiteral("ok"), false},
{QStringLiteral("error"), QStringLiteral("backend_error")}};
}
return QVariantMap{{QStringLiteral("ok"), true}, {QStringLiteral("token"), token}};
QString AmmUiBackend::customTokenStorePath() const
{
// A dedicated store path via CUSTOM_TOKEN_CONFIG (akin to the module's env-configured
// TOKENS_CONFIG). Otherwise per-user app data — but in a QML plugin with no
// QCoreApplication application name that can come back empty, so fall back to a fixed
// dot-dir under HOME. Persistence must never silently no-op on an empty path.
const QByteArray env = qgetenv("CUSTOM_TOKEN_CONFIG");
if (!env.isEmpty())
return QString::fromLocal8Bit(env);
const QString appData = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
const QString dir = !appData.isEmpty()
? appData
: QDir(QDir::homePath()).filePath(QStringLiteral(".logos-amm"));
return QDir(dir).filePath(QStringLiteral("amm-custom-tokens.json"));
}
QStringList AmmUiBackend::loadCustomTokenIds() const
{
const QString path = customTokenStorePath();
if (path.isEmpty())
return {};
QFile file(path);
if (!file.open(QIODevice::ReadOnly))
return {};
const QByteArray bytes = file.readAll();
file.close();
QJsonParseError error{};
const QJsonDocument doc = QJsonDocument::fromJson(bytes, &error);
if (error.error != QJsonParseError::NoError || !doc.isArray())
return {};
QStringList ids;
for (const QJsonValue& value : doc.array()) {
const QString id = value.toString().trimmed();
if (!id.isEmpty() && !ids.contains(id))
ids.append(id);
}
return ids;
}
bool AmmUiBackend::saveCustomTokenIds(const QStringList& ids) const
{
const QString path = customTokenStorePath();
if (path.isEmpty()) {
qWarning() << "AmmUiBackend: no custom-token store path; not persisting custom tokens";
return false;
}
QDir().mkpath(QFileInfo(path).absolutePath());
QJsonArray array;
for (const QString& id : ids)
array.append(id);
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
qWarning() << "AmmUiBackend: cannot write custom-token store" << path << file.errorString();
return false;
}
file.write(QJsonDocument(array).toJson(QJsonDocument::Compact));
file.close();
return true;
}
QVariantMap AmmUiBackend::createPool(QVariantMap request)
{
+11
View File
@@ -87,9 +87,20 @@ public slots:
QVariantList poolList() override;
// The AMM's supported fee tiers (raw bps) for the fee selector.
QVariantList feeTiers() override;
// Resolves the liquidity token selector rows for the app-owned id set
// (configured persisted custom; held-but-unlisted tokens are added by id).
QVariantList resolveTokens() override;
// Validates + persists a user-pasted custom token id (see the .rep).
QVariantMap addCustomToken(QString tokenId) override;
private:
void syncWalletState();
// Persisted custom (user-pasted) token ids. Stored as a JSON array of id
// strings at customTokenStorePath(); missing/unreadable ⇒ empty. The path is
// CUSTOM_TOKEN_CONFIG if set, else a per-user app-data fallback.
QStringList loadCustomTokenIds() const;
bool saveCustomTokenIds(const QStringList& ids) const;
QString customTokenStorePath() const;
// Publishes the new-position context PROP: a local "loading" placeholder
// until wallet state (and thus the module connection) is ready, then the
// module's newPositionContext for the current hints.
+14
View File
@@ -150,4 +150,18 @@ class AmmUiBackend
// guest enforces), so the fee selector never hardcodes or drifts. The QML
// formats labels and decides selectability.
SLOT(QVariantList feeTiers())
// Resolves the liquidity token selector's rows. The backend owns the id set:
// the configured tokens (TOKENS_CONFIG) plus the user's persisted custom tokens
// (see addCustomToken) — the same "known list" shape the swap side shows. Tokens
// the wallet merely holds are NOT auto-listed; add an unlisted one by id. Returns
// [{ definitionId (base58), name, totalSupply, holdingId, balance }] — every row
// the same shape, held tokens first (holdingId "" / balance "0" when not held).
SLOT(QVariantList resolveTokens())
// Adds a user-pasted custom token id (base58 or hex) to the persisted set, after
// validating it resolves to a fungible definition. On success persists it (de-duped)
// and returns { ok: true, token: <row> } with the resolved row; on an unresolvable /
// non-fungible id returns { ok: false, error: "unresolved" } and persists nothing.
SLOT(QVariantMap addCustomToken(QString tokenId))
}
+10
View File
@@ -12,6 +12,10 @@ inspector (framework from
- `add-liquidity.mjs` selects the seeded **A/B** pair, asserts the CTA stays
disabled until deposit amounts are entered, submits an add, and verifies the
A/B pool reserves grew **on-chain**.
- `custom-token.mjs` pastes token **D**'s id (created on-chain by the setup but
deliberately **absent** from the token config) into a Liquidity token slot and
verifies the app resolves it, selects it, and **persists** it to the custom-token
store — the "add an unlisted token by id" path. No pool / submit involved.
## Isolation
@@ -42,15 +46,21 @@ nix build .#test-framework -o apps/amm/result-mcp
TEST_SEQUENCER_ADDR=http://127.0.0.1:3040 apps/amm/tests/testnet/setup-amm-testnet.sh
# 2. Terminal 1 — launch the UI against ONLY the isolated wallet + test tokens.
# CUSTOM_TOKEN_CONFIG (where the app persists tokens added by id) defaults to the
# per-user store; set it to an isolated path so custom-token.mjs controls the store
# (it clears this file before + after running). Required for custom-token.mjs to
# avoid touching your real custom-token store.
LEE_WALLET_HOME_DIR=$(pwd)/apps/amm/tests/testnet/.wallet \
AMM_PROGRAM_BIN=$(pwd)/programs/amm/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/amm.bin \
TOKENS_CONFIG=$(pwd)/apps/amm/tests/testnet/amm-tokens.json \
CUSTOM_TOKEN_CONFIG=$(pwd)/apps/amm/tests/testnet/custom-tokens.json \
nix run .#amm-ui
# 3. Terminal 2 — drive a test; watch it click through the live UI.
node apps/amm/tests/swap.mjs # swap against the seeded A/B pool
node apps/amm/tests/create-pool.mjs # create the (unseeded) A/C pool
node apps/amm/tests/add-liquidity.mjs # add liquidity to the seeded A/B pool
node apps/amm/tests/custom-token.mjs # add token D (unlisted) by id
```
Headless CI variant (no window, launches the app itself, pass/fail only):
+186
View File
@@ -0,0 +1,186 @@
// ---------------------------------------------------------------------------
// AMM UI test — add a CUSTOM (unlisted) token by id through the Liquidity view.
//
// Token D is created on-chain by the setup script but deliberately LEFT OUT of
// the token config, so it never appears in the selector on its own. This test
// pastes D's definition id into a liquidity token slot and verifies the app
// RESOLVES it (reads its on-chain definition), SELECTS it, and PERSISTS it to the
// custom-token store — the addCustomToken path. No pool / submit is involved, so
// it needs neither an open wallet nor a seeded pool.
//
// NOTE: the add path is holding-agnostic — a token resolves from its public
// definition whether or not the wallet holds it (balance shows "0" when not held).
// D happens to be held by the test wallet only because minting requires the holding
// account to sign; a genuinely un-owned token adds via exactly the same path.
//
// Prereqs in the running app (see apps/amm/tests/README.md):
// * launched against the isolated test wallet + TOKENS_CONFIG the setup writes
// (TKA, TKB, TKC — NOT token D) and CUSTOM_TOKEN_CONFIG pointing at a
// writable path (defaults below, matching the README launch line)
// * a reachable local sequencer (to read D's definition)
// ---------------------------------------------------------------------------
import { resolve } from "node:path";
import { rm } from "node:fs/promises";
import { execFileSync } from "node:child_process";
const fwRoot =
process.env.LOGOS_QT_MCP ||
new URL("../result-mcp", import.meta.url).pathname;
const { test, run } = await import(resolve(fwRoot, "test-framework/framework.mjs"));
// The isolated test wallet home (set by the setup script) — used to resolve token
// D's deterministic definition id via the wallet CLI, the same way the setup does.
const WALLET_HOME =
process.env.LEE_WALLET_HOME_DIR ||
new URL("./testnet/.wallet", import.meta.url).pathname;
// Where the app persists custom tokens. Defaults to the isolated test store; set
// CUSTOM_TOKEN_CONFIG to override. IMPORTANT: launch the app with the SAME path
// (CUSTOM_TOKEN_CONFIG) so the test's clean-slate clears the store the app actually
// uses — otherwise the app writes to its default per-user store and the test starts
// from a stale slate. Only used to pre-clear; persistence is verified through the app.
const CUSTOM_TOKEN_CONFIG =
process.env.CUSTOM_TOKEN_CONFIG ||
new URL("./testnet/custom-tokens.json", import.meta.url).pathname;
// --- small helpers (mirror create-pool.mjs) --------------------------------
const ignore = async (fn) => { try { return await fn(); } catch { /* best effort */ } };
async function idByObjectName(app, name) {
const res = await app.findByProperty("objectName", name);
if (res.error || !res.matches || res.matches.length === 0)
throw new Error(`no object with objectName="${name}" (is the app on the Liquidity tab?)`);
return res.matches[0].id;
}
async function prop(app, id, name) {
const props = (await app.getProperties(id)).properties || [];
const p = props.find((x) => x.name === name);
return p ? p.value : undefined;
}
async function evaluate(app, id, expression) {
await app.inspector.send("evaluate", { expression, objectId: id });
}
async function saveShot(app, name) {
const shot = await ignore(() => app.screenshot());
if (shot && shot.image) {
const path = new URL(`./${name}.png`, import.meta.url).pathname;
await import("node:fs/promises").then(({ writeFile }) =>
writeFile(path, Buffer.from(shot.image, "base64")));
console.log(` screenshot -> ${path}`);
}
}
// Resolve token D's definition id from the test wallet (deterministic under the
// test mnemonic), the same account label the setup script mints it to.
function resolveTokenD() {
const out = execFileSync("wallet", ["account", "id", "--account-id", "token-d-def"], {
encoding: "utf8",
env: { ...process.env, LEE_WALLET_HOME_DIR: WALLET_HOME, NSSA_WALLET_HOME_DIR: WALLET_HOME },
});
const id = (out.match(/[1-9A-HJ-NP-Za-km-z]{32,44}/) || [])[0];
if (!id)
throw new Error(`could not resolve token-d-def id from wallet (home=${WALLET_HOME}) — run the setup script`);
return id;
}
// Trigger a token-list reload and wait for it to complete. resolveTokens() re-reads the
// persisted custom-token store from disk (in C++), so a token that survives a reload was
// genuinely persisted — no need to know the app's store path.
async function reloadTokens(app, pageId) {
await evaluate(app, pageId, "refreshTokens()");
await app.waitFor(
async () => { if ((await prop(app, pageId, "tokensLoading")) === true) throw new Error("reloading"); },
{ timeout: 10000, interval: 200, description: "token reload to finish" },
);
}
async function selectableIds(app, formId) {
// Read the CSV string form (var arrays don't reliably serialize over the inspector).
const csv = await prop(app, formId, "selectableTokenIdsCsv");
return typeof csv === "string" && csv.length > 0 ? csv.split(",") : [];
}
// --- the test ---------------------------------------------------------------
test("amm liquidity: add a custom (unlisted) token by id", async (app) => {
const tokenD = resolveTokenD();
console.log(` custom token D = ${tokenD}`);
// 1. Switch to the Liquidity tab and wait for the form + page to render.
await app.waitFor(
async () => { await app.expectTexts(["Trade", "Liquidity"]); },
{ timeout: 20000, interval: 500, description: "nav bar to load" },
);
await ignore(() => app.click("Liquidity"));
await app.waitFor(
async () => { await idByObjectName(app, "newPositionForm"); },
{ timeout: 10000, interval: 300, description: "liquidity form to render" },
);
const formId = await idByObjectName(app, "newPositionForm");
const pageId = await idByObjectName(app, "liquidityPage");
// 2. Clean slate: clear the isolated custom-token store the app uses and reload, so D is
// genuinely absent and pasting it must go through addCustomToken (not a direct select).
// If D is still listed after this, the app isn't using this store — launch it with
// CUSTOM_TOKEN_CONFIG pointing here (see README).
await rm(CUSTOM_TOKEN_CONFIG, { force: true });
await reloadTokens(app, pageId);
if ((await selectableIds(app, formId)).includes(tokenD))
throw new Error(
`token D is still listed after clearing ${CUSTOM_TOKEN_CONFIG} — launch the app with ` +
"CUSTOM_TOKEN_CONFIG set to this same path so the test controls the store (see README).",
);
// 3. Paste D's id into token slot A — the same entry point the token input's
// onTokenEntered uses. It's unlisted, so resolveToken routes through the app's
// addCustomToken: resolve the on-chain definition, persist, select.
await evaluate(app, formId, `resolveToken("A", "${tokenD}")`);
// 4. Wait for the resolution to complete: D selected on side A, no resolution error.
try {
await app.waitFor(
async () => {
const err = await prop(app, formId, "tokenResolutionError");
if (err) throw new Error(`resolution failed: ${err}`);
const selected = await prop(app, formId, "selectedTokenAId");
if (selected !== tokenD) throw new Error(`D not selected yet (selectedTokenAId=${selected})`);
},
{ timeout: 20000, interval: 500, description: "token D resolved + selected" },
);
} catch (e) {
await saveShot(app, "custom-token-not-resolved");
const err = await prop(app, formId, "tokenResolutionError");
throw new Error(`${e.message}. tokenResolutionError=${err}`);
}
// 5. The persistence proof: reload the list (re-reads the store from disk) and confirm D
// SURVIVES. D isn't in the token config, so if it's still selectable after a reload it
// can only have come from the persisted custom-token store — i.e. addCustomToken wrote
// it. If persistence silently failed, D would vanish here.
await reloadTokens(app, pageId);
if (!(await selectableIds(app, formId)).includes(tokenD)) {
await saveShot(app, "custom-token-not-persisted");
throw new Error(
"token D disappeared after a reload — it resolved + selected but was NOT persisted to the " +
"custom-token store. Launch the app with CUSTOM_TOKEN_CONFIG set to a writable path (see README).",
);
}
console.log(" token D added as a custom token ✓ (survives a token-list reload)");
await saveShot(app, "custom-token-added");
// Leave no side effects: clear the persisted custom token. The running app keeps it in
// memory until restart, but the next run's clean-slate step re-reads this cleared store.
await rm(CUSTOM_TOKEN_CONFIG, { force: true });
});
run();
// How to run: see apps/amm/tests/README.md — same flow as create-pool.mjs, plus
// launch the UI with CUSTOM_TOKEN_CONFIG set (the setup creates token D on-chain
// but leaves it out of the token config).
+55 -8
View File
@@ -2,12 +2,17 @@
#
# setup-amm-testnet.sh
# --------------------
# Deploy the token/amm/twap programs, mint three fungible tokens, initialize the
# Deploy the token/amm/twap programs, mint four fungible tokens, initialize the
# AMM, and create the A/B pool — from scratch — against whatever sequencer your
# `wallet` / `spel` config points at. This is the prerequisite state the AMM UI
# tests exercise: swap.mjs swaps against the seeded A/B pool, and create-pool.mjs
# creates the (deliberately unseeded) A/C pool. Run it once, then launch the UI /
# run the tests.
# tests exercise: swap.mjs swaps against the seeded A/B pool, create-pool.mjs
# creates the (deliberately unseeded) A/C pool, and custom-token.mjs adds token D
# by id. Run it once, then launch the UI / run the tests.
#
# Token D is created ON-CHAIN but deliberately LEFT OUT of the written token config
# (amm-tokens.json) — it is the "custom" token the custom-token.mjs test pastes by
# id to confirm the liquidity view resolves and adds an unlisted token. Its id is
# written to custom-token.json for that test to read.
#
# DETERMINISTIC TEST WALLET: by default the script bootstraps an ISOLATED wallet
# (git-ignored, under this folder) by restoring it from a fixed BIP-39 mnemonic
@@ -64,10 +69,12 @@ TEST_SEQUENCER_ADDR="${TEST_SEQUENCER_ADDR:-}"
# Deterministic accounts, created in THIS fixed order after a fresh restore so
# their ids are reproducible. Resolved to ids at runtime via `wallet account id`.
# token-c-* are APPENDED (not inserted) so the pre-existing a/b/lp ids don't shift.
# token-c-*/token-d-* are APPENDED (not inserted) so the pre-existing a/b/lp ids don't shift.
# Token C has no seeded pool — the create-pool UI test (apps/amm/tests/create-pool.mjs)
# creates the A/C pool itself, minting its own LP holding via the app.
ACCOUNT_LABELS=(token-a-def token-a-holding token-b-def token-b-holding lp-holding token-c-def token-c-holding)
# Token D is created but LEFT OUT of the token config — the custom-token UI test
# (apps/amm/tests/custom-token.mjs) adds it by id.
ACCOUNT_LABELS=(token-a-def token-a-holding token-b-def token-b-holding lp-holding token-c-def token-c-holding token-d-def token-d-holding)
###############################################################################
# CONFIG — non-account parameters (edit freely)
@@ -86,6 +93,8 @@ AMM_IDL="artifacts/amm-idl.json"
TOKEN_A_NAME="TOKEN A"; TOKEN_A_SYMBOL="TKA"; TOKEN_A_SUPPLY="1000000000000000000000"; TOKEN_A_DECIMALS=18
TOKEN_B_NAME="TOKEN B"; TOKEN_B_SYMBOL="TKB"; TOKEN_B_SUPPLY="1000000000000000000000"; TOKEN_B_DECIMALS=18
TOKEN_C_NAME="TOKEN C"; TOKEN_C_SYMBOL="TKC"; TOKEN_C_SUPPLY="1000000000000000000000"; TOKEN_C_DECIMALS=18
# Token D is the "custom" token: created on-chain but NOT written to the token config.
TOKEN_D_NAME="TOKEN D"; TOKEN_D_SYMBOL="TKD"; TOKEN_D_SUPPLY="1000000000000000000000"; TOKEN_D_DECIMALS=18
# --- Pool inputs ---
CLOCK_ACCOUNT="4BdcjoXkq786TMWcBGGHqcxeLYMZmn17rL4eM9ZyRWNU" # canonical LEZ system clock
@@ -105,6 +114,11 @@ TOKENS_CONFIG_OUT="apps/amm/tests/testnet/amm-tokens.json"
# per entry. More seeded pools = more entries here, no app change.
POOLS_CONFIG_OUT="apps/amm/tests/testnet/amm-pools.json"
# Isolated custom-token store for TESTS ONLY (git-ignored). Pass this path as
# CUSTOM_TOKEN_CONFIG when launching the UI so custom-token.mjs controls it instead of
# the app's default per-user store. Initialized empty so a test run starts clean.
CUSTOM_TOKEN_CONFIG_OUT="apps/amm/tests/testnet/custom-tokens.json"
###############################################################################
# Helpers
###############################################################################
@@ -263,12 +277,14 @@ TOKEN_B_HOLDING="$(acct_id token-b-holding)" || die "token-b-holding not registe
USER_HOLDING_LP="$(acct_id lp-holding)" || die "lp-holding not registered"
TOKEN_C_DEF="$(acct_id token-c-def)" || die "token-c-def not registered"
TOKEN_C_HOLDING="$(acct_id token-c-holding)" || die "token-c-holding not registered"
for v in TOKEN_A_DEF TOKEN_A_HOLDING TOKEN_B_DEF TOKEN_B_HOLDING USER_HOLDING_LP TOKEN_C_DEF TOKEN_C_HOLDING; do
TOKEN_D_DEF="$(acct_id token-d-def)" || die "token-d-def not registered"
TOKEN_D_HOLDING="$(acct_id token-d-holding)" || die "token-d-holding not registered"
for v in TOKEN_A_DEF TOKEN_A_HOLDING TOKEN_B_DEF TOKEN_B_HOLDING USER_HOLDING_LP TOKEN_C_DEF TOKEN_C_HOLDING TOKEN_D_DEF TOKEN_D_HOLDING; do
[ -n "${!v}" ] || die "failed to resolve account id for $v"
done
# Derived roles (the input holding signs; mint authority == holding; authority is the A holding).
TOKEN_A_MINT_AUTH="$TOKEN_A_HOLDING"; TOKEN_B_MINT_AUTH="$TOKEN_B_HOLDING"; TOKEN_C_MINT_AUTH="$TOKEN_C_HOLDING"
TOKEN_A_MINT_AUTH="$TOKEN_A_HOLDING"; TOKEN_B_MINT_AUTH="$TOKEN_B_HOLDING"; TOKEN_C_MINT_AUTH="$TOKEN_C_HOLDING"; TOKEN_D_MINT_AUTH="$TOKEN_D_HOLDING"
AMM_AUTHORITY="$TOKEN_A_HOLDING"
USER_HOLDING_A="$TOKEN_A_HOLDING"; USER_HOLDING_B="$TOKEN_B_HOLDING"
@@ -279,6 +295,8 @@ kv "token-b-holding" "$TOKEN_B_HOLDING"
kv "lp-holding" "$USER_HOLDING_LP"
kv "token-c-def" "$TOKEN_C_DEF"
kv "token-c-holding" "$TOKEN_C_HOLDING"
kv "token-d-def" "$TOKEN_D_DEF"
kv "token-d-holding" "$TOKEN_D_HOLDING"
###############################################################################
# 2. Deploy programs
@@ -320,6 +338,16 @@ run_tx strict "create fungible definition: $TOKEN_C_NAME" -- \
--holding-target-account "$TOKEN_C_HOLDING" \
--mint-authority "$TOKEN_C_MINT_AUTH"
# Token D is deliberately LEFT OUT of the token config below — the custom-token UI
# test pastes its id to add it as a custom token. Its definition must exist on-chain
# so the app can resolve it.
run_tx strict "create fungible definition: $TOKEN_D_NAME" -- \
spel --idl "$TOKEN_IDL" --program "$TOKEN_BIN" -- new-fungible-definition \
--name "$TOKEN_D_NAME" --total-supply "$TOKEN_D_SUPPLY" \
--definition-target-account "$TOKEN_D_DEF" \
--holding-target-account "$TOKEN_D_HOLDING" \
--mint-authority "$TOKEN_D_MINT_AUTH"
###############################################################################
# 5. Verify token definitions & holdings
###############################################################################
@@ -329,6 +357,8 @@ inspect "$TOKEN_IDL" "$TOKEN_B_DEF" "TokenDefinition"
inspect "$TOKEN_IDL" "$TOKEN_B_HOLDING" "TokenHolding"
inspect "$TOKEN_IDL" "$TOKEN_C_DEF" "TokenDefinition"
inspect "$TOKEN_IDL" "$TOKEN_C_HOLDING" "TokenHolding"
inspect "$TOKEN_IDL" "$TOKEN_D_DEF" "TokenDefinition"
inspect "$TOKEN_IDL" "$TOKEN_D_HOLDING" "TokenHolding"
###############################################################################
# 6. Derive AMM PDAs from the program ids + token pair
@@ -400,6 +430,8 @@ inspect "$AMM_IDL" "$POOL" "PoolDefinition"
###############################################################################
# 10. Write the UI token config from the deterministic accounts
###############################################################################
# NOTE: token D is intentionally NOT written here — it is the "custom" token the
# custom-token.mjs test adds by id, so it must be absent from the known list.
sec "Write UI token config -> $TOKENS_CONFIG_OUT"
cat > "$TOKENS_CONFIG_OUT" <<JSON
[
@@ -464,6 +496,15 @@ JSON
} > "$POOLS_CONFIG_OUT"
kv "wrote" "$POOLS_CONFIG_OUT"
###############################################################################
# 12. Initialize the isolated custom-token store (empty)
###############################################################################
sec "Write custom-token store -> $CUSTOM_TOKEN_CONFIG_OUT"
# Initialize the isolated custom-token store empty so a test run starts with no
# custom tokens. custom-token.mjs adds token D by id and clears this again after.
printf '%s\n' "[]" > "$CUSTOM_TOKEN_CONFIG_OUT"
kv "wrote" "$CUSTOM_TOKEN_CONFIG_OUT (empty)"
sec "Done"
log "${GRN}✅ Setup complete.${RST}"
kv "AMM program id" "$AMM_PID"
@@ -475,6 +516,12 @@ log " ${DIM}LEE_WALLET_HOME_DIR=$TEST_WALLET_HOME \\${RST}"
log " ${DIM} AMM_PROGRAM_BIN=$REPO_ROOT/$AMM_BIN \\${RST}"
log " ${DIM} TOKENS_CONFIG=$REPO_ROOT/$TOKENS_CONFIG_OUT \\${RST}"
log " ${DIM} AMM_POOLS_CONFIG=$REPO_ROOT/$POOLS_CONFIG_OUT \\${RST}"
log " ${DIM} CUSTOM_TOKEN_CONFIG=$REPO_ROOT/$CUSTOM_TOKEN_CONFIG_OUT \\${RST}"
log " ${DIM} nix run .#amm-ui${RST}"
log ""
log "Token D was created ON-CHAIN but left out of the token config (the ${DIM}custom${RST}"
log "token). Its id: ${DIM}$TOKEN_D_DEF${RST}"
log ""
log "Then in another terminal: ${DIM}node apps/amm/tests/swap.mjs${RST} (swap A/B)"
log " or: ${DIM}node apps/amm/tests/create-pool.mjs${RST} (create A/C pool)"
log " or: ${DIM}node apps/amm/tests/custom-token.mjs${RST} (add token D by id)"
+2
View File
@@ -22,6 +22,8 @@ char *amm_pair_ids(const char *request_json);
char *amm_context(const char *request_json);
char *amm_resolve_tokens(const char *request_json);
char *amm_swap_pair(const char *request_json);
char *amm_resolve_pool(const char *request_json);
+69 -1
View File
@@ -11,7 +11,7 @@ use super::{
config::load_config,
holding::{select_holding, wallet_holdings, SelectedHolding},
quote_error::issue,
ContextRequest, TokenIdsRequest,
ContextRequest, ResolveTokensRequest, TokenIdsRequest,
};
use crate::account::{
account_id_from_hex, account_id_hex, decode_account, parse_base58_id, parse_program_id,
@@ -133,6 +133,74 @@ pub(super) fn context(request: ContextRequest) -> Result<Value, String> {
}))
}
/// Resolves an explicit, app-provided set of token ids into selector rows — the lean,
/// stateless successor to `context`. The app owns the id set (its configured tokens plus any
/// custom/pasted ids it remembers), so there is no network envelope and no process-cached wallet
/// state here: the module reads the definitions + wallet fresh and passes them in, exactly like
/// `context` did per token.
///
/// `token_ids` are hex (the module normalizes base58→hex at the boundary); `token_definitions`
/// are the corresponding read accounts, keyed by hex id. Every returned row has the same shape —
/// `{ definitionId (base58), name, totalSupply, holdingId, balance }` — so the app never branches
/// per row; when the wallet doesn't hold the token, `holdingId` is `""` and `balance` is `"0"`.
/// A requested id whose definition is unreadable or non-fungible is omitted; the app treats a
/// requested id with no returned row as unresolved/unavailable.
pub(super) fn resolve_tokens(request: ResolveTokensRequest) -> Result<Value, String> {
let amm_program = parse_program_id(&request.amm_program_id)?;
let Ok(config) = load_config(amm_program, &request.config) else {
return Ok(json!({ "status": "error", "code": "config_unavailable", "tokens": [] }));
};
let holdings = wallet_holdings(&request.wallet_accounts, config.token_program_id);
// De-duplicate the requested ids, dropping any malformed ones. Order is irrelevant —
// the rows are sorted below (held first, then by id) like `context`.
let mut token_ids = BTreeSet::new();
for id in &request.token_ids {
if let Ok(id) = account_id_from_hex(id, "token id") {
token_ids.insert(id);
}
}
let mut rows = Vec::new();
for token_id in token_ids {
let read = request
.token_definitions
.iter()
.find(|read| account_id_from_hex(&read.id, "token definition id") == Ok(token_id));
// Only readable, fungible definitions become rows; anything else is omitted.
let Ok((name, total_supply, _metadata_id)) =
fungible_definition(read, token_id, config.token_program_id)
else {
continue;
};
// Uniform shape — every row carries holdingId/balance so the app never branches per row.
// A token the wallet doesn't hold gets an empty id and "0" balance.
let selected = select_holding(&holdings, token_id);
rows.push(json!({
"definitionId": token_id.to_string(),
"name": name,
"totalSupply": total_supply.to_string(),
"holdingId": selected.as_ref().map(|holding| holding.id.to_string()).unwrap_or_default(),
"balance": selected
.as_ref()
.map_or_else(|| String::from("0"), |holding| holding.balance.to_string()),
}));
}
rows.sort_by(|left, right| {
let held = |row: &Value| !row["holdingId"].as_str().unwrap_or_default().is_empty();
held(right).cmp(&held(left)).then_with(|| {
left["definitionId"]
.as_str()
.cmp(&right["definitionId"].as_str())
})
});
Ok(json!({ "status": "ok", "tokens": rows }))
}
fn context_error(request: &ContextRequest, code: &str) -> Value {
json!({
"status": "error",
+6 -1
View File
@@ -21,7 +21,7 @@ pub use request::{
AddLiquidityPlanRequest, AddLiquidityQuoteRequest, ConfigIdRequest, ContextRequest,
CreatePoolPlanRequest, CreatePoolQuoteRequest, FeeTiersRequest, PairIdsRequest, PoolIdRequest,
ProgramIdRequest, RemoveLiquidityPlanRequest, RemoveLiquidityQuoteRequest, ResolvePoolRequest,
SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest,
ResolveTokensRequest, SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest,
SwapExactOutQuoteRequest, SwapPairRequest, SyncReservesPlanRequest, TokenHoldingsRequest,
TokenIdsRequest,
};
@@ -83,6 +83,11 @@ pub fn context(request: ContextRequest) -> AmmResult {
context::context(request).map_err(Into::into)
}
/// Resolves an app-provided set of token ids into selector rows (lean successor to `context`).
pub fn resolve_tokens(request: ResolveTokensRequest) -> AmmResult {
context::resolve_tokens(request).map_err(Into::into)
}
/// Derives the canonical account ids for a swap pair (tokens in either order).
pub fn swap_pair(request: SwapPairRequest) -> AmmResult {
swap::swap_pair(request).map_err(Into::into)
+17
View File
@@ -43,6 +43,23 @@ pub struct ContextRequest {
pub resolved_token_ids: Vec<String>,
}
/// Resolves an app-provided set of token ids into selector rows (the lean successor to
/// `ContextRequest`). `token_ids` are hex — the module normalizes base58→hex and reads each
/// definition into `token_definitions` (keyed by hex id) plus the wallet accounts; the FFI is
/// stateless and reads nothing itself.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ResolveTokensRequest {
pub amm_program_id: String,
pub config: AccountRead,
#[serde(default)]
pub token_ids: Vec<String>,
#[serde(default)]
pub wallet_accounts: Vec<AccountRead>,
#[serde(default)]
pub token_definitions: Vec<AccountRead>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PairIdsRequest {
+52 -3
View File
@@ -14,13 +14,13 @@ use token_core::{TokenDefinition, TokenHolding};
use twap_oracle_core::compute_current_tick_account_pda;
use super::{
context::{context, token_ids},
context::{context, resolve_tokens, token_ids},
holding::{select_holding, SelectedHolding},
pair::{is_canonical_pair, pair_ids, PairIds},
quote::{div_ceil_u256, minimum_opening_pair, Q64},
swap::{swap_exact_in_plan, swap_exact_out_plan},
ContextRequest, PairIdsRequest, SwapExactInPlanRequest, SwapExactOutPlanRequest,
TokenIdsRequest,
ContextRequest, PairIdsRequest, ResolveTokensRequest, SwapExactInPlanRequest,
SwapExactOutPlanRequest, TokenIdsRequest,
};
use crate::{
account::{account_id_hex, account_read, decode_account, program_id_bytes},
@@ -291,6 +291,55 @@ fn context_selects_tokens_without_holdings() {
assert!(value["tokens"][0].get("holdingId").is_none());
}
#[test]
fn resolve_tokens_returns_lean_rows_held_first_and_omits_unresolvable() {
let held = AccountId::new([2; 32]);
let listed = AccountId::new([5; 32]);
let missing = AccountId::new([9; 32]); // requested but no definition read supplied
let config_id = compute_config_pda(AMM_PROGRAM);
let value = resolve_tokens(ResolveTokensRequest {
amm_program_id: amm_program_id(),
config: account_read(config_id, &config_account()),
token_ids: vec![
account_id_hex(held),
account_id_hex(listed),
account_id_hex(missing),
],
wallet_accounts: vec![account_read(
AccountId::new([6; 32]),
&token_holding(held, 42),
)],
token_definitions: vec![
account_read(held, &token_definition("Held", 1_000)),
account_read(listed, &token_definition("Listed", 2_000)),
],
})
.unwrap();
// Held token sorts first; the requested id with no readable definition is omitted. Every row
// carries the same fields — the non-held token gets an empty holdingId and "0" balance.
assert_eq!(
value["tokens"],
json!([
{
"definitionId": held.to_string(),
"name": "Held",
"totalSupply": "1000",
"holdingId": AccountId::new([6; 32]).to_string(),
"balance": "42",
},
{
"definitionId": listed.to_string(),
"name": "Listed",
"totalSupply": "2000",
"holdingId": "",
"balance": "0",
},
])
);
}
#[test]
fn missing_pool_snapshot_defaults_remain_real_accounts() {
let id = AccountId::new([5; 32]);
+6 -1
View File
@@ -9,7 +9,7 @@ use crate::api::{
self, AddLiquidityPlanRequest, AddLiquidityQuoteRequest, AmmApiError, AmmResult,
ConfigIdRequest, ContextRequest, CreatePoolPlanRequest, CreatePoolQuoteRequest,
FeeTiersRequest, PairIdsRequest, PoolIdRequest, ProgramIdRequest, RemoveLiquidityPlanRequest,
RemoveLiquidityQuoteRequest, ResolvePoolRequest, SwapExactInPlanRequest,
RemoveLiquidityQuoteRequest, ResolvePoolRequest, ResolveTokensRequest, SwapExactInPlanRequest,
SwapExactInQuoteRequest, SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest,
SyncReservesPlanRequest, TokenHoldingsRequest, TokenIdsRequest,
};
@@ -100,6 +100,11 @@ pub extern "C" fn amm_context(request_json: *const c_char) -> *mut c_char {
call::<ContextRequest>(request_json, api::context)
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_resolve_tokens(request_json: *const c_char) -> *mut c_char {
call::<ResolveTokensRequest>(request_json, api::resolve_tokens)
}
#[unsafe(no_mangle)]
pub extern "C" fn amm_swap_pair(request_json: *const c_char) -> *mut c_char {
call::<SwapPairRequest>(request_json, api::swap_pair)
+7 -6
View File
@@ -7,10 +7,11 @@ pub mod api;
pub use api::{
config_id, context, create_pool_plan, create_pool_quote, fee_tiers, pair_ids, pool_id,
program_id, resolve_pool, swap_exact_in_plan, swap_exact_in_quote, swap_exact_out_plan,
swap_exact_out_quote, swap_pair, token_ids, AccountRead, AmmApiError, AmmResponse, AmmResult,
ConfigIdRequest, ContextRequest, CreatePoolPlanRequest, CreatePoolQuoteRequest,
FeeTiersRequest, PairIdsRequest, PoolIdRequest, ProgramIdRequest, ResolvePoolRequest,
SwapExactInPlanRequest, SwapExactInQuoteRequest, SwapExactOutPlanRequest,
SwapExactOutQuoteRequest, SwapPairRequest, TokenIdsRequest, WalletAccount,
program_id, resolve_pool, resolve_tokens, swap_exact_in_plan, swap_exact_in_quote,
swap_exact_out_plan, swap_exact_out_quote, swap_pair, token_ids, AccountRead, AmmApiError,
AmmResponse, AmmResult, ConfigIdRequest, ContextRequest, CreatePoolPlanRequest,
CreatePoolQuoteRequest, FeeTiersRequest, PairIdsRequest, PoolIdRequest, ProgramIdRequest,
ResolvePoolRequest, ResolveTokensRequest, SwapExactInPlanRequest, SwapExactInQuoteRequest,
SwapExactOutPlanRequest, SwapExactOutQuoteRequest, SwapPairRequest, TokenIdsRequest,
WalletAccount,
};
+56
View File
@@ -1328,6 +1328,62 @@ LogosList AmmModuleImpl::feeTiers() {
return out;
}
LogosList AmmModuleImpl::resolveTokens(const LogosMap& request, bool wallet_open) {
const std::string amm_program_id = ammProgramId();
if (amm_program_id.empty())
return LogosList::array();
// The config gives the token_program_id the FFI needs to decode definitions/holdings.
const FfiResult configResult =
call(amm_config_id, json{{"ammProgramId", amm_program_id}});
if (!configResult.ok)
return LogosList::array();
const json config = readPublicAccount(jStr(configResult.value, "configId"));
// Normalize the app-provided ids (base58 or hex) → hex, de-dup, and read each
// definition account. The FFI is stateless, so it gets the reads pre-fetched.
const auto token_ids_it = request.find("tokenIds");
const json token_ids = (token_ids_it != request.end() && token_ids_it->is_array())
? *token_ids_it
: json::array();
std::vector<std::string> ids_vec;
ids_vec.reserve(token_ids.size());
for (const auto& raw : token_ids) {
if (!raw.is_string()) continue;
const std::string hex = normalizeAccountId(raw.get<std::string>());
if (!hex.empty()) ids_vec.push_back(hex);
}
std::sort(ids_vec.begin(), ids_vec.end());
ids_vec.erase(std::unique(ids_vec.begin(), ids_vec.end()), ids_vec.end());
json ids = json::array();
json definitions = json::array();
for (const auto& hex : ids_vec) {
ids.push_back(hex);
definitions.push_back(readPublicAccount(hex));
}
// Fresh wallet read — the selector wants current holdings/balances.
const json wallet_accounts = walletAccountReads(wallet_open, /*refresh=*/true);
const FfiResult result = call(amm_resolve_tokens, json{
{"ammProgramId", amm_program_id},
{"config", config},
{"tokenIds", ids},
{"walletAccounts", wallet_accounts},
{"tokenDefinitions", definitions},
});
if (!result.ok)
return LogosList::array();
LogosList out = LogosList::array();
const auto it = result.value.find("tokens");
if (it != result.value.end() && it->is_array())
for (const auto& row : *it) out.push_back(row);
return out;
}
LogosMap AmmModuleImpl::newPositionContext(const LogosMap& request,
bool wallet_open,
bool refresh_wallet_accounts) {
+14
View File
@@ -217,6 +217,20 @@ public:
/// TOKENS_CONFIG is unset / unreadable / not a JSON array.
LogosList tokenList();
/// Resolves an app-provided set of token ids into liquidity selector rows.
/// `request` carries `{ tokenIds: [<definition id>, …] }` (base58 or hex,
/// normalized to hex here) — the app owns the set: its configured tokens plus any
/// custom/pasted ids it remembers (held-but-unlisted tokens are not auto-added by
/// the app). Reads each definition and (when `wallet_open`) the wallet, then returns
/// `[{ definitionId (base58), name, totalSupply, holdingId, balance }]`. Every
/// row has the same fields — a token the wallet doesn't hold gets `holdingId:""`
/// and `balance:"0"` — held tokens first. A requested id whose definition is
/// unreadable / non-fungible is omitted (the app treats a missing row as
/// unresolved). Empty list if AMM_PROGRAM_BIN is unset or the config read fails.
/// (`tokenIds` is wrapped in a map, not passed as a bare list, because the
/// universal-module glue only supports map/scalar inputs.)
LogosList resolveTokens(const LogosMap& request, bool wallet_open);
/// New-position (add-liquidity) view state: reads the AMM config + the
/// user's wallet accounts and returns the new-position context map the
/// UI renders (available tokens, fee tiers, warnings). `wallet_open` gates