mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-24 21:59:36 +00:00
feat(apps/amm): wire the add-liquidity submit end-to-end
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.
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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:<hex tx hash> } on success, else
|
||||
// { status:"error", error:<code> } (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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user