feat(wallet): humanize shared wallet experience

Consolidate wallet account decoding and portfolio handling around the shared Rust IDL decoder. Simplify AMM wallet integration, remove obsolete caches and network plumbing, and cover account selection and live flows.
This commit is contained in:
Ricardo Guilherme Schmidt
2026-08-21 17:38:45 -03:00
parent 62d133e909
commit 8c3e6fccfe
64 changed files with 6268 additions and 27501 deletions
+18 -16
View File
@@ -4,15 +4,15 @@ UI-driven tests for the AMM app, driving the running app through the QML
inspector (framework from
[`logos-co/logos-qt-mcp`](https://github.com/logos-co/logos-qt-mcp)):
- `swap.mjs` selects two tokens, enters an amount, submits a swap, and verifies
- `e2e/swap.mjs` selects two tokens, enters an amount, submits a swap, and verifies
the **A/B** pool reserves changed **on-chain**.
- `create-pool.mjs` selects the **A/C** pair (which the setup script leaves
- `e2e/create-pool.mjs` selects the **A/C** pair (which the setup script leaves
unseeded — only A/B is created), submits a pool creation, and verifies the A/C
pool now exists **on-chain**.
- `add-liquidity.mjs` selects the seeded **A/B** pair, asserts the CTA stays
- `e2e/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
- `e2e/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.
@@ -51,19 +51,19 @@ TEST_SEQUENCER_ADDR=http://127.0.0.1:3040 apps/amm/tests/testnet/setup-amm-testn
# (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 \
AMM_PROGRAM_BIN=$(pwd)/target/guest/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
node apps/amm/tests/e2e/swap.mjs # swap against the seeded A/B pool
node apps/amm/tests/e2e/create-pool.mjs # create the (unseeded) A/C pool
node apps/amm/tests/e2e/add-liquidity.mjs # add liquidity to the seeded A/B pool
node apps/amm/tests/e2e/custom-token.mjs # add token D (unlisted) by id
```
Headless CI variant (no window, launches the app itself, pass/fail only):
Hermetic headless smoke test (no wallet or sequencer required):
```bash
nix build .#integration-test -L
@@ -83,16 +83,18 @@ nix build .#integration-test -L
re-restores the isolated `.wallet` (rewrites only that directory).
- **Overrides.** `TEST_WALLET_HOME`, `TEST_MNEMONIC`, `TEST_WALLET_PASSWORD`,
`TEST_WALLET_DEPTH`, `TEST_SEQUENCER_ADDR` — see the script header.
- **Framework location.** `swap.mjs` loads `../result-mcp` by default; override
- **Framework location.** `e2e/swap.mjs` loads `../../result-mcp` by default; override
with `LOGOS_QT_MCP=/abs/path/to/result-mcp`.
- **Artifacts.** On failure `swap.mjs` prints the `SwapCard` state and saves
`apps/amm/tests/swap-*.png` (git-ignored) for inspection.
- **Artifacts.** On failure `e2e/swap.mjs` prints the `SwapCard` state and saves
`apps/amm/tests/e2e/swap-*.png` (git-ignored) for inspection.
## Files
- `swap.mjs` — the end-to-end swap UI test (A/B pool).
- `create-pool.mjs` — the end-to-end create-pool UI test (creates the A/C pool).
- `add-liquidity.mjs` — the end-to-end add-liquidity UI test (adds to the A/B pool).
- `ui-smoke.mjs` — the hermetic headless UI integration test.
- `e2e/swap.mjs` — the end-to-end swap UI test (A/B pool).
- `e2e/create-pool.mjs` — the end-to-end create-pool UI test (creates the A/C pool).
- `e2e/add-liquidity.mjs` — the end-to-end add-liquidity UI test (adds to the A/B pool).
- `e2e/custom-token.mjs` — the custom-token persistence test.
- `testnet/setup-amm-testnet.sh` — isolated testnet + wallet bootstrap (TKA/TKB/TKC,
seeds the A/B pool only).
- `qml/`, `cpp/` — the module's own QML/C++ unit tests.
@@ -18,13 +18,13 @@ import { readFile, writeFile } from "node:fs/promises";
const fwRoot =
process.env.LOGOS_QT_MCP ||
new URL("../result-mcp", import.meta.url).pathname;
new URL("../../result-mcp", import.meta.url).pathname;
const { test, run } = await import(resolve(fwRoot, "test-framework/framework.mjs"));
// The token config the app was launched with (same file the setup script writes).
const TOKENS_CONFIG =
process.env.TOKENS_CONFIG ||
new URL("./testnet/amm-tokens.json", import.meta.url).pathname;
new URL("../testnet/amm-tokens.json", import.meta.url).pathname;
// Deposit added for token A (raw base units — the form treats amounts as raw). The
// seeded A/B pool is 10000/10000, so 1000 mints a nonzero LP and is trivially funded.
@@ -18,13 +18,13 @@ import { readFile, writeFile } from "node:fs/promises";
const fwRoot =
process.env.LOGOS_QT_MCP ||
new URL("../result-mcp", import.meta.url).pathname;
new URL("../../result-mcp", import.meta.url).pathname;
const { test, run } = await import(resolve(fwRoot, "test-framework/framework.mjs"));
// The token config the app was launched with (same file the setup script writes).
const TOKENS_CONFIG =
process.env.TOKENS_CONFIG ||
new URL("./testnet/amm-tokens.json", import.meta.url).pathname;
new URL("../testnet/amm-tokens.json", import.meta.url).pathname;
// --- small helpers (mirrors swap.mjs) --------------------------------------
@@ -26,14 +26,14 @@ import { execFileSync } from "node:child_process";
const fwRoot =
process.env.LOGOS_QT_MCP ||
new URL("../result-mcp", import.meta.url).pathname;
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;
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
@@ -42,7 +42,7 @@ const WALLET_HOME =
// 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;
new URL("../testnet/custom-tokens.json", import.meta.url).pathname;
// --- small helpers (mirror create-pool.mjs) --------------------------------
@@ -21,7 +21,7 @@ import { writeFile } from "node:fs/promises";
// Override with LOGOS_QT_MCP=/abs/path/to/result-mcp if it lives elsewhere.
const fwRoot =
process.env.LOGOS_QT_MCP ||
new URL("../result-mcp", import.meta.url).pathname;
new URL("../../result-mcp", import.meta.url).pathname;
const { test, run } = await import(resolve(fwRoot, "test-framework/framework.mjs"));
const SELL_AMOUNT = "100";
@@ -22,8 +22,9 @@ TestCase {
var summary = createTemporaryObject(summaryComponent, testCase)
verify(summary)
compare(summary.actionText("NewDefinition"), "Create pool")
compare(summary.actionText("AddLiquidity"), "Add liquidity")
compare(summary.actionText(""), "-")
summary.snapshot = { "poolExists": false }
compare(summary.actionText(), "Create pool")
summary.snapshot = { "poolExists": true }
compare(summary.actionText(), "Add liquidity")
}
}
+67 -121
View File
@@ -22,7 +22,8 @@ TestCase {
Liquidity.NewPositionForm {
visible: false
width: 760
newPositionContext: testCase.readyContext()
tokens: testCase.readyTokens()
walletReady: true
}
}
@@ -37,72 +38,68 @@ TestCase {
signalName: "quoteRequested"
}
function readyContext() {
return {
"status": "ready",
"tokens": [
{
"definitionId": tokenLow,
"name": "Low",
"totalSupply": "1000000",
"balanceRaw": "1000",
"selectable": true
},
{
"definitionId": tokenHigh,
"name": "High",
"totalSupply": "1000000000000",
"balanceRaw": "5000000000",
"selectable": true
}
]
}
function readyTokens() {
return [
{
"definitionId": tokenLow,
"name": "Low",
"totalSupply": "1000000",
"balance": "1000"
},
{
"definitionId": tokenHigh,
"name": "High",
"totalSupply": "1000000000000",
"balance": "5000000000"
}
]
}
function rawAmountsContext() {
return {
"status": "ready",
"tokens": [
{
"definitionId": tokenLow,
"name": "Sir Mints-a-Lot",
"totalSupply": "1000000000000",
"balanceRaw": "1000000000",
"selectable": true
},
{
"definitionId": tokenHigh,
"name": "Aurora",
"totalSupply": "1000000000000",
"balanceRaw": "1000000000",
"selectable": true
}
]
}
function rawAmountTokens() {
return [
{
"definitionId": tokenLow,
"name": "Sir Mints-a-Lot",
"totalSupply": "1000000000000",
"balance": "1000000000"
},
{
"definitionId": tokenHigh,
"name": "Aurora",
"totalSupply": "1000000000000",
"balance": "1000000000"
}
]
}
function flowState(quote) {
return {
function flowState(quote, poolExists) {
var state = {
"quote": quote || ({}),
"contextLoading": false,
"quoteLoading": false,
"quoteStale": false,
"submitting": false
}
if (poolExists !== undefined)
state.poolExists = poolExists
else if (quote && quote.poolStatus === "active_pool")
state.poolExists = true
else if (quote && quote.poolStatus === "missing_pool")
state.poolExists = false
return state
}
function createEmptyForm(context) {
function createEmptyForm(tokens) {
var form = createTemporaryObject(formComponent, testCase, {
"flowState": flowState(({})),
"newPositionContext": context || readyContext()
"tokens": tokens || readyTokens()
})
verify(form)
wait(0)
return form
}
function createForm(context) {
var form = createEmptyForm(context)
function createForm(tokens) {
var form = createEmptyForm(tokens)
form.selectToken("A", tokenLow)
form.selectToken("B", tokenHigh)
compare(form.selectedTokenAId, tokenLow)
@@ -175,7 +172,7 @@ TestCase {
}
function test_missingPoolAcceptsLargeDirectAmountsFromEitherSide() {
var form = createForm(rawAmountsContext())
var form = createForm(rawAmountTokens())
form.priceAmountA = "15"
form.priceAmountB = "10"
form.flowState = flowState({
@@ -210,7 +207,7 @@ TestCase {
}
function test_missingPoolRoundsPairedRawAmounts() {
var form = createForm(rawAmountsContext())
var form = createForm(rawAmountTokens())
form.priceAmountA = "15"
form.priceAmountB = "10"
form.flowState = flowState({
@@ -378,7 +375,6 @@ TestCase {
compare(form.fieldError("amountB"), "")
compare(form.formErrorText(), "")
compare(form.accountPreview().length, 0)
}
function test_activePoolEditUsesDisplayReserveRatio() {
@@ -421,6 +417,8 @@ TestCase {
function test_activePoolEditRecoversAfterInvalidQuote() {
var form = createForm()
form.flowState = flowState(({}), true)
wait(0)
form.flowState = flowState({
"status": "ok",
"tokenAId": tokenHigh,
@@ -441,7 +439,7 @@ TestCase {
"code": "value_must_be_positive",
"tokenAId": tokenHigh,
"tokenBId": tokenLow
})
}, true)
wait(0)
compare(form.poolFeeBps, 30)
@@ -485,7 +483,7 @@ TestCase {
"code": "fee_tier_mismatch",
"details": { "poolFeeBps": "5" }
}]
})
}, true)
wait(0)
compare(form.selectedFeeBps, 5)
@@ -497,17 +495,12 @@ TestCase {
compare(quoteRequestedSpy.signalArguments[0][1].request.maxAmountB, "1000")
}
function test_contextFailureFinishesTokenResolution() {
function test_tokenResolutionFailureClearsPendingState() {
var form = createForm()
form.resolvingTokenId = tokenThird
form.resolvingTokenSide = "A"
form.newPositionContext = {
"status": "error",
"code": "config_unavailable",
"tokens": []
}
form.finishTokenResolution(true)
form.failTokenResolution("config_unavailable")
wait(0)
compare(form.resolvingTokenId, "")
@@ -515,53 +508,27 @@ TestCase {
compare(form.tokenResolutionError, form.issueText("config_unavailable"))
}
function test_staleContextDoesNotFinishNewerTokenResolution() {
var form = createForm()
form.resolvingTokenId = tokenThird
form.resolvingTokenSide = "B"
form.newPositionContext = {
"status": "ready",
"tokens": [{
"definitionId": tokenLow,
"name": "Earlier token",
"selectable": true
}]
}
wait(0)
compare(form.resolvingTokenId, tokenThird)
compare(form.resolvingTokenSide, "B")
compare(form.tokenResolutionError, "")
}
function test_replacingSelectedTokensClearsPairDraft() {
var form = createForm()
form.amountA = "12"
form.amountB = "34"
form.minimumAmountA = "12"
form.minimumAmountB = "34"
form.confirmedPoolStatus = "active_pool"
form.newPositionContext = {
"status": "ready",
"tokens": [
{
"definitionId": tokenHigh,
"name": "High",
"totalSupply": "1000000000000",
"balanceRaw": "5000000000",
"selectable": true
},
{
"definitionId": tokenThird,
"name": "Third",
"totalSupply": "1000000",
"balanceRaw": "100",
"selectable": true
}
]
}
form.tokens = [
{
"definitionId": tokenHigh,
"name": "High",
"totalSupply": "1000000000000",
"balance": "5000000000"
},
{
"definitionId": tokenThird,
"name": "Third",
"totalSupply": "1000000",
"balance": "100"
}
]
wait(0)
compare(form.selectedTokenAId, "")
@@ -570,27 +537,6 @@ TestCase {
compare(form.amountB, "")
compare(form.minimumAmountA, "")
compare(form.minimumAmountB, "")
compare(form.confirmedPoolStatus, "")
}
function test_networkFailurePreservesPairDraft() {
var form = createForm()
form.amountA = "12"
form.amountB = "34"
form.confirmedPoolStatus = "active_pool"
form.newPositionContext = {
"status": "network_mismatch",
"tokens": []
}
wait(0)
compare(form.selectedTokenAId, tokenLow)
compare(form.selectedTokenBId, tokenHigh)
compare(form.amountA, "12")
compare(form.amountB, "34")
compare(form.confirmedPoolStatus, "active_pool")
verify(form.contextBlocksForm())
}
function test_submittedBase58TransactionIdIsCopied() {
+65
View File
@@ -0,0 +1,65 @@
pragma ComponentBehavior: Bound
import QtQuick
import QtTest
import "../../qml/components/liquidity" as Liquidity
import "../../qml/components/swap" as Swap
TestCase {
id: testCase
name: "TokenInput"
readonly property string base58Id: "Eiw5zDP1BKukkxMY8dj7Hkw9NfCTh6iU5av5F7FT8ExC"
readonly property string hexId: "cbe5e5fed00f9af47a0cbc6f96de828dd8e72090971fa1b904bec2014e3f634d"
Component {
id: inputComponent
Swap.TokenInput {
visible: false
width: 400
theme: inputTheme
selectorObjectName: "accountSelector"
Liquidity.AmmTheme {
id: inputTheme
}
}
}
function createInput(definitionId) {
return createTemporaryObject(inputComponent, testCase, {
"token": { "definitionId": definitionId, "symbol": "TKA" },
"holdings": [{
"accountId": "holding-a",
"accountType": "TokenHolding",
"balanceRaw": "10",
"definitionId": base58Id,
"definitionIdHex": hexId
}]
})
}
function test_base58DefinitionSelectsHolding() {
var input = createInput(base58Id)
verify(input)
var selector = findChild(input, "accountSelector")
verify(selector)
compare(selector.stateField, "definitionId")
tryCompare(selector, "hasFunds", true)
tryCompare(selector, "selectedAccountId", "holding-a")
}
function test_hexDefinitionSelectsHolding() {
var input = createInput(hexId)
verify(input)
var selector = findChild(input, "accountSelector")
verify(selector)
compare(selector.stateField, "definitionIdHex")
tryCompare(selector, "hasFunds", true)
tryCompare(selector, "selectedAccountId", "holding-a")
}
}
+9 -8
View File
@@ -70,10 +70,10 @@ 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-*/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)
# Token C has no seeded pool — the create-pool UI test (apps/amm/tests/e2e/create-pool.mjs)
# creates the A/C pool itself, minting its own LP holding via the app.
# 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.
# (apps/amm/tests/e2e/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)
###############################################################################
@@ -81,9 +81,9 @@ ACCOUNT_LABELS=(token-a-def token-a-holding token-b-def token-b-holding lp-holdi
###############################################################################
# --- Program binaries (docker release builds; image ids must match deployment) ---
TOKEN_BIN="programs/token/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/token.bin"
AMM_BIN="programs/amm/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/amm.bin"
TWAP_BIN="programs/twap_oracle/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/twap_oracle.bin"
TOKEN_BIN="${TOKEN_BIN:-target/guest/token.bin}"
AMM_BIN="${AMM_BIN:-target/guest/amm.bin}"
TWAP_BIN="${TWAP_BIN:-target/guest/twap_oracle.bin}"
# --- IDLs ---
TOKEN_IDL="artifacts/token-idl.json"
@@ -522,6 +522,7 @@ 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)"
log "Then in another terminal: ${DIM}node apps/amm/tests/e2e/swap.mjs${RST} (swap A/B)"
log " or: ${DIM}node apps/amm/tests/e2e/create-pool.mjs${RST} (create A/C pool)"
log " or: ${DIM}node apps/amm/tests/e2e/add-liquidity.mjs${RST} (add A/B liquidity)"
log " or: ${DIM}node apps/amm/tests/e2e/custom-token.mjs${RST} (add token D by id)"
+16
View File
@@ -0,0 +1,16 @@
import { resolve } from "node:path";
const frameworkRoot = process.env.LOGOS_QT_MCP;
if (!frameworkRoot)
throw new Error("LOGOS_QT_MCP is required");
const { test, run } = await import(resolve(frameworkRoot, "test-framework/framework.mjs"));
test("amm ui: renders primary navigation", async (app) => {
await app.waitFor(
async () => { await app.expectTexts(["Trade", "Liquidity", "Pools"]); },
{ timeout: 20000, interval: 500, description: "primary navigation" },
);
});
run();