mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-25 06:01:11 +00:00
Both liquidity branches now quote through the lean composable ops. The create path joins the add path (already on addLiquidityQuote) by reworking liquidity_quote into a dual-mode create quote and wiring the form to it via the resolvePool pool read. quoteNewPosition/amm_quote are no longer reached from the UI. FFI (modules/amm/ffi): - liquidity_quote is dual-mode: price-only (initialPriceRealRaw, no amounts) returns the minimum opening deposit via minimum_opening_pair; supplied amounts return the actual deposit with the price derived from them. Emits actual/minimum amounts, expectedLp, lockedLp and the Q64.64 price. - LiquidityQuoteRequest gains initial_price_real_raw (Option<String>, needed only in price-only mode). Module (modules/amm/src): - liquidityQuote forwards initialPriceRealRaw to the op. UI (apps/amm/qml): - Route create-vs-add on the pool read (resolvePool -> poolExists); create quotes via liquidityQuote, assembled into the missing-pool shape the form already consumes. - poolStatus moves off the quote onto the flow's poolExists; the form derives activePool/missingPool from it. Trim the vestigial quote fields (canSubmit, requiresFreshLp, warnings, errors[], accountPreview, the "Pool" row) and drop the account-plan panel for parity with the swap view. - Fix a real bug: a pair change now resets poolExists (resetPoolExistence) so resetPairDraft re-resolves the pool like a fresh selection. Otherwise an active pool kept stale (cleared) reserves with no re-quote, and the deposit ratio-fill silently no-op'd. Tests (apps/amm/tests): - Read activePool instead of the removed poolStatus. The add test waits for the reset's active-pool quote to settle (reserves reloaded) before the ratio-fill; the create test resets the draft to clear leftover cross-run amounts and the stale submitted transactionId.
317 lines
15 KiB
JavaScript
317 lines
15 KiB
JavaScript
// ---------------------------------------------------------------------------
|
|
// AMM UI test — create a NEW pool (A/C) through the Liquidity view.
|
|
//
|
|
// Drives the running AMM UI through the QML inspector (logos-qt-mcp), the same
|
|
// way swap.mjs does. It selects the A/C pair (which the setup script leaves
|
|
// UNSEEDED — only A/B is created), lets the form auto-fill the minimum opening
|
|
// deposit, submits the create, and verifies the pool now exists ON-CHAIN.
|
|
//
|
|
// Prereqs in the running app (see apps/amm/tests/README.md):
|
|
// * launched against the isolated test wallet + TOKENS_CONFIG that
|
|
// testnet/setup-amm-testnet.sh writes (TKA, TKB, TKC)
|
|
// * an open wallet + reachable local sequencer
|
|
// * the A/C pool must NOT exist yet (setup only seeds A/B)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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;
|
|
|
|
// --- small helpers (mirrors swap.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 Liquidity 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 create/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 {
|
|
activePool: get("activePool"),
|
|
missingPool: get("missingPool"),
|
|
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 a create-pool side. The selector auto-selects a
|
|
// single holding, but choose it explicitly (robust to multi-account wallets):
|
|
// wait for the holdings to populate, then select the first match. canConfirm now
|
|
// requires both A/B holdings before a pool can be created.
|
|
async function selectAccount(app, selectorObjectName) {
|
|
// The selector lives in a Loader that instantiates only once the pool is known
|
|
// to be missing, so it may render a frame after missingPool flips — wait for it.
|
|
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 token picker helpers (shared shape with swap.mjs), used only to
|
|
// read the A/C pool's on-chain state for the final assertion. ------------
|
|
|
|
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}` },
|
|
);
|
|
}
|
|
|
|
// --- the test ---------------------------------------------------------------
|
|
|
|
test("amm liquidity: create the A/C pool", async (app) => {
|
|
// Resolve the A and C token-definition ids from the launched token config.
|
|
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 tokenC = bySymbol("TKC");
|
|
console.log(` create pool A(${tokenA.slice(0, 6)}…) / C(${tokenC.slice(0, 6)}…)`);
|
|
|
|
// 1. Switch to the Liquidity tab and wait for the form to render.
|
|
await app.waitFor(
|
|
async () => { await app.expectTexts(["Trade", "Liquidity"]); },
|
|
{ timeout: 20000, interval: 500, description: "nav bar to load" },
|
|
);
|
|
await ignore(() => app.click("Liquidity"));
|
|
// waitFor resolves when the condition stops throwing — it does NOT return the
|
|
// callback's value, so fetch the id with a direct call afterwards.
|
|
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/C pair through selectToken() — the same entry point the token picker
|
|
// uses — so the form runs its normal selection logic and resetPairDraft(), clearing any
|
|
// persisted amounts/price/minimums from a reused app session and firing a fresh quote.
|
|
// Setting selectedToken*Id directly would bypass that reset and could assert against
|
|
// stale draft state. Both tokens are already in the config, so no async token resolution
|
|
// is needed. The first missing-pool quote returns the minimum opening deposit, which
|
|
// applyQuoteSideEffects auto-fills — so canConfirm becomes ready.
|
|
await evaluate(app, formId, `selectToken("A", "${tokenA}")`);
|
|
await evaluate(app, formId, `selectToken("B", "${tokenC}")`);
|
|
|
|
// Sanity: the selection must stick — the ids have to be present in the token
|
|
// config the app was launched with, or tokenById can't resolve them.
|
|
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/C pair selected" },
|
|
);
|
|
await evaluate(app, formId, "requestQuote(true)");
|
|
|
|
// 3. Wait for the missing-pool quote (which makes the per-side account selectors
|
|
// render), pick the funding account for each side, then wait for a submittable
|
|
// create quote — canConfirm needs the funded minimum deposit AND both holdings.
|
|
try {
|
|
await app.waitFor(
|
|
async () => {
|
|
const s = await formState(app, formId);
|
|
if (s.activePool === true)
|
|
throw new Error("A/C pool already exists — reset the testnet (only A/B should be seeded)");
|
|
if (!s.missingPool) throw new Error("pool status not resolved yet");
|
|
},
|
|
{ timeout: 20000, interval: 500, description: "missing-pool quote" },
|
|
);
|
|
await selectAccount(app, "newPositionAccountSelectorA");
|
|
await selectAccount(app, "newPositionAccountSelectorB");
|
|
// The live app window persists across runs, so the form may carry leftover deposit
|
|
// amounts and a stale "Position submitted" transactionId from a prior create — both
|
|
// block a clean run (mismatched amounts keep canConfirm false; a stale txId would make
|
|
// step 5's "submitted" wait pass instantly). resetPairDraft() clears the amounts/price
|
|
// and re-quotes (price-only), so applyQuoteSideEffects re-fills the fresh minimum
|
|
// deposit, and its draftChanged() clears the stale transactionId. Holdings are kept.
|
|
await evaluate(app, formId, "resetPairDraft()");
|
|
await app.waitFor(
|
|
async () => {
|
|
const s = await formState(app, formId);
|
|
if (!s.canConfirm) throw new Error("create CTA not ready yet");
|
|
},
|
|
{ timeout: 20000, interval: 500, description: "create CTA ready" },
|
|
);
|
|
} catch (e) {
|
|
await saveShot(app, "create-pool-cta-not-ready");
|
|
throw new Error(`${e.message}. Form state: ${JSON.stringify(await formState(app, formId))}`);
|
|
}
|
|
await saveShot(app, "create-pool-filled");
|
|
console.log(` minimum deposit: A=${(await formState(app, formId)).amountA} C=${(await formState(app, formId)).amountB}`);
|
|
|
|
// 4. Submit -> confirmation dialog -> confirm.
|
|
const dialogId = await idByObjectName(app, "liquidityConfirmDialog");
|
|
const submitId = await idByObjectName(app, "newPositionSubmitButton");
|
|
await app.inspector.send("click", { objectId: submitId });
|
|
|
|
// QtQuick Controls Buttons don't reliably take the inspector's synthetic click,
|
|
// so if the dialog didn't open, emit the form's confirmationRequested signal
|
|
// directly (exactly what the button's onClicked does).
|
|
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 });
|
|
// QtQuick Buttons don't always take the synthetic click; fall back to invoking
|
|
// the dialog's confirm() slot directly (fires onConfirmed -> flow.confirm()).
|
|
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()"));
|
|
}
|
|
|
|
// 5. Wait for the create to submit. It's fully async — createAccountPublic (mint
|
|
// the LP holding) then createPool then the tx submit — so transactionId lands
|
|
// a few seconds after confirm(), not immediately.
|
|
try {
|
|
await app.waitFor(
|
|
async () => {
|
|
const s = await formState(app, formId);
|
|
if (!s.transactionId) throw new Error("create not submitted yet");
|
|
},
|
|
{ timeout: 30000, interval: 1000, description: "create to submit (transactionId set)" },
|
|
);
|
|
} catch {
|
|
await saveShot(app, "create-pool-result");
|
|
throw new Error(`create did not submit. Form state: ${JSON.stringify(await formState(app, formId))}`);
|
|
}
|
|
const final = await formState(app, formId);
|
|
console.log(` create submitted: tx ${final.transactionId}`);
|
|
|
|
// 6. Verify ON-CHAIN via the Swap card's resolvePool — a plain hex pool read
|
|
// against the sequencer (createPool has no tx poll / poolStatus, so we don't
|
|
// lean on the liquidity form's legacy quote). Selecting A/C on the Trade tab
|
|
// must now report the pool as existing with reserves.
|
|
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, 2); // TKC
|
|
const swapId = await idByObjectName(app, "swapCard");
|
|
|
|
try {
|
|
await app.waitFor(
|
|
async () => {
|
|
// Force a fresh read each poll — the create block may not be applied yet.
|
|
await ignore(() => evaluate(app, swapId, "doResolvePool()"));
|
|
await new Promise((r) => setTimeout(r, 800));
|
|
if ((await prop(app, swapId, "poolExists")) !== true)
|
|
throw new Error("A/C pool not found yet");
|
|
},
|
|
{ timeout: 40000, interval: 1500, description: "A/C pool to exist on-chain" },
|
|
);
|
|
} catch {
|
|
await saveShot(app, "create-pool-result");
|
|
throw new Error(
|
|
`A/C pool not found on-chain after the create (tx ${final.transactionId}).\n` +
|
|
` swap card: poolExists=${await prop(app, swapId, "poolExists")} ` +
|
|
`reserveA=${await prop(app, swapId, "poolReserveA")} ` +
|
|
`reserveB=${await prop(app, swapId, "poolReserveB")}`,
|
|
);
|
|
}
|
|
const rA = await prop(app, swapId, "poolReserveA");
|
|
const rB = await prop(app, swapId, "poolReserveB");
|
|
console.log(` A/C pool exists on-chain ✓ reserves A=${rA} B=${rB} (tx ${final.transactionId})`);
|
|
await saveShot(app, "create-pool-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 swap.mjs.
|