From d045dca1f1f6705768ed8296e4f866da226c9180 Mon Sep 17 00:00:00 2001 From: r4bbit <445106+0x-r4bbit@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:20:42 +0200 Subject: [PATCH] feat(apps/amm): wire the add-liquidity submit end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate the active-pool branch of the liquidity form onto the new addLiquidity op (quoting stays on legacy quoteNewPosition for now, as agreed). - AmmUiBackend: add the addLiquidity QtRO slot; forwards to the module and refreshes balances (mirrors createPool). - NewPositionFlow: route the active-pool confirm to addLiquidity, minting a fresh LP holding then submitting {tokenA/B, holdingA/B, lpHolding, maxAmountA/B, minLpRaw, deadline}. minLpRaw comes from the legacy quote's minimumLpRaw. - NewPositionForm: show the per-side account selectors in add mode; gate the CTA on both holdings and on the deposit amounts being present (hasDepositAmounts) — the pair's probe quote otherwise reports canSubmit on simulated amounts and wrongly enables the button before any amount is entered. - amm_ffi: fix add_liquidity_plan to orient the (max amount, holding) pair to the pool's STORED definition order, not is_canonical_pair — a pool created outside the FFI (the testnet setup's spel new-definition) can store a non-canonical order, which otherwise sent a holding into the wrong vault (Transfer sender/recipient definition mismatch). Test uses a non-canonical pool. Adds add-liquidity.mjs: asserts the CTA stays disabled with no amounts, submits an add to the seeded A/B pool, and verifies reserveA grew on-chain. --- .../components/liquidity/NewPositionForm.qml | 25 +- apps/amm/qml/state/NewPositionFlow.qml | 86 +++-- apps/amm/src/AmmUiBackend.cpp | 19 + apps/amm/src/AmmUiBackend.h | 3 + apps/amm/src/AmmUiBackend.rep | 10 + apps/amm/tests/README.md | 9 +- apps/amm/tests/add-liquidity.mjs | 328 ++++++++++++++++++ 7 files changed, 444 insertions(+), 36 deletions(-) create mode 100644 apps/amm/tests/add-liquidity.mjs diff --git a/apps/amm/qml/components/liquidity/NewPositionForm.qml b/apps/amm/qml/components/liquidity/NewPositionForm.qml index 02f7b45..b7cb27e 100644 --- a/apps/amm/qml/components/liquidity/NewPositionForm.qml +++ b/apps/amm/qml/components/liquidity/NewPositionForm.qml @@ -92,17 +92,23 @@ AmmActionCard { && root.selectedTokenBId.length > 0 && root.selectedTokenAId !== root.selectedTokenBId readonly property bool resolvingToken: root.resolvingTokenId.length > 0 - // Creating a pool submits caller-provided A/B holdings (see submissionSnapshot); - // require both selectors resolved. Add-liquidity enumerates holdings server-side, - // so it does not gate on these. - readonly property bool holdingsReady: !root.missingPool + // Both createPool and addLiquidity submit caller-provided A/B holdings (see + // submissionSnapshot), so once a pool status is known both require the two selectors + // resolved (the selectors are shown for both branches). + readonly property bool holdingsReady: !(root.missingPool || root.activePool) || (root.selectedHoldingAId.length > 0 && root.selectedHoldingBId.length > 0) + // Both deposit amounts must be present. For create the quote auto-fills them from the + // opening deposit; for add the user enters one and the other ratio-fills. Without this + // the active-pool probe quote (sent on pair-select with simulated amounts) reports + // canSubmit, wrongly enabling the CTA before any amount is entered. + readonly property bool hasDepositAmounts: root.amountA.length > 0 && root.amountB.length > 0 readonly property bool canConfirm: root.quotePayload.status === "ok" && root.quotePayload.canSubmit === true && root.quoteMatchesPair() && String(root.quotePayload.quoteHash || "").length > 0 && root.holdingsReady + && root.hasDepositAmounts && !root.contextLoading && !root.quoteLoading && !root.quoteStale @@ -263,7 +269,7 @@ AmmActionCard { selectedTokenId: root.selectedTokenAId holdings: root.holdings holdingDefinitionId: root.selectedTokenAId - showHoldingSelector: root.missingPool && root.hasPair + showHoldingSelector: (root.missingPool || root.activePool) && root.hasPair selectorObjectName: "newPositionAccountSelectorA" tokenInvalid: root.tokenHasError("A") tokenSelectionEnabled: !root.contextLoading && !root.submitting @@ -315,7 +321,7 @@ AmmActionCard { selectedTokenId: root.selectedTokenBId holdings: root.holdings holdingDefinitionId: root.selectedTokenBId - showHoldingSelector: root.missingPool && root.hasPair + showHoldingSelector: (root.missingPool || root.activePool) && root.hasPair selectorObjectName: "newPositionAccountSelectorB" tokenInvalid: root.tokenHasError("B") tokenSelectionEnabled: !root.contextLoading && !root.submitting @@ -1458,13 +1464,16 @@ AmmActionCard { return { "request": built.request, "quoteHash": String(root.quotePayload.quoteHash || ""), - // Canonical-order holdings for the create path's createPool call: the + // Canonical-order holdings for the createPool / addLiquidity calls: the // request's tokenAId/amountARaw are canonical, so holdingAId must be the - // canonical token A's holding too (createPool re-canonicalizes as a no-op). + // canonical token A's holding too (the module re-canonicalizes as a no-op). // The user picks these via the per-side account selectors; selectedHoldingA // is display token A's holding, so it aligns with tokenA the same way. "holdingAId": String(root.displayIsCanonical ? root.selectedHoldingAId : root.selectedHoldingBId), "holdingBId": String(root.displayIsCanonical ? root.selectedHoldingBId : root.selectedHoldingAId), + // The add path's slippage floor on the LP minted (orientation-independent), + // taken from the active-pool quote; ignored by the create path. + "minLpRaw": String(root.quotePayload.minimumLpRaw || ""), "pairText": qsTr("%1 / %2").arg(root.shortTokenName(root.tokenA)).arg(root.shortTokenName(root.tokenB)), "feeText": root.feeLabel(root.selectedFeeBps), "depositAText": root.quoteAmount("actualAmountARaw", "actualAmountBRaw", "A"), diff --git a/apps/amm/qml/state/NewPositionFlow.qml b/apps/amm/qml/state/NewPositionFlow.qml index ce0babe..6d882be 100644 --- a/apps/amm/qml/state/NewPositionFlow.qml +++ b/apps/amm/qml/state/NewPositionFlow.qml @@ -196,33 +196,14 @@ QtObject { return } - // Pool creation (initialPriceRealRaw is set only on the missing-pool path) goes - // through the new createPool op — hex ids, caller-provided accounts. Add-liquidity - // keeps the legacy submitNewPosition. - if (snapshot.request.initialPriceRealRaw !== undefined) { + // Route by pool state: creation (initialPriceRealRaw is set only on the missing-pool + // path) goes through createPool; the active-pool branch through addLiquidity. Both + // mint a fresh LP holding then submit via the lean module ops (hex ids, + // caller-provided accounts). Quoting stays on the legacy quoteNewPosition for now. + if (snapshot.request.initialPriceRealRaw !== undefined) root.createPool(snapshot) - return - } - - root.runtime.watch(root.backend.submitNewPosition(snapshot.request, snapshot.quoteHash), - function(result) { - if (result && result.status === "submitted" - && /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test( - String(result.transactionId || ""))) { - root.submitting = false - root.transactionId = result.transactionId - root.flowErrorCode = "" - root.contextErrorCode = "" - root.quoteErrorCode = "" - root.invalidateQuote() - root.submitSucceeded() - return - } - root.finishSubmitFailure(result || root.quoteError("wallet_submission_failed")) - }, - function(error) { - root.finishSubmitFailure(root.quoteError("wallet_submission_failed")) - }) + else + root.addLiquidity(snapshot) } // Create a pool via the new createPool op. A new pool has no pre-existing LP @@ -277,6 +258,59 @@ QtObject { }) } + // Add liquidity to an existing pool via the new addLiquidity op. Like createPool a fresh + // LP holding receives the minted LP, so create one then submit. The submit is priced off + // the legacy quote's maxAmounts + minimumLpRaw (quoting stays legacy for now; the + // module's addLiquidityQuote is built but unwired). No confirmation poll yet. + function addLiquidity(snapshot) { + root.runtime.watch(root.backend.createAccountPublic(), + function(lpId) { + if (!lpId || String(lpId).length === 0) { + root.finishSubmitFailure(root.quoteError("wallet_submission_failed")) + return + } + root.submitAddLiquidity(snapshot, String(lpId)) + }, + function(error) { + root.finishSubmitFailure(root.quoteError("wallet_submission_failed")) + }) + } + + function submitAddLiquidity(snapshot, lpHoldingId) { + var request = { + "tokenAId": snapshot.request.tokenAId, + "tokenBId": snapshot.request.tokenBId, + "holdingAId": snapshot.holdingAId, + "holdingBId": snapshot.holdingBId, + "lpHoldingId": lpHoldingId, + "maxAmountARaw": snapshot.request.maxAmountARaw, + "maxAmountBRaw": snapshot.request.maxAmountBRaw, + "minLpRaw": snapshot.minLpRaw, + // u64-max sentinel = no deadline, same as the swap submits. + "deadlineMs": "18446744073709551615" + } + root.runtime.watch(root.backend.addLiquidity(request), + function(result) { + if (result && result.status === "ok" + && String(result.transactionId || "").length > 0) { + root.submitting = false + root.transactionId = String(result.transactionId) + root.flowErrorCode = "" + root.contextErrorCode = "" + root.quoteErrorCode = "" + root.invalidateQuote() + root.submitSucceeded() + return + } + var code = result && result.error ? String(result.error) + : "wallet_submission_failed" + root.finishSubmitFailure(root.quoteError(code)) + }, + function(error) { + root.finishSubmitFailure(root.quoteError("wallet_submission_failed")) + }) + } + function finishSubmitFailure(result) { root.submitting = false const hasFreshQuote = result && result.quote diff --git a/apps/amm/src/AmmUiBackend.cpp b/apps/amm/src/AmmUiBackend.cpp index bc6ea04..3b89b48 100644 --- a/apps/amm/src/AmmUiBackend.cpp +++ b/apps/amm/src/AmmUiBackend.cpp @@ -305,3 +305,22 @@ QVariantMap AmmUiBackend::createPool(QVariantMap request) refreshBalances(); return result; } + +QVariantMap AmmUiBackend::addLiquidity(QVariantMap request) +{ + // Same connected-state submit guard as createPool — this app's lock is authoritative + // even though the shared wallet may remain open elsewhere. + if (!isWalletOpen()) + return QVariantMap { + { QStringLiteral("status"), QStringLiteral("error") }, + { QStringLiteral("error"), QStringLiteral("wallet_unavailable") }, + }; + + // The caller supplies the fresh LP holding in the request; the backend forwards to the + // module (it creates no wallet accounts) and refreshes balances on a successful submit. + const QVariantMap result = m_logos->amm_module.addLiquidity(request); + if (result.value(QStringLiteral("status")).toString() == QStringLiteral("ok") + && !result.value(QStringLiteral("transactionId")).toString().isEmpty()) + refreshBalances(); + return result; +} diff --git a/apps/amm/src/AmmUiBackend.h b/apps/amm/src/AmmUiBackend.h index 705daaa..7825c92 100644 --- a/apps/amm/src/AmmUiBackend.h +++ b/apps/amm/src/AmmUiBackend.h @@ -77,6 +77,9 @@ public slots: // accounts here. QVariantMap liquidityQuote(QVariantMap request) override; QVariantMap createPool(QVariantMap request) override; + // Add-liquidity submit. Forwards to the module; the flow supplies a fresh LP + // holding in the request (the backend creates no wallet accounts here). + QVariantMap addLiquidity(QVariantMap request) override; // Lists the wallet's fungible token holdings for the account selector. QVariantList tokenHoldings() override; diff --git a/apps/amm/src/AmmUiBackend.rep b/apps/amm/src/AmmUiBackend.rep index b47c3c7..f68399f 100644 --- a/apps/amm/src/AmmUiBackend.rep +++ b/apps/amm/src/AmmUiBackend.rep @@ -114,6 +114,16 @@ class AmmUiBackend // invalid_account_id, bad_amount, bad_fee_bps_amount, invalid_fee_tier, // wallet_submission_failed, backend_error). SLOT(QVariantMap createPool(QVariantMap request)) + // Submits an AddLiquidity transaction into the request's existing pool. `request` + // carries { tokenAId, tokenBId, holdingAId, holdingBId, lpHoldingId, maxAmountARaw, + // maxAmountBRaw, minLpRaw, deadlineMs } (ids hex or base58; amounts/deadline decimal + // strings). minLpRaw is the slippage floor on the LP minted; lpHoldingId is a fresh + // holding the caller supplies (the flow creates it) to receive the minted LP — the + // backend forwards and creates no wallet accounts. Returns + // { status:"ok", error:"", transactionId: } on success, else + // { status:"error", error: } (wallet_unavailable, config_missing, + // invalid_account_id, bad_amount, no_pool, wallet_submission_failed, backend_error). + SLOT(QVariantMap addLiquidity(QVariantMap request)) // Lists the connected wallet's fungible token holdings for the account // selector: [{ accountId, accountType:"TokenHolding", definitionId, // definitionIdHex, balanceRaw }] — one row per holding account, every token, diff --git a/apps/amm/tests/README.md b/apps/amm/tests/README.md index 5ea2079..5284bfe 100644 --- a/apps/amm/tests/README.md +++ b/apps/amm/tests/README.md @@ -9,6 +9,9 @@ inspector (framework from - `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 + disabled until deposit amounts are entered, submits an add, and verifies the + A/B pool reserves grew **on-chain**. ## Isolation @@ -45,8 +48,9 @@ LEE_WALLET_HOME_DIR=$(pwd)/apps/amm/tests/testnet/.wallet \ 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/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 ``` Headless CI variant (no window, launches the app itself, pass/fail only): @@ -78,6 +82,7 @@ nix build .#integration-test -L - `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). - `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. diff --git a/apps/amm/tests/add-liquidity.mjs b/apps/amm/tests/add-liquidity.mjs new file mode 100644 index 0000000..a6ec48f --- /dev/null +++ b/apps/amm/tests/add-liquidity.mjs @@ -0,0 +1,328 @@ +// --------------------------------------------------------------------------- +// AMM UI test — ADD liquidity to the existing A/B pool through the Liquidity view. +// +// Drives the running AMM UI through the QML inspector (logos-qt-mcp), the same +// way create-pool.mjs does. The setup script seeds the A/B pool (10000/10000), so +// this selects A/B (an ACTIVE pool), asserts the CTA stays DISABLED until deposit +// amounts are entered, enters a deposit (token B ratio-fills), submits the add, +// and verifies the pool reserves grew ON-CHAIN. +// +// Prereqs in the running app (see apps/amm/tests/README.md): +// * launched against the isolated test wallet + TOKENS_CONFIG (TKA, TKB, TKC) +// * an open wallet + reachable local sequencer +// * the A/B pool seeded (testnet/setup-amm-testnet.sh) +// --------------------------------------------------------------------------- + +import { resolve } from "node:path"; +import { readFile, writeFile } from "node:fs/promises"; + +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 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; + +// 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. +const DEPOSIT_A = "1000"; + +// --- 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 right 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 setProp(app, id, property, value) { + await app.inspector.send("setProperty", { objectId: id, property, value }); +} + +async function evaluate(app, id, expression) { + await app.inspector.send("evaluate", { expression, objectId: id }); +} + +// The NewPositionForm's add/pool state — explains WHY the CTA isn't ready. +async function formState(app, formId) { + const props = (await app.getProperties(formId)).properties || []; + const get = (n) => { const p = props.find((x) => x.name === n); return p ? p.value : undefined; }; + return { + poolStatus: get("poolStatus"), + activePool: get("activePool"), + canConfirm: get("canConfirm"), + amountA: get("amountA"), + amountB: get("amountB"), + submitError: get("submitError"), + transactionId: get("transactionId"), + selectedHoldingAId: get("selectedHoldingAId"), + selectedHoldingBId: get("selectedHoldingBId"), + }; +} + +// Pick the funding account for an add-liquidity side (identical to create-pool.mjs). +async function selectAccount(app, selectorObjectName) { + let id; + await app.waitFor( + async () => { id = await idByObjectName(app, selectorObjectName); }, + { timeout: 10000, interval: 300, description: `${selectorObjectName} to render` }, + ); + await app.waitFor( + async () => { if ((await prop(app, id, "hasFunds")) !== true) throw new Error("no matching holdings yet"); }, + { timeout: 10000, interval: 300, description: `${selectorObjectName} holdings to load` }, + ); + await evaluate(app, id, "setSelection(accountIdFor(matchingAccounts[0]), false)"); + await app.waitFor( + async () => { if (!(await prop(app, id, "selectedAccountId"))) throw new Error("holding not selected yet"); }, + { timeout: 5000, interval: 200, description: `${selectorObjectName} holding selected` }, + ); +} + +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 writeFile(path, Buffer.from(shot.image, "base64")); + console.log(` screenshot -> ${path}`); + } +} + +// --- swap-card helpers: read the A/B pool's on-chain reserves (shared with swap.mjs) --- + +async function pickerOpen(app) { + const id = await idByObjectName(app, "tokenSelectorModal"); + return (await prop(app, id, "visible")) === true; +} + +async function openPicker(app, buttonObjectName) { + const btnId = await idByObjectName(app, buttonObjectName); + await app.inspector.send("click", { objectId: btnId }); + await app.waitFor( + async () => { if (!(await pickerOpen(app))) throw new Error("picker not open"); }, + { timeout: 5000, interval: 200, description: `open ${buttonObjectName}` }, + ); +} + +async function pickToken(app, index) { + const res = await app.findByProperty("objectName", "tokenListItem"); + const items = (res && res.matches) || []; + if (items.length <= index) + throw new Error(`token #${index + 1} not found — only ${items.length} in the list`); + await app.inspector.send("click", { objectId: items[index].id }); + await app.waitFor( + async () => { if (await pickerOpen(app)) throw new Error("picker still open"); }, + { timeout: 5000, interval: 200, description: `select token #${index + 1}` }, + ); +} + +// Read the A/B pool's canonical reserveA via the swap card's resolvePool (a live +// sequencer query — the same genuine chain-state read create-pool.mjs uses). Selects +// A(0)/B(1) on the Trade tab and forces a fresh resolve. Returns { swapId, reserveA }. +async function readReserveA(app) { + await ignore(() => app.click("Trade")); + await app.waitFor( + async () => { await app.expectTexts(["Sell", "Buy"]); }, + { timeout: 10000, interval: 500, description: "swap card to load" }, + ); + await openPicker(app, "swapSellTokenButton"); + await pickToken(app, 0); // TKA + await openPicker(app, "swapBuyTokenButton"); + await pickToken(app, 1); // TKB + const swapId = await idByObjectName(app, "swapCard"); + let reserveA; + await app.waitFor( + async () => { + await ignore(() => evaluate(app, swapId, "doResolvePool()")); + await new Promise((r) => setTimeout(r, 800)); + if ((await prop(app, swapId, "poolExists")) !== true) + throw new Error("A/B pool not resolved yet"); + reserveA = await prop(app, swapId, "poolReserveA"); + }, + { timeout: 20000, interval: 1000, description: "A/B pool reserves" }, + ); + return { swapId, reserveA }; +} + +// --- the test --------------------------------------------------------------- + +test("amm liquidity: add to the A/B pool", async (app) => { + const tokens = JSON.parse(await readFile(TOKENS_CONFIG, "utf8")); + const bySymbol = (s) => { + const t = tokens.find((x) => (x.symbol || "").toUpperCase() === s); + if (!t) throw new Error(`token ${s} not in ${TOKENS_CONFIG} — run the setup script`); + return t.definitionId; + }; + const tokenA = bySymbol("TKA"); + const tokenB = bySymbol("TKB"); + console.log(` add liquidity to A(${tokenA.slice(0, 6)}…) / B(${tokenB.slice(0, 6)}…)`); + + // 0. Baseline: the A/B pool's on-chain reserveA before adding. + const before = await readReserveA(app); + console.log(` A/B reserveA before: ${before.reserveA}`); + + // 1. Switch to the Liquidity tab and wait for the form. + 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"); + + // 2. Select the A/B pair (existing pool) and kick a quote. + await setProp(app, formId, "selectedTokenAId", tokenA); + await setProp(app, formId, "selectedTokenBId", tokenB); + await app.waitFor( + async () => { + const a = await prop(app, formId, "selectedTokenAId"); + const b = await prop(app, formId, "selectedTokenBId"); + if (!a || !b) throw new Error(`pair not selected (A=${a} B=${b})`); + }, + { timeout: 5000, interval: 300, description: "A/B pair selected" }, + ); + await evaluate(app, formId, "requestQuote(true)"); + + // 3. Wait for the active-pool quote (the pool exists). + await app.waitFor( + async () => { + const s = await formState(app, formId); + if (s.poolStatus !== "active_pool") + throw new Error(`pool not active yet (status=${s.poolStatus})`); + }, + { timeout: 20000, interval: 500, description: "active-pool quote" }, + ); + + // 4. Pick the funding account for each side (add mode shows the selectors). + await selectAccount(app, "newPositionAccountSelectorA"); + await selectAccount(app, "newPositionAccountSelectorB"); + + // 5. The CTA must be DISABLED when no deposit amounts are entered — even though the + // pool is active and the pair's probe quote reports canSubmit on simulated amounts. + // resetPairDraft() clears the amount fields and re-fires that probe quote, reaching + // this exact state deterministically regardless of any prior form state (the live app + // window persists across runs, so the fields may carry leftover amounts). + await evaluate(app, formId, "resetPairDraft()"); + await app.waitFor( + async () => { + const s = await formState(app, formId); + if (s.poolStatus !== "active_pool") + throw new Error(`probe quote not back yet (status=${s.poolStatus})`); + if (s.amountA || s.amountB) + throw new Error(`deposit amounts not cleared (A=${s.amountA} B=${s.amountB})`); + if (s.canConfirm) + throw new Error("Add-liquidity CTA is enabled with no deposit amounts entered"); + }, + { timeout: 20000, interval: 500, description: "CTA disabled with no amounts" }, + ); + console.log(" CTA correctly disabled with no amounts entered ✓"); + + // 6. Enter a deposit for token A; token B ratio-fills. Then wait for a submittable quote. + await evaluate(app, formId, `finishActiveAmount("A", "${DEPOSIT_A}")`); + try { + await app.waitFor( + async () => { + const s = await formState(app, formId); + if (!s.canConfirm) throw new Error("add CTA not ready yet"); + }, + { timeout: 20000, interval: 500, description: "add CTA ready" }, + ); + } catch (e) { + await saveShot(app, "add-liquidity-cta-not-ready"); + throw new Error(`${e.message}. Form state: ${JSON.stringify(await formState(app, formId))}`); + } + const filled = await formState(app, formId); + console.log(` deposit: A=${filled.amountA} B=${filled.amountB}`); + await saveShot(app, "add-liquidity-filled"); + + // 7. Submit -> confirmation dialog -> confirm. + const dialogId = await idByObjectName(app, "liquidityConfirmDialog"); + const submitId = await idByObjectName(app, "newPositionSubmitButton"); + await app.inspector.send("click", { objectId: submitId }); + + // QtQuick Buttons don't reliably take the inspector's synthetic click, so if the + // dialog didn't open, emit the form's confirmationRequested directly. + try { + await app.waitFor( + async () => { if ((await prop(app, dialogId, "visible")) !== true) throw new Error("not open"); }, + { timeout: 4000, interval: 300, description: "confirm dialog open" }, + ); + } catch { + console.log(" submit click didn't take — emitting confirmationRequested via evaluate"); + await ignore(() => evaluate(app, formId, "confirmationRequested(submissionSnapshot())")); + await app.waitFor( + async () => { if ((await prop(app, dialogId, "visible")) !== true) throw new Error("dialog not open"); }, + { timeout: 8000, interval: 300, description: "confirm dialog open (after evaluate)" }, + ); + } + + const confirmId = await idByObjectName(app, "transactionConfirmButton"); + await app.inspector.send("click", { objectId: confirmId }); + try { + await app.waitFor( + async () => { if ((await prop(app, dialogId, "visible")) === true) throw new Error("still open"); }, + { timeout: 3000, interval: 300, description: "confirm click registered" }, + ); + } catch { + console.log(" confirm button click didn't take — invoking confirm() via evaluate"); + await ignore(() => evaluate(app, dialogId, "confirm()")); + } + + // 8. Wait for the add to submit. Fully async — createAccountPublic (mint the fresh LP + // holding) then addLiquidity then the tx submit — so transactionId lands a few + // seconds after confirm(). + try { + await app.waitFor( + async () => { + const s = await formState(app, formId); + if (!s.transactionId) throw new Error("add not submitted yet"); + }, + { timeout: 30000, interval: 1000, description: "add to submit (transactionId set)" }, + ); + } catch { + await saveShot(app, "add-liquidity-result"); + throw new Error(`add did not submit. Form state: ${JSON.stringify(await formState(app, formId))}`); + } + const final = await formState(app, formId); + console.log(` add submitted: tx ${final.transactionId}`); + + // 9. Verify ON-CHAIN: the pool's reserveA must have grown by the deposit. + const after = await readReserveA(app); + try { + await app.waitFor( + async () => { + // Force a fresh read each poll — the add block may not be applied yet. + await ignore(() => evaluate(app, after.swapId, "doResolvePool()")); + await new Promise((r) => setTimeout(r, 800)); + const now = await prop(app, after.swapId, "poolReserveA"); + if (!(BigInt(now) > BigInt(before.reserveA))) + throw new Error(`reserveA not grown yet (before=${before.reserveA} now=${now})`); + }, + { timeout: 40000, interval: 1500, description: "A/B reserveA to grow on-chain" }, + ); + } catch { + await saveShot(app, "add-liquidity-result"); + throw new Error( + `A/B reserveA did not grow after the add (tx ${final.transactionId}).\n` + + ` before=${before.reserveA} after=${await prop(app, after.swapId, "poolReserveA")}`, + ); + } + const grownReserveA = await prop(app, after.swapId, "poolReserveA"); + console.log(` A/B reserveA after: ${grownReserveA} ✓ grew on-chain (tx ${final.transactionId})`); + await saveShot(app, "add-liquidity-result"); +}); + +run(); + +// How to run (from scratch, interactive + CI): see the "Running the UI tests" +// section in apps/amm/tests/README.md — same flow as create-pool.mjs.