From 20bfbddbdc7d879cb2e09071a2f8002d5cc42732 Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Fri, 10 Jul 2026 03:35:08 -0300 Subject: [PATCH] test(amm-ui): validate new position flow #224 --- apps/amm/README.md | 5 + apps/amm/VALIDATION.md | 47 ++++ apps/amm/src/AmmUiBackend.cpp | 59 +++- apps/amm/tests/new-position-validation.mjs | 312 +++++++++++++++++++++ 4 files changed, 408 insertions(+), 15 deletions(-) create mode 100644 apps/amm/VALIDATION.md create mode 100755 apps/amm/tests/new-position-validation.mjs diff --git a/apps/amm/README.md b/apps/amm/README.md index df5a6c2..0713b57 100644 --- a/apps/amm/README.md +++ b/apps/amm/README.md @@ -52,6 +52,11 @@ nix run . This builds and runs the application in development mode. +## Validation + +New Position validation commands and acceptance criteria live in +[VALIDATION.md](VALIDATION.md). + ## Updating Dependencies To update the pinned versions of dependencies in `flake.lock`: diff --git a/apps/amm/VALIDATION.md b/apps/amm/VALIDATION.md new file mode 100644 index 0000000..c10a5db --- /dev/null +++ b/apps/amm/VALIDATION.md @@ -0,0 +1,47 @@ +# AMM UI Validation + +Validation for the New Position flow covers deterministic quote math, account +construction, QML checks, and the module build. + +## Commands + +Run from the repository root: + +```bash +node apps/amm/tests/new-position-validation.mjs +qmllint apps/amm/qml/pages/LiquidityPage.qml apps/amm/qml/components/liquidity/NewPositionForm.qml apps/amm/qml/components/liquidity/NewPositionConfirmationDialog.qml +nix build ./apps/amm#packages.x86_64-linux.default --no-link +``` + +When shared AMM program types or instruction signatures change, also run the +affected Rust checks: + +```bash +RISC0_DEV_MODE=1 cargo +1.94.0 test -p amm_program +cargo +1.94.0 build -p idl-gen --release +./target/release/idl-gen programs/amm/methods/guest/src/bin/amm.rs > /tmp/amm-idl.json +diff artifacts/amm-idl.json /tmp/amm-idl.json +``` + +Live wallet/sequencer validation is manual until transaction dispatch has real +token holding IDs. Use a local devnet wallet/sequencer when available, open the +AMM UI, create or adopt one active account, preview both the active LOGOS/USDC +pool and the missing USDC/WETH pool, then submit and verify either a successful +transaction or the deterministic `submit_unavailable` backend response. + +## Acceptance Checklist + +- Active wallet context exposes only holdings for the active account. +- Unsupported fee tiers are disabled and explain why. +- Active pool fee tier is fixed to the stored pool fee. +- Missing pool flow locks the user-selected price and scales from the minimum + deposit that mints more than `MINIMUM_LIQUIDITY`. +- Active pool flow keeps the reserve ratio and previews LP with + `floor(min(supply * amount_a / reserve_a, supply * amount_b / reserve_b))`. +- Decimal inference keeps small supplies at 0 decimals, medium supplies at 6, + large supplies at 9, and massive supplies at 18. +- Human amount parse/format is reversible for 0, 6, 9, and 18 decimals. +- `new_definition` and `add_liquidity` account lists match the committed AMM IDL + order. +- Submit re-quotes and surfaces `quote_changed` when the request no longer + matches the preview hash. diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index 2ce57dc..0e77f4d 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -35,6 +35,10 @@ namespace { // Wallet home env override. Mirrors LEZ's own var so the app shares the // canonical wallet (~/.lee/wallet) used by the wallet UI and other apps. const char WALLET_HOME_ENV[] = "LEE_WALLET_HOME_DIR"; + constexpr double MINIMUM_LIQUIDITY = 1000.0; + constexpr double ACTIVE_POOL_USDC_RESERVE = 1'250'000.0; + constexpr double ACTIVE_POOL_LOGOS_RESERVE = 10'000'000.0; + constexpr double ACTIVE_POOL_LP_SUPPLY = 6'875'000.0; // Normalise file:// URLs and OS paths to a plain local path. QString toLocalPath(const QString& path) { @@ -126,11 +130,11 @@ namespace { double devnetBalance(const QString& symbol) { if (symbol == QStringLiteral("USDC")) - return 12450.0; + return 125000.0; if (symbol == QStringLiteral("LOGOS")) return 850000.0; if (symbol == QStringLiteral("WETH")) - return 3.25; + return 100.0; return 0.0; } @@ -193,12 +197,21 @@ namespace { double activeRatio(const QString& symbolA, const QString& symbolB) { if (symbolA == QStringLiteral("USDC") && symbolB == QStringLiteral("LOGOS")) - return 8.0; + return ACTIVE_POOL_LOGOS_RESERVE / ACTIVE_POOL_USDC_RESERVE; if (symbolA == QStringLiteral("LOGOS") && symbolB == QStringLiteral("USDC")) - return 0.125; + return ACTIVE_POOL_USDC_RESERVE / ACTIVE_POOL_LOGOS_RESERVE; return 1.0; } + double activePoolReserve(const QString& symbol) + { + if (symbol == QStringLiteral("USDC")) + return ACTIVE_POOL_USDC_RESERVE; + if (symbol == QStringLiteral("LOGOS")) + return ACTIVE_POOL_LOGOS_RESERVE; + return 0.0; + } + double defaultInitialPrice(const QString& symbolA, const QString& symbolB) { if (symbolA == QStringLiteral("USDC") && symbolB == QStringLiteral("WETH")) @@ -309,14 +322,20 @@ namespace { changes.append(accountChange(QStringLiteral("Vault B"), stableId(QStringLiteral("devnet:vault:%1:%2").arg(poolId, tokenB)), missingPool ? QStringLiteral("Update or create") : QStringLiteral("Update"))); + changes.append(accountChange(QStringLiteral("LP definition"), + stableId(QStringLiteral("devnet:lp-definition:%1").arg(poolId)), + missingPool ? QStringLiteral("Create") : QStringLiteral("Update"))); if (missingPool) { - changes.append(accountChange(QStringLiteral("LP definition"), - stableId(QStringLiteral("devnet:lp-definition:%1").arg(poolId)), - QStringLiteral("Create"))); changes.append(accountChange(QStringLiteral("LP lock holding"), stableId(QStringLiteral("devnet:lp-lock:%1").arg(poolId)), QStringLiteral("Create"))); } + changes.append(accountChange(QStringLiteral("User holding A"), + stableId(QStringLiteral("devnet:user-holding:%1").arg(tokenA)), + QStringLiteral("Update"))); + changes.append(accountChange(QStringLiteral("User holding B"), + stableId(QStringLiteral("devnet:user-holding:%1").arg(tokenB)), + QStringLiteral("Update"))); changes.append(accountChange(QStringLiteral("User LP holding"), stableId(QStringLiteral("devnet:user-lp:%1").arg(poolId)), QStringLiteral("Update or create"))); @@ -695,7 +714,7 @@ QString AmmUiBackend::activeAccountAddress() const QVariantMap AmmUiBackend::buildNewPositionContext() const { QVariantMap context; - context.insert(QStringLiteral("minimumLiquidity"), 1000.0); + context.insert(QStringLiteral("minimumLiquidity"), MINIMUM_LIQUIDITY); context.insert(QStringLiteral("feeTiers"), feeTiers()); QVariantMap network; @@ -787,7 +806,11 @@ QVariantMap AmmUiBackend::quoteNewPositionMap(const QVariantMap& request) const const double ratio = activeRatio(tokenA, tokenB); const double amountA = editA ? inputA : inputB / ratio; const double amountB = editA ? inputA * ratio : inputB; - const double expectedLp = std::floor(std::min(amountA * 5.5, amountB * 0.69)); + const double reserveA = activePoolReserve(tokenA); + const double reserveB = activePoolReserve(tokenB); + const double expectedLp = std::floor(std::min( + ACTIVE_POOL_LP_SUPPLY * amountA / reserveA, + ACTIVE_POOL_LP_SUPPLY * amountB / reserveB)); const double minimumLp = std::floor(expectedLp * (10000 - slippageBps) / 10000.0); if (inputA <= 0.0 && inputB <= 0.0) @@ -812,11 +835,17 @@ QVariantMap AmmUiBackend::quoteNewPositionMap(const QVariantMap& request) const parsePositiveAmount(request.value(QStringLiteral("initialPrice")).toString()) > 0.0 ? parsePositiveAmount(request.value(QStringLiteral("initialPrice")).toString()) : defaultInitialPrice(tokenA, tokenB); - const double scale = std::max(1, request.value(QStringLiteral("depositScale")).toInt()); - const double amountA = price >= 1.0 ? price * scale : scale; - const double amountB = price >= 1.0 ? scale : scale / price; - const double expectedLp = std::floor(std::sqrt(amountA * amountB) * 48.0); - const double userLp = std::max(0.0, expectedLp - 1000.0); + const double scaleMultiplier = + std::max(1, request.value(QStringLiteral("depositScale")).toInt()); + const double baseA = price >= 1.0 ? price : 1.0; + const double baseB = price >= 1.0 ? 1.0 : 1.0 / price; + const double minimumScale = + std::floor(MINIMUM_LIQUIDITY / std::sqrt(baseA * baseB)) + 1.0; + const double scale = minimumScale * scaleMultiplier; + const double amountA = baseA * scale; + const double amountB = baseB * scale; + const double initialLp = std::floor(std::sqrt(amountA * amountB)); + const double userLp = std::max(0.0, initialLp - MINIMUM_LIQUIDITY); const double minimumLp = std::floor(userLp * (10000 - slippageBps) / 10000.0); if (amountA > holdingA.value(QStringLiteral("balance")).toDouble()) @@ -833,7 +862,7 @@ QVariantMap AmmUiBackend::quoteNewPositionMap(const QVariantMap& request) const position.insert(QStringLiteral("ownedB"), formatTokenAmount(0, tokenB)); QVariantMap quote = - quoteOk(context, request, amountA, amountB, amountA, amountB, userLp, minimumLp, 1000.0, position); + quoteOk(context, request, amountA, amountB, amountA, amountB, userLp, minimumLp, MINIMUM_LIQUIDITY, position); QVariantMap pool = quote.value(QStringLiteral("pool")).toMap(); pool.insert(QStringLiteral("priceText"), QStringLiteral("1 %1 = %2 %3") diff --git a/apps/amm/tests/new-position-validation.mjs b/apps/amm/tests/new-position-validation.mjs new file mode 100755 index 0000000..b661b91 --- /dev/null +++ b/apps/amm/tests/new-position-validation.mjs @@ -0,0 +1,312 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; + +const MINIMUM_LIQUIDITY = 1000; +const ACTIVE_POOL_RESERVES = { + LOGOS: 10_000_000, + USDC: 1_250_000, +}; +const ACTIVE_POOL_LP_SUPPLY = 6_875_000; +const SUPPORTED_FEE_TIERS = new Set([1, 5, 30, 100]); +const ROLE_TO_IDL_NAME = { + Config: 'config', + Pool: 'pool', + 'Vault A': 'vault_a', + 'Vault B': 'vault_b', + 'LP definition': 'pool_definition_lp', + 'LP lock holding': 'lp_lock_holding', + 'User holding A': 'user_holding_a', + 'User holding B': 'user_holding_b', + 'User LP holding': 'user_holding_lp', + 'Current tick': 'current_tick_account', + Clock: 'clock', +}; + +function stableId(key) { + return createHash('sha256').update(key).digest('hex'); +} + +function inferDecimals(totalSupply) { + const supply = BigInt(totalSupply); + const digits = supply.toString().length; + + if (supply < 10_000_000n) + return 0; + if (digits <= 14) + return 6; + if (digits <= 21) + return 9; + return 18; +} + +function parseHumanAmount(input, decimals) { + const text = String(input).replaceAll(',', '').trim(); + assert.match(text, /^\d+(\.\d+)?$/, `invalid amount: ${input}`); + + const [whole, fraction = ''] = text.split('.'); + assert.ok(fraction.length <= decimals, `too many decimal places: ${input}`); + + const scale = 10n ** BigInt(decimals); + const paddedFraction = fraction.padEnd(decimals, '0') || '0'; + return BigInt(whole) * scale + BigInt(paddedFraction); +} + +function formatBaseUnits(units, decimals) { + const value = BigInt(units); + if (decimals === 0) + return value.toString(); + + const scale = 10n ** BigInt(decimals); + const whole = value / scale; + const fraction = (value % scale).toString().padStart(decimals, '0').replace(/0+$/, ''); + return fraction.length > 0 ? `${whole}.${fraction}` : whole.toString(); +} + +function parsePositiveAmount(value) { + const parsed = Number(String(value).replaceAll(',', '').trim()); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; +} + +function unorderedPairKey(symbolA, symbolB) { + return symbolA < symbolB ? `${symbolA}/${symbolB}` : `${symbolB}/${symbolA}`; +} + +function activeRatio(symbolA, symbolB) { + if (symbolA === 'USDC' && symbolB === 'LOGOS') + return ACTIVE_POOL_RESERVES.LOGOS / ACTIVE_POOL_RESERVES.USDC; + if (symbolA === 'LOGOS' && symbolB === 'USDC') + return ACTIVE_POOL_RESERVES.USDC / ACTIVE_POOL_RESERVES.LOGOS; + return 1; +} + +function defaultInitialPrice(symbolA, symbolB) { + if (symbolA === 'USDC' && symbolB === 'WETH') + return 2500; + if (symbolA === 'WETH' && symbolB === 'USDC') + return 0.0004; + return 1; +} + +function poolContext(symbolA, symbolB) { + const pairKey = unorderedPairKey(symbolA, symbolB); + const poolId = stableId(`devnet:amm-pool:${pairKey}`); + + if (pairKey === 'LOGOS/USDC') + return { poolId, poolStatus: 'active_pool', instruction: 'add_liquidity', storedFeeBps: 30 }; + if (pairKey === 'USDC/WETH') + return { poolId, poolStatus: 'missing_pool', instruction: 'new_definition', storedFeeBps: 0 }; + return { poolId, poolStatus: 'unavailable_pool', instruction: '', storedFeeBps: 0 }; +} + +function accountChange(role, id, action) { + return { role, id, action }; +} + +function accountChanges(request, context) { + const { tokenA, tokenB } = request; + const { poolId } = context; + const missingPool = context.poolStatus === 'missing_pool'; + const changes = [ + accountChange('Config', stableId('devnet:amm-config'), 'Read'), + accountChange('Pool', poolId, missingPool ? 'Create' : 'Update'), + accountChange('Vault A', stableId(`devnet:vault:${poolId}:${tokenA}`), missingPool ? 'Update or create' : 'Update'), + accountChange('Vault B', stableId(`devnet:vault:${poolId}:${tokenB}`), missingPool ? 'Update or create' : 'Update'), + accountChange('LP definition', stableId(`devnet:lp-definition:${poolId}`), missingPool ? 'Create' : 'Update'), + ]; + + if (missingPool) + changes.push(accountChange('LP lock holding', stableId(`devnet:lp-lock:${poolId}`), 'Create')); + + changes.push( + accountChange('User holding A', stableId(`devnet:user-holding:${tokenA}`), 'Update'), + accountChange('User holding B', stableId(`devnet:user-holding:${tokenB}`), 'Update'), + accountChange('User LP holding', stableId(`devnet:user-lp:${poolId}`), 'Update or create'), + accountChange('Current tick', stableId(`devnet:current-tick:${poolId}`), missingPool ? 'Create' : 'Update'), + accountChange('Clock', stableId('devnet:clock:canonical'), 'Read'), + ); + + return changes; +} + +function quoteNewPosition(request) { + assert.ok(SUPPORTED_FEE_TIERS.has(request.feeBps), 'unsupported fee tier'); + + const context = poolContext(request.tokenA, request.tokenB); + const slippageBps = Math.max(1, Math.min(5000, request.slippageBps)); + + if (context.poolStatus === 'active_pool') { + assert.equal(request.feeBps, context.storedFeeBps, 'active pool fee tier mismatch'); + + const inputA = parsePositiveAmount(request.amountA); + const inputB = parsePositiveAmount(request.amountB); + const editA = request.editedSide !== 'B'; + const ratio = activeRatio(request.tokenA, request.tokenB); + const amountA = editA ? inputA : inputB / ratio; + const amountB = editA ? inputA * ratio : inputB; + const reserveA = ACTIVE_POOL_RESERVES[request.tokenA]; + const reserveB = ACTIVE_POOL_RESERVES[request.tokenB]; + const expectedLp = Math.floor(Math.min( + ACTIVE_POOL_LP_SUPPLY * amountA / reserveA, + ACTIVE_POOL_LP_SUPPLY * amountB / reserveB, + )); + + return { + accountChanges: accountChanges(request, context), + deposit: { maxA: amountA, maxB: amountB, actualA: amountA, actualB: amountB }, + instruction: context.instruction, + lp: { + expected: expectedLp, + locked: 0, + minimum: Math.floor(expectedLp * (10000 - slippageBps) / 10000), + }, + poolStatus: context.poolStatus, + }; + } + + if (context.poolStatus === 'missing_pool') { + const parsedPrice = parsePositiveAmount(request.initialPrice); + const price = parsedPrice > 0 ? parsedPrice : defaultInitialPrice(request.tokenA, request.tokenB); + const baseA = price >= 1 ? price : 1; + const baseB = price >= 1 ? 1 : 1 / price; + const minimumScale = Math.floor(MINIMUM_LIQUIDITY / Math.sqrt(baseA * baseB)) + 1; + const scale = minimumScale * Math.max(1, request.depositScale); + const amountA = baseA * scale; + const amountB = baseB * scale; + const initialLp = Math.floor(Math.sqrt(amountA * amountB)); + const userLp = Math.max(0, initialLp - MINIMUM_LIQUIDITY); + + return { + accountChanges: accountChanges(request, context), + deposit: { maxA: amountA, maxB: amountB, actualA: amountA, actualB: amountB }, + instruction: context.instruction, + lp: { + expected: userLp, + locked: MINIMUM_LIQUIDITY, + minimum: Math.floor(userLp * (10000 - slippageBps) / 10000), + }, + poolStatus: context.poolStatus, + }; + } + + throw new Error(`unsupported pool: ${request.tokenA}/${request.tokenB}`); +} + +function idlAccounts(instructionName) { + const idl = JSON.parse(readFileSync(new URL('../../../artifacts/amm-idl.json', import.meta.url), 'utf8')); + const instruction = idl.instructions.find((item) => item.name === instructionName); + assert.ok(instruction, `missing IDL instruction: ${instructionName}`); + return instruction.accounts.map((account) => account.name); +} + +function assertAccountOrderMatchesIdl(quote, instructionName) { + const actual = quote.accountChanges.map((change) => { + assert.ok(ROLE_TO_IDL_NAME[change.role], `unmapped account role: ${change.role}`); + return ROLE_TO_IDL_NAME[change.role]; + }); + assert.deepEqual(actual, idlAccounts(instructionName)); +} + +function testDecimals() { + assert.equal(inferDecimals(9_999_999n), 0); + assert.equal(inferDecimals(1_000_000_000_000n), 6); + assert.equal(inferDecimals(1_000_000_000_000_000_000n), 9); + assert.equal(inferDecimals(1_000_000_000_000_000_000_000_000n), 18); + assert.ok([9_999_999n, 1_000_000_000_000n, 1_000_000_000_000_000_000n] + .every((supply) => inferDecimals(supply) !== 8)); + + for (const [amount, decimals] of [ + ['12345', 0], + ['12.3456', 6], + ['0.000000001', 9], + ['1.000000000000000001', 18], + ]) { + assert.equal(formatBaseUnits(parseHumanAmount(amount, decimals), decimals), amount); + } + + assert.throws(() => parseHumanAmount('1.1', 0), /too many decimal places/); + assert.throws(() => parseHumanAmount('1.0000000001', 9), /too many decimal places/); +} + +function testMissingPool() { + const quote1x = quoteNewPosition({ + amountA: '', + amountB: '', + depositScale: 1, + editedSide: 'A', + feeBps: 30, + initialPrice: '2500', + slippageBps: 50, + tokenA: 'USDC', + tokenB: 'WETH', + }); + assert.equal(quote1x.poolStatus, 'missing_pool'); + assert.equal(quote1x.instruction, 'new_definition'); + assert.equal(quote1x.deposit.maxA, 52_500); + assert.equal(quote1x.deposit.maxB, 21); + assert.equal(quote1x.lp.expected, 50); + assert.equal(quote1x.lp.locked, 1000); + assert.equal(quote1x.lp.minimum, 49); + assertAccountOrderMatchesIdl(quote1x, 'new_definition'); + + const quote2x = quoteNewPosition({ + amountA: '', + amountB: '', + depositScale: 2, + editedSide: 'A', + feeBps: 30, + initialPrice: '2500', + slippageBps: 50, + tokenA: 'USDC', + tokenB: 'WETH', + }); + assert.equal(quote2x.deposit.maxA / quote2x.deposit.maxB, 2500); + assert.equal(quote2x.deposit.maxA, 105_000); + assert.equal(quote2x.deposit.maxB, 42); + assert.equal(quote2x.lp.expected, 1100); + assert.equal(quote2x.lp.minimum, 1094); +} + +function testActivePool() { + const quoteByA = quoteNewPosition({ + amountA: '100', + amountB: '', + depositScale: 1, + editedSide: 'A', + feeBps: 30, + initialPrice: '', + slippageBps: 50, + tokenA: 'USDC', + tokenB: 'LOGOS', + }); + assert.equal(quoteByA.poolStatus, 'active_pool'); + assert.equal(quoteByA.instruction, 'add_liquidity'); + assert.equal(quoteByA.deposit.maxA, 100); + assert.equal(quoteByA.deposit.maxB, 800); + assert.equal(quoteByA.lp.expected, 550); + assert.equal(quoteByA.lp.minimum, 547); + assertAccountOrderMatchesIdl(quoteByA, 'add_liquidity'); + + const quoteByB = quoteNewPosition({ + amountA: '', + amountB: '100', + depositScale: 1, + editedSide: 'B', + feeBps: 30, + initialPrice: '', + slippageBps: 50, + tokenA: 'LOGOS', + tokenB: 'USDC', + }); + assert.equal(quoteByB.deposit.maxA, 800); + assert.equal(quoteByB.deposit.maxB, 100); + assert.equal(quoteByB.lp.expected, 550); +} + +testDecimals(); +testMissingPool(); +testActivePool(); + +console.log('New Position validation harness passed');