feat(apps/amm): drive the Pools list from AMM_POOLS_CONFIG

The Pools page shipped with a hardcoded four-pair sample. Replace it with a
config-driven "known pools" list, mirroring how the Swap token picker reads
TOKENS_CONFIG: the app loads a flat JSON array from the AMM_POOLS_CONFIG
environment variable and renders one row per entry. Adding pairs is a config
edit — no app change.

Pool discovery is an app concern, so the config is read in the backend
(AmmUiBackend::poolList, Qt JSON) rather than the amm_module — the module is
shedding app-specific view surface (tokenList/newPositionContext), so pools go
where tokens are heading, not where they are today. poolList() fails soft to an
empty list when AMM_POOLS_CONFIG is unset/unreadable/not an array, and skips
individual entries missing tokenA/tokenB/a numeric feeBps.

Each entry carries the display symbols (tokenA/tokenB), feeBps, and the on-chain
identifiers (poolId, tokenADefinitionId, tokenBDefinitionId) so a row can later
be resolved against chain state. PoolsPage takes injected backend/runtime and
loads via runtime.watch(backend.poolList()); the Repeater renders entries
generically.

The AMM testnet setup script now emits amm-pools.json from a POOL_SPECS array
(one line per seeded pool, currently the seeded TKA/TKB pool) and prints
AMM_POOLS_CONFIG in the launch instructions. Adds amm-pools.json.example, a
README section, and gitignores the runtime config files.
This commit is contained in:
Ricardo Guilherme Schmidt
2026-08-13 14:11:47 +02:00
committed by r4bbit
parent c47f387ad4
commit cdedb49609
12 changed files with 580 additions and 9 deletions
+6
View File
@@ -6,6 +6,9 @@ modules/
# your own definitionId/holding accounts. Never commit your real accounts.
amm-tokens.json
# Personal known-pools config (AMM_POOLS_CONFIG). Same rationale as above.
amm-pools.json
# Screenshot artifacts written by tests/swap.mjs
tests/*.png
@@ -14,3 +17,6 @@ tests/testnet/.wallet/
# Isolated token config written by tests/testnet/setup-amm-testnet.sh
tests/testnet/amm-tokens.json
# Isolated known-pools config written by tests/testnet/setup-amm-testnet.sh
tests/testnet/amm-pools.json
+34 -1
View File
@@ -211,11 +211,44 @@ picker stays empty (a `qWarning` naming the exact cause is logged to stderr; no
swap can be started). `definitionId`/`holding` may be given as base58 (as the
wallet/runbook display them) or hex — the app normalizes both to hex.
Full command with both variables set (absolute paths, from the repo root):
### Known-pools config (optional, for the Pools list)
The Pools view is config-driven the same way: it reads a flat JSON list from the
`AMM_POOLS_CONFIG` environment variable (absolute path) and renders one row per
entry. `tokenA`/`tokenB` are the display symbols and `feeBps` the fee tier;
`poolId`/`tokenADefinitionId`/`tokenBDefinitionId` identify the pool on-chain.
Adding more pairs is purely a config edit — no app change:
```json
[
{
"tokenA": "TKA",
"tokenB": "TKB",
"feeBps": 1,
"poolId": "9qbX…",
"tokenADefinitionId": "4T69…",
"tokenBDefinitionId": "7Zc2…"
}
]
```
Copy the checked-in template to start (`amm-pools.json` is git-ignored):
```bash
cp apps/amm/amm-pools.json.example apps/amm/amm-pools.json # then replace the REPLACE_… placeholders
```
If `AMM_POOLS_CONFIG` is unset, unreadable, or not a valid JSON array, the Pools
list shows its empty state. Entries missing `tokenA`, `tokenB`, or a numeric
`feeBps` are skipped individually. The AMM testnet setup script writes this file
for the pool(s) it seeds (see below).
Full command with the variables set (absolute paths, from the repo root):
```bash
AMM_PROGRAM_BIN=$(pwd)/programs/amm/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/amm.bin \
TOKENS_CONFIG=$(pwd)/apps/amm/amm-tokens.json \
AMM_POOLS_CONFIG=$(pwd)/apps/amm/amm-pools.json \
nix run .#amm-ui
```
+1 -1
View File
@@ -19,7 +19,7 @@ amm_import="$amm_qml/lib/qml"
test -f "$amm_import/Logos/Wallet/qmldir"
"$qt_qml/bin/qmltestrunner" -import "$qt_qml/lib/qt-6/qml" -import "$logos_qml/lib" -import "$amm_import" -input apps/shared/wallet/tests/qml
"$qt_qml/bin/qmltestrunner" -import "$qt_qml/lib/qt-6/qml" -import "$logos_qml/lib" -import "$amm_import" -input apps/amm/tests/qml
"$qt_qml/bin/qmllint" -I "$qt_qml/lib/qt-6/qml" -I "$logos_qml/lib" -I "$amm_qml/lib" -I "$amm_import" apps/amm/qml/pages/LiquidityPage.qml apps/amm/qml/components/liquidity/NewPositionForm.qml apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml
"$qt_qml/bin/qmllint" -I "$qt_qml/lib/qt-6/qml" -I "$logos_qml/lib" -I "$amm_qml/lib" -I "$amm_import" apps/amm/qml/pages/LiquidityPage.qml apps/amm/qml/pages/PoolsPage.qml apps/amm/qml/components/liquidity/NewPositionForm.qml apps/amm/qml/components/liquidity/LiquidityConfirmationSummary.qml
```
When shared AMM program types or instruction signatures change, also run the
+10
View File
@@ -0,0 +1,10 @@
[
{
"tokenA": "TKA",
"tokenB": "TKB",
"feeBps": 1,
"poolId": "REPLACE_WITH_POOL_PDA",
"tokenADefinitionId": "REPLACE_WITH_TOKEN_A_DEFINITION_ID",
"tokenBDefinitionId": "REPLACE_WITH_TOKEN_B_DEFINITION_ID"
}
]
+7
View File
@@ -92,5 +92,12 @@ Item {
runtime: logos
visible: navbar.currentIndex === 1
}
PoolsPage {
anchors.fill: parent
backend: root.ready ? root.backend : null
runtime: logos
visible: navbar.currentIndex === 2
}
}
}
+42 -7
View File
@@ -1,3 +1,5 @@
pragma ComponentBehavior: Bound
import QtQuick 2.15
import QtQuick.Layouts 1.15
@@ -10,7 +12,7 @@ Item {
id: root
property int currentIndex: 0
readonly property var tabs: ["Trade", "Liquidity"]
readonly property var tabs: [qsTr("Trade"), qsTr("Liquidity"), qsTr("Pools")]
// Wallet wiring, passed down from Main.qml.
property var backend: null
@@ -44,7 +46,7 @@ Item {
// App identity
Text {
text: "Logos AMM"
text: qsTr("Logos AMM")
color: Theme.palette.text
font.pixelSize: 17
font.weight: Font.Bold
@@ -54,28 +56,61 @@ Item {
// Tab pills
Row {
Accessible.role: Accessible.PageTabList
Accessible.name: qsTr("Primary navigation")
spacing: 4
Repeater {
model: root.tabs
delegate: Rectangle {
id: tabButton
required property int index
required property string modelData
readonly property bool active: root.currentIndex === index
height: 36
width: tabLabel.implicitWidth + 28
radius: 18
color: active ? Theme.palette.backgroundSecondary : "transparent"
border.width: activeFocus ? 1 : 0
border.color: Theme.palette.text
activeFocusOnTab: true
Accessible.role: Accessible.PageTab
Accessible.name: tabLabel.text
function activate() {
root.currentIndex = index
root.tabChanged(index)
}
Behavior on color { ColorAnimation { duration: 150 } }
Keys.onReturnPressed: function(event) {
tabButton.activate()
event.accepted = true
}
Keys.onEnterPressed: function(event) {
tabButton.activate()
event.accepted = true
}
Keys.onSpacePressed: function(event) {
tabButton.activate()
event.accepted = true
}
Text {
id: tabLabel
anchors.centerIn: parent
text: modelData
color: active ? Theme.palette.text : Theme.palette.textSecondary
text: tabButton.modelData
color: tabButton.active ? Theme.palette.text : Theme.palette.textSecondary
font.pixelSize: 14
font.weight: active ? Font.Medium : Font.Normal
font.weight: tabButton.active ? Font.Medium : Font.Normal
Behavior on color { ColorAnimation { duration: 150 } }
}
@@ -84,8 +119,8 @@ Item {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
root.currentIndex = index
root.tabChanged(index)
tabButton.forceActiveFocus()
tabButton.activate()
}
}
}
+280
View File
@@ -0,0 +1,280 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import "../components/liquidity"
import "../components/swap/TokenVisuals.js" as TokenVisuals
Item {
id: root
// Real backend replica (logos.module("amm_ui")) and the watch runtime,
// wired from Main.qml. Null until the app is ready.
property var backend: null
property var runtime: null
readonly property int pageMargin: width < 640 ? 16 : 24
readonly property int contentMaxWidth: 760
readonly property int poolCount: root.pools ? root.pools.length : 0
readonly property bool showEmptyState: root.poolCount === 0
// Config-driven known pools, loaded from AmmUiBackend::poolList() (which
// reads the AMM_POOLS_CONFIG JSON file). Each entry is rendered generically,
// so adding pairs to the config needs no change here. Empty until the
// backend is ready and the call resolves.
property var pools: []
function loadPools() {
if (!root.backend || !root.runtime)
return
root.runtime.watch(root.backend.poolList(),
function(list) { root.pools = list },
function(err) { console.warn("poolList error:", err) })
}
onBackendChanged: root.loadPools()
onRuntimeChanged: root.loadPools()
AmmTheme {
id: theme
}
function feeLabel(feeBps) {
var percentage = Number(feeBps) / 100
return qsTr("%1%").arg(percentage.toLocaleString(Qt.locale(), "f", 2))
}
Rectangle {
anchors.fill: parent
color: theme.colors.background
}
Flickable {
id: scroll
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: Math.max(height, pageContent.y + pageContent.implicitHeight
+ root.pageMargin)
flickableDirection: Flickable.VerticalFlick
boundsBehavior: Flickable.StopAtBounds
ScrollBar.vertical: ScrollBar {
policy: ScrollBar.AsNeeded
}
Item {
id: pageContent
x: Math.max(root.pageMargin, (scroll.width - width) / 2)
y: root.width < 640 ? 24 : 40
width: Math.max(0, Math.min(root.contentMaxWidth,
scroll.width - root.pageMargin * 2))
implicitHeight: contentColumn.implicitHeight
Column {
id: contentColumn
width: parent.width
spacing: 24
Column {
width: parent.width
spacing: 6
Text {
text: qsTr("Pools")
color: theme.colors.textPrimary
font.pixelSize: 30
font.weight: Font.Bold
}
Text {
text: qsTr("Pools")
color: theme.colors.textSecondary
font.pixelSize: 13
}
}
Rectangle {
id: poolsList
objectName: "poolsList"
width: parent.width
implicitHeight: !root.showEmptyState
? listContent.implicitHeight : 144
color: theme.colors.cardBg
radius: 16
border.color: theme.colors.border
border.width: 1
Column {
id: listContent
width: parent.width
visible: !root.showEmptyState
Item {
width: parent.width
height: 48
Text {
anchors.left: parent.left
anchors.leftMargin: 20
anchors.verticalCenter: parent.verticalCenter
text: qsTr("Pair")
color: theme.colors.textSecondary
font.pixelSize: 12
font.weight: Font.DemiBold
}
Text {
anchors.right: parent.right
anchors.rightMargin: 20
anchors.verticalCenter: parent.verticalCenter
text: qsTr("Fee")
color: theme.colors.textSecondary
font.pixelSize: 12
font.weight: Font.DemiBold
}
}
Rectangle {
width: parent.width
height: 1
color: theme.colors.divider
}
Repeater {
model: root.pools || []
delegate: PoolRow {
width: listContent.width
showDivider: index < root.poolCount - 1
objectName: "poolRow%1".arg(index)
}
}
}
Text {
id: emptyState
objectName: "poolsListEmptyState"
anchors.centerIn: parent
width: parent.width - 40
visible: root.showEmptyState
text: qsTr("No pools configured.")
color: theme.colors.textSecondary
font.pixelSize: 14
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.Wrap
}
}
}
}
}
component PoolRow: Item {
id: row
required property var modelData
required property int index
property bool showDivider: false
readonly property var pool: modelData
readonly property string pairText: qsTr("%1 / %2")
.arg(String(pool.tokenA || ""))
.arg(String(pool.tokenB || ""))
readonly property string feeText: root.feeLabel(pool.feeBps)
height: 68
RowLayout {
anchors.fill: parent
anchors.leftMargin: 20
anchors.rightMargin: 20
spacing: 12
Item {
Layout.preferredWidth: 46
Layout.preferredHeight: 30
Accessible.ignored: true
TokenAvatar {
x: 0
y: 1
symbol: String(row.pool.tokenA || "")
z: 1
}
TokenAvatar {
x: 18
y: 1
symbol: String(row.pool.tokenB || "")
}
}
Text {
Layout.fillWidth: true
text: row.pairText
color: theme.colors.textPrimary
font.pixelSize: 16
font.weight: Font.Medium
elide: Text.ElideRight
}
Rectangle {
Layout.preferredWidth: feeText.implicitWidth + 20
Layout.preferredHeight: 30
radius: 6
color: theme.colors.inputBg
border.color: theme.colors.borderStrong
border.width: 1
Text {
id: feeText
anchors.centerIn: parent
text: row.feeText
color: theme.colors.textPrimary
font.pixelSize: 13
font.weight: Font.Medium
}
}
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
visible: row.showDivider
height: 1
color: theme.colors.divider
}
}
component TokenAvatar: Rectangle {
id: avatar
required property string symbol
width: 28
height: 28
radius: 14
color: TokenVisuals.colorFor(symbol)
border.color: theme.colors.cardBg
border.width: 2
Accessible.ignored: true
Text {
anchors.centerIn: parent
text: TokenVisuals.letterFor(avatar.symbol)
color: "#FFFFFF"
font.pixelSize: 10
font.weight: Font.Bold
Accessible.ignored: true
}
}
}
+66
View File
@@ -1,6 +1,10 @@
#include "AmmUiBackend.h"
#include <QDebug>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QTimer>
#include "LogosWalletProvider.h"
@@ -9,6 +13,59 @@
#include "logos_sdk.h"
namespace {
// Absolute path to the JSON known-pools config consumed by poolList().
// Mirrors TOKENS_CONFIG for the token list; produced by the AMM testnet
// setup script (apps/amm/tests/testnet/setup-amm-testnet.sh).
constexpr char POOLS_CONFIG_ENV[] = "AMM_POOLS_CONFIG";
// Parses the AMM_POOLS_CONFIG JSON file into the QVariantList the Pools UI
// renders. Fails soft (empty list) when the env var is unset, the file is
// unreadable, or the payload is not a JSON array — one malformed entry is
// skipped rather than dropping the whole list. tokenA/tokenB (display
// symbols) and a numeric feeBps are required; the id fields pass through
// when present so the entry can later be resolved on-chain.
QVariantList readPoolsConfig()
{
QVariantList out;
const QString path = qEnvironmentVariable(POOLS_CONFIG_ENV);
if (path.isEmpty())
return out;
QFile file(path);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
return out;
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
if (!doc.isArray())
return out;
for (const QJsonValue& entry : doc.array()) {
if (!entry.isObject())
continue;
const QJsonObject obj = entry.toObject();
const QString tokenA = obj.value(QStringLiteral("tokenA")).toString();
const QString tokenB = obj.value(QStringLiteral("tokenB")).toString();
const QJsonValue feeBps = obj.value(QStringLiteral("feeBps"));
if (tokenA.isEmpty() || tokenB.isEmpty() || !feeBps.isDouble())
continue;
QVariantMap pool;
pool.insert(QStringLiteral("tokenA"), tokenA);
pool.insert(QStringLiteral("tokenB"), tokenB);
pool.insert(QStringLiteral("feeBps"), feeBps.toInt());
pool.insert(QStringLiteral("poolId"),
obj.value(QStringLiteral("poolId")).toString());
pool.insert(QStringLiteral("tokenADefinitionId"),
obj.value(QStringLiteral("tokenADefinitionId")).toString());
pool.insert(QStringLiteral("tokenBDefinitionId"),
obj.value(QStringLiteral("tokenBDefinitionId")).toString());
out.append(pool);
}
return out;
}
// The new-position context placeholder published before the module
// connection is up (matches the module's "loading" contextState).
QVariantMap loadingContext()
@@ -249,6 +306,15 @@ QVariantList AmmUiBackend::tokenHoldings()
return m_logos->amm_module.tokenHoldings(isWalletOpen());
}
QVariantList AmmUiBackend::poolList()
{
// Config-driven known pools. Read straight from AMM_POOLS_CONFIG on every
// call (the UI fetches this once on load); adding more pairs is a config
// edit, no app change. Pool discovery is an app concern, so this stays in
// the backend rather than the amm_module.
return readPoolsConfig();
}
QVariantMap AmmUiBackend::createPool(QVariantMap request)
{
// Same connected-state submit guard as the swaps — this app's lock is
+3
View File
@@ -82,6 +82,9 @@ public slots:
QVariantMap addLiquidity(QVariantMap request) override;
// Lists the wallet's fungible token holdings for the account selector.
QVariantList tokenHoldings() override;
// Reads the known-pools list from AMM_POOLS_CONFIG (app config JSON, read
// here rather than in the amm_module — pool discovery is an app detail).
QVariantList poolList() override;
private:
void syncWalletState();
+7
View File
@@ -137,4 +137,11 @@ class AmmUiBackend
// definitionIdHex, balanceRaw }] — one row per holding account, every token,
// including zero-balance holdings. The selector narrows to a token by id.
SLOT(QVariantList tokenHoldings())
// Reads the known-pools config at AMM_POOLS_CONFIG (absolute path, JSON array
// of { tokenA, tokenB, feeBps, poolId, tokenADefinitionId, tokenBDefinitionId })
// and returns it as a QVariantList of QVariantMap entries. tokenA/tokenB are
// display symbols; the id fields identify the pool on-chain. Returns an empty
// list if AMM_POOLS_CONFIG is unset/unreadable/invalid. This is app config, not
// an on-chain read, so it lives in the backend rather than the amm_module.
SLOT(QVariantList poolList())
}
+82
View File
@@ -0,0 +1,82 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtTest
import "../../qml/pages" as Pages
TestCase {
id: testCase
name: "PoolsPage"
Component {
id: backendComponent
QtObject {
property var poolListResult: [
{ "tokenA": "TKA", "tokenB": "TKB", "feeBps": 5 },
{ "tokenA": "TKC", "tokenB": "TKA", "feeBps": 30 }
]
function poolList() {
return poolListResult
}
}
}
Component {
id: runtimeComponent
QtObject {
function watch(value, succeeded, failed) {
if (value === undefined)
return
succeeded(value)
}
}
}
Component {
id: pageComponent
Pages.PoolsPage {
visible: false
width: 800
height: 600
}
}
function test_rowsAreDrivenByBackendPoolList() {
var backend = createTemporaryObject(backendComponent, testCase)
var runtime = createTemporaryObject(runtimeComponent, testCase)
var page = createTemporaryObject(pageComponent, testCase, {
"backend": backend,
"runtime": runtime
})
verify(backend)
verify(runtime)
verify(page)
// The config drives the row count — no pair is hardcoded in the page,
// so adding entries to poolList() is all it takes to render more rows.
compare(page.poolCount, 2)
verify(page.feeLabel(5).endsWith("%"))
var list = findChild(page, "poolsList")
var firstRow = findChild(page, "poolRow0")
verify(list)
verify(firstRow)
compare(firstRow.pairText, "TKA / TKB")
compare(firstRow.feeText, page.feeLabel(5))
}
function test_emptyPoolModelUsesTheEmptyState() {
var page = createTemporaryObject(pageComponent, testCase)
verify(page)
// No backend wired: pools defaults to [] and the empty state shows.
compare(page.poolCount, 0)
compare(page.showEmptyState, true)
}
}
@@ -100,6 +100,11 @@ POOL_DEADLINE="18446744073709551615"
# TOKENS_CONFIG when launching the UI for a test run.
TOKENS_CONFIG_OUT="apps/amm/tests/testnet/amm-tokens.json"
# Where the UI known-pools config is written (git-ignored, tests only). Pass this
# path as AMM_POOLS_CONFIG when launching the UI; the Pools page renders one row
# per entry. More seeded pools = more entries here, no app change.
POOLS_CONFIG_OUT="apps/amm/tests/testnet/amm-pools.json"
###############################################################################
# Helpers
###############################################################################
@@ -423,6 +428,42 @@ cat > "$TOKENS_CONFIG_OUT" <<JSON
JSON
kv "wrote" "$TOKENS_CONFIG_OUT"
###############################################################################
# 11. Write the UI known-pools config from the seeded pool(s)
###############################################################################
sec "Write UI pools config -> $POOLS_CONFIG_OUT"
# One row per seeded pool: "SYMBOL_A SYMBOL_B FEE_BPS POOL_ID DEF_A DEF_B".
# Add a line here for each new seeded pool — nothing else (script or app) needs
# to change; the Pools page renders one row per entry generically.
POOL_SPECS=(
"$TOKEN_A_SYMBOL $TOKEN_B_SYMBOL $POOL_FEES $POOL $TOKEN_A_DEF $TOKEN_B_DEF"
)
pool_entry() {
cat <<JSON
{
"tokenA": "$1",
"tokenB": "$2",
"feeBps": $3,
"poolId": "$4",
"tokenADefinitionId": "$5",
"tokenBDefinitionId": "$6"
}
JSON
}
{
echo "["
for i in "${!POOL_SPECS[@]}"; do
[ "$i" -gt 0 ] && echo " ,"
# shellcheck disable=SC2086 # deliberate word-split of the spec into fields
set -- ${POOL_SPECS[$i]}
pool_entry "$1" "$2" "$3" "$4" "$5" "$6"
done
echo "]"
} > "$POOLS_CONFIG_OUT"
kv "wrote" "$POOLS_CONFIG_OUT"
sec "Done"
log "${GRN}✅ Setup complete.${RST}"
kv "AMM program id" "$AMM_PID"
@@ -433,6 +474,7 @@ log "Launch the UI against the ISOLATED test wallet + test token config:"
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} nix run .#amm-ui${RST}"
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)"