diff --git a/.prettierignore b/.prettierignore index 43160a71..fe69922e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,4 +6,5 @@ node_modules playwright-report test-results wasm -CHANGELOG.md \ No newline at end of file +CHANGELOG.md +docs/adr diff --git a/components/screens/full-pages/TokenOperationFailed.tsx b/components/screens/full-pages/TokenOperationFailed.tsx index 1172ebb4..df338741 100644 --- a/components/screens/full-pages/TokenOperationFailed.tsx +++ b/components/screens/full-pages/TokenOperationFailed.tsx @@ -34,15 +34,6 @@ export const TokenOperationFailed = () => { ), }, - reveal_timeout: { - title: "Minting Incomplete: Mempool timeout", - message: ( -
- Timeout occurred, the reveal transaction have been rejected, please - try again later -
- ), - }, commit_timeout: { title: "Minting Incomplete: Mempool timeout", message: ( diff --git a/components/send/kas-send/DetailsStep.tsx b/components/send/kas-send/DetailsStep.tsx index 26f741c1..ae86152b 100644 --- a/components/send/kas-send/DetailsStep.tsx +++ b/components/send/kas-send/DetailsStep.tsx @@ -85,7 +85,7 @@ export function DetailsStep({ // 1 KAS self-send built over the current UTXO set, so it does grow with // fragmentation, but only with how many inputs 1 KAS takes: measured // against assets/kaspa_bg.wasm (2.0.1), 203,600 sompi with one input, - // 315,400 with two (UTXOs of ~0.6 KAS and up, at every count tried up to + // 315,400 with two (UTXOs of 0.541 KAS and up, at every count tried up to // 50,000), 539,000 at 0.3 KAS UTXOs, 762,600 at 0.2. The real Max send // spends every UTXO: the Generator charges 19,931,000 sompi for 174 inputs // (the most it builds at all, rusty-kaspa#701) and needs ~0.1 KAS of change diff --git a/docs/adr/ADR-003-hardcoded-configuration.md b/docs/adr/ADR-003-hardcoded-configuration.md new file mode 100644 index 00000000..c7e7ffe2 --- /dev/null +++ b/docs/adr/ADR-003-hardcoded-configuration.md @@ -0,0 +1,34 @@ +# ADR-003 — Hardcoded configuration, no env vars + +> **Mirror.** The source of truth is the Notion page +> [ADR-003 — Hardcoded configuration, no env vars](https://app.notion.com/p/3aa2984b7b1d81e197d7c2662c8721e3) +> (kastle Wiki / Decisions, page id `3aa2984b-7b1d-81e1-97d7-c2662c8721e3`). +> The text below is copied verbatim from that page as last edited +> 2026-07-27. Edit the Notion page first, then refresh this file. +> +> **Stale on one point (repo state 2026-09-09):** the Sources section states +> `import.meta.env` is unused. `components/screens/DevMode.tsx:133` gates the +> Sentry scrubbing check on `import.meta.env.DEV`. The ADR text is left as +> written; update Notion, not this note, when the decision is revisited. + +--- + +*Type: Decision* +*Maintainer: Leo* +*Last updated: 2026-07-27* +*Status: Current (Accepted)* + +## Sources + +- [Kastle Extension Handover](https://app.notion.com/p/3a32984b7b1d80cbac11d4392161b6a8) §§2, 9 +- Repo-wide verification 2026-07-27: zero `.env*` files; only `process.env.NODE_ENV` + `process.env.CI` referenced; `import.meta.env` unused + +## Decision + +All runtime configuration — RPC/indexer endpoints (`contexts/SettingsContext.tsx`), L2 chains (`lib/layer2.ts`), Sentry DSN (`lib/instrument.ts:5`), PostHog key (`contexts/PostHogWrapperProvider.tsx:22`), the EIP-6963 extension ID (`entrypoints/injected.ts:30`) — is hardcoded in source. The only "environment" split is `NODE_ENV` (prod gates Sentry). + +## Consequences + +- Rotating the Sentry DSN or PostHog key, or changing any endpoint, **requires a code change + release + store review**. Plan rotation lead time accordingly. +- Telemetry keys are cleartext in a public repo — accepted; they are client-side-public by nature. NEVER add actual secrets to source under this pattern. +- CI-only env surface: the four `CHROME_*` store-submission secrets + `APP_ID`/`APP_PRIVATE_KEY`/`SLACK_WEBHOOK_URL` in GitHub Actions. diff --git a/docs/kastle-api.md b/docs/kastle-api.md index 7c93a170..d3f1aba3 100644 --- a/docs/kastle-api.md +++ b/docs/kastle-api.md @@ -483,7 +483,7 @@ Consolidates all UTXOs in the current account into a single UTXO by sending the Useful for reducing future transaction fees caused by having many small UTXOs. -> **Current behaviour (Extension):** the handler builds the self-send with the Generator's default input selection, which picks only as many UTXOs as the payment needs — in practice one input moved to the same address minus the fee. The account's UTXOs are **not** consolidated. To compound today, build the sweep yourself with [Build Transaction](#10-build-transaction) and broadcast it with [Sign & Broadcast Transaction](#11-sign--broadcast-transaction). A sweep-mode fix is tracked separately. +> **Current behaviour (Extension):** the handler builds the self-send with the Generator's default input selection, which picks only as many UTXOs as the payment needs — in practice one input moved to the same address minus the fee. The account's UTXOs are **not** consolidated. To compound today, use [Build Transaction](#10-build-transaction) to send most of the balance back to your own address with an explicit `amount` — the balance minus a fee reserve (0.3 KAS covers the largest batch the Generator builds); `outputs` is required there, so a true sweep cannot be expressed. The Generator selects inputs only until the amount is covered, so this reduces fragmentation rather than guaranteeing consolidation: UTXOs worth less than the reserve in total can stay unspent, and full consolidation needs sweep-mode input selection. On a fragmented wallet the result is a chain of transactions; pass each one, in order, to [Sign & Broadcast Transaction](#11-sign--broadcast-transaction). A sweep-mode fix is tracked separately. > **Since Extension `2.60.1`:** when compounding would take more than one transaction, the call rejects with `{ code: 4300, message }` (`BATCH_REQUIRED`) instead of broadcasting only the first one. Build the batch with [Build Transaction](#10-build-transaction) and pass each transaction, in order, to [Sign & Broadcast Transaction](#11-sign--broadcast-transaction). diff --git a/lib/commit-reveal.ts b/lib/commit-reveal.ts index 5736f379..1659962c 100644 --- a/lib/commit-reveal.ts +++ b/lib/commit-reveal.ts @@ -37,8 +37,47 @@ const outpointKey = (outpoint: { transactionId: string; index: number }) => // 5,163 outpoints removed then re-added in 150 s), so a read taken right // after a confirmation can still list what the previous operation spent. // Building on such a read double-spends the wallet's own unconfirmed -// transaction. ponytail: never pruned — a few keys per operation. -const spentByClient = new WeakMap>(); +// transaction. Keyed by outpoint, valued by the id of the transaction that +// spent it, so a record can be dropped once the mempool no longer knows that +// transaction (see readWalletEntries). Otherwise never pruned — a few keys +// per operation. +const spentByClient = new WeakMap>(); + +// Kaspa's utxos-changed subscription is a set of addresses with no per-caller +// count: one watcher's unsubscribe drops an address every other watcher on the +// connection still needs. Count the holders here and only unsubscribe what +// nobody holds any more. +const holdersByClient = new WeakMap>(); + +const holders = (rpcClient: RpcClient) => { + let held = holdersByClient.get(rpcClient); + if (!held) { + held = new Map(); + holdersByClient.set(rpcClient, held); + } + return held; +}; + +const retain = (rpcClient: RpcClient, addresses: string[]) => { + const held = holders(rpcClient); + for (const address of addresses) { + held.set(address, (held.get(address) ?? 0) + 1); + } +}; + +// Returns the addresses this caller was the last holder of. +const release = (rpcClient: RpcClient, addresses: string[]) => { + const held = holders(rpcClient); + return addresses.filter((address) => { + const left = (held.get(address) ?? 1) - 1; + if (left > 0) { + held.set(address, left); + return false; + } + held.delete(address); + return true; + }); +}; /** * Thrown when a reveal batch is only partially broadcast. `transactionIds` are @@ -172,7 +211,7 @@ export class CommitRevealHelper { private spent() { let spent = spentByClient.get(this.rpcClient); if (!spent) { - spent = new Set(); + spent = new Map(); spentByClient.set(this.rpcClient, spent); } return spent; @@ -180,15 +219,52 @@ export class CommitRevealHelper { private recordSpent(tx: Transaction) { for (const input of tx.inputs) { - this.spent().add(outpointKey(input.previousOutpoint)); + this.spent().set(outpointKey(input.previousOutpoint), tx.id); + } + } + + /** + * Drops the spent records of every transaction among `stale`'s spenders + * that the node's mempool no longer knows. Such a transaction left the + * mempool unmined (node restart, full-mempool eviction, the 24 h expiry), + * so the node will list its inputs as unspent forever and a record of them + * would refuse every retry until the tab is reloaded. + */ + private async forgetDropped(stale: IUtxoEntry[]) { + const spent = this.spent(); + const spenders = new Set( + stale.map((entry) => spent.get(outpointKey(entry.outpoint))!), + ); + for (const transactionId of spenders) { + // Only the node's own "Transaction … not found" answer + // (RpcError::TransactionNotFound) means the mempool dropped it. A + // transport or server error keeps the record: failing closed for one + // more attempt beats reusing an outpoint a live transaction still + // spends. + const dropped = await this.rpcClient + .getMempoolEntry({ + transactionId, + includeOrphanPool: true, + filterTransactionPool: false, + }) + .then( + () => false, + (e: unknown) => /not found/i.test(errorMessage(e)), + ); + if (!dropped) continue; + for (const [key, id] of spent) { + if (id === transactionId) spent.delete(key); + } } } /** * The wallet's UTXO set as the node reports it, re-read until it no longer - * lists an outpoint this connection already spent. Throws once the - * confirmation timeout passes with the spend still unreflected: the caller - * fails closed instead of broadcasting a double spend. + * lists an outpoint this connection already spent. Once the confirmation + * timeout passes with a spend still unreflected, records of transactions + * the mempool has dropped are forgotten (their inputs really are spendable + * again); if any spend is still pending this throws and the caller fails + * closed instead of broadcasting a double spend. */ private async readWalletEntries(address: string): Promise { const deadline = @@ -196,15 +272,21 @@ export class CommitRevealHelper { (this.options.confirmationTimeoutMs ?? CONFIRMATION_TIMEOUT_MS); for (;;) { const { entries } = await this.rpcClient.getUtxosByAddresses([address]); - const stale = entries.filter((entry) => - this.spent().has(outpointKey(entry.outpoint)), - ); + const spent = this.spent(); + const isStale = (entry: IUtxoEntry) => + spent.has(outpointKey(entry.outpoint)); + const stale = entries.filter(isStale); if (stale.length === 0) { return entries; } if (Date.now() >= deadline) { + await this.forgetDropped(stale); + const pending = stale.filter(isStale); + if (pending.length === 0) { + return entries; + } throw new Error( - `A previous transaction is still unconfirmed; the wallet still holds ${stale + `A previous transaction is still unconfirmed; the wallet still holds ${pending .map((entry) => outpointKey(entry.outpoint)) .join(", ")}`, ); @@ -499,6 +581,11 @@ export class CommitRevealHelper { )), this.options.confirmationTimeoutMs ?? REVEAL_CONFIRMATION_TIMEOUT_MS, ); + // Nobody awaits the watcher until the whole batch is submitted. If the + // subscription rejects, or a submit throws and the watcher is orphaned, + // its rejection must not surface as an unhandled one. The caller's own + // await still sees it. + confirm.catch(() => undefined); // Submit in order: later transactions spend the outputs of earlier ones. // An orphan error means the node has not seen a parent yet, so retry @@ -525,8 +612,6 @@ export class CommitRevealHelper { ); continue; } - // Nobody awaits the watcher now; let its own timeout end it quietly. - confirm.catch(() => undefined); throw new RevealBroadcastError(e, transactionIds, signed.length); } } @@ -554,7 +639,9 @@ export const waitForUtxosChanged = async ( const forAddresses = (entries: IUtxoEntry[] = []) => entries.filter((entry) => payloads.has(entry.address?.payload ?? "")); + retain(rpcClient, addresses); try { + // Set semantics on the node: re-subscribing a held address is a no-op. await rpcClient.subscribeUtxosChanged(addresses); await new Promise((resolve, reject) => { @@ -578,7 +665,12 @@ export const waitForUtxosChanged = async ( }, timeoutMs); }); } finally { - await rpcClient.unsubscribeUtxosChanged(addresses); + // An orphaned watcher (a retry started while the previous one's watcher + // was still timing out) must not drop the addresses the live one holds. + const idle = release(rpcClient, addresses); + if (idle.length > 0) { + await rpcClient.unsubscribeUtxosChanged(idle); + } } }; diff --git a/lib/token-operation-error.ts b/lib/token-operation-error.ts index 10f3d7bc..c64165fe 100644 --- a/lib/token-operation-error.ts +++ b/lib/token-operation-error.ts @@ -1,7 +1,6 @@ export type TokenOperationFailureKind = | "disconnected" | "commit_timeout" - | "reveal_timeout" | "default"; export interface TokenOperationFailure { @@ -27,11 +26,9 @@ export const describeTokenOperationError = ( if (message.includes("disconnected")) { return { kind: "disconnected", message }; } - if (message === "Reveal transaction did not mature within 2 minutes") { - return { kind: "reveal_timeout", message }; - } // waitTxForAddress rejects with "Timeout"; on the commit leg that is the - // only timeout perform() still throws (reveal confirmation is only warned). + // only timeout perform() still throws (a reveal confirmation timeout is + // caught and warned in lib/commit-reveal.ts, so there is no reveal kind). if ( message === "Timeout" || message === "Commit transaction did not mature within 2 minutes" diff --git a/tests/commit-reveal-batch-unit.spec.ts b/tests/commit-reveal-batch-unit.spec.ts index 4b798e19..5c0b1a2a 100644 --- a/tests/commit-reveal-batch-unit.spec.ts +++ b/tests/commit-reveal-batch-unit.spec.ts @@ -239,7 +239,9 @@ test.describe("commit-reveal over a fragmented UTXO set (B3 reveal)", () => { expect(reveals.length).toBeGreaterThan(1); // The old code stopped after the first reveal transaction and never paid. expect(node.submitted.length).toBe(1 + completed.revealTxIds!.length); - expect(paidTo(node.submitted, payee)).toBe(kaspaToSompi("20")!); + expect(String(paidTo(node.submitted, payee))).toBe( + String(kaspaToSompi("20")), + ); expect(completed.commitTxId).toBe(commit.id); expect(completed.revealTxIds).toEqual(reveals.map((tx) => tx.id)); // The reported reveal id is the payment, not the compaction. @@ -311,7 +313,9 @@ test.describe("commit-reveal over a fragmented UTXO set (B3 reveal)", () => { const userReads = node.reads.filter((a) => a.includes(user.toString())); expect(userReads.length).toBe(2); expect(node.submitted[0].id).toBe(completed.commitTxId); - expect(paidTo(node.submitted, payee)).toBe(kaspaToSompi("20")!); + expect(String(paidTo(node.submitted, payee))).toBe( + String(kaspaToSompi("20")), + ); }); test("a mid-batch broadcast failure reports what landed", async () => { @@ -372,7 +376,9 @@ test.describe("commit-reveal over a fragmented UTXO set (B3 reveal)", () => { expect(orphaned).toBeDefined(); expect(node.submitted[2].id).toBe(orphaned); expect(completed.revealTxIds).toContain(orphaned); - expect(paidTo(node.submitted, payee)).toBe(kaspaToSompi("20")!); + expect(String(paidTo(node.submitted, payee))).toBe( + String(kaspaToSompi("20")), + ); }); test("a reveal confirmation timeout still reports every broadcast transaction", async () => { @@ -388,16 +394,34 @@ test.describe("commit-reveal over a fragmented UTXO set (B3 reveal)", () => { node.silence = (tx) => tx.outputs.some((o) => o.scriptPublicKey.toString() === payeeScript); - const yielded = await run(helper, "1000", [ - { address: payee.toString(), amount: "20" }, - ]); + // The compactions ARE reported, and each pays the user's own address. A + // watcher that accepted any transaction paying the user (the old one) + // would resolve on the first of them, well inside the timeout, and never + // warn; the whole run would still end in `completed`. + const warnings: string[] = []; + const warn = console.warn; + console.warn = (...args: unknown[]) => + warnings.push(args.map(String).join(" ")); + const started = Date.now(); + let yielded: Yielded[]; + try { + yielded = await run(helper, "1000", [ + { address: payee.toString(), amount: "20" }, + ]); + } finally { + console.warn = warn; + } const completed = completedOf(yielded); const [commit, ...reveals] = node.submitted; + expect(Date.now() - started).toBeGreaterThanOrEqual(300); + expect(warnings).toEqual(["Reveal confirmation not observed Timeout"]); expect(reveals.length).toBeGreaterThan(1); expect(completed.commitTxId).toBe(commit.id); expect(completed.revealTxIds).toEqual(reveals.map((tx) => tx.id)); - expect(paidTo(node.submitted, payee)).toBe(kaspaToSompi("20")!); + expect(String(paidTo(node.submitted, payee))).toBe( + String(kaspaToSompi("20")), + ); }); test("a commit that broadcasts but never confirms still reports its id", async () => { @@ -433,11 +457,41 @@ test.describe("commit-reveal over a fragmented UTXO set (B3 reveal)", () => { node.submitted[0].id, ]); const { entries } = await node.getUtxosByAddresses([p2sh]); - expect(entries.map((entry) => entry.amount)).toEqual([ - kaspaToSompi(SCRIPT_UTXO_AMOUNT)!, + expect(entries.map((entry) => String(entry.amount))).toEqual([ + String(kaspaToSompi(SCRIPT_UTXO_AMOUNT)), ]); }); + test("a reveal watcher whose subscription fails neither blocks nor leaks a rejection", async () => { + const { node, helper, payee } = setup(BATCHING_COUNT, BATCHING_TOTAL); + // The watcher is created before the first reveal submit and nobody awaits + // it until the whole batch is out. If its subscription rejects in that + // window the rejection must already be handled (Playwright fails the + // test on an unhandled one) and the broadcast must still complete. + let subscriptions = 0; + node.subscribeUtxosChanged = async () => { + if (++subscriptions === 2) throw "RPC disconnected"; + }; + // A real submit is a network round trip. With microtask-only submits the + // caller's late `.catch` attaches before Node checks for unhandled + // rejections and the leak is invisible. + const submit = node.submitTransaction.bind(node); + node.submitTransaction = async (request) => { + await new Promise((resolve) => setTimeout(resolve, 1)); + return submit(request); + }; + + const yielded = await run(helper, "1000", [ + { address: payee.toString(), amount: "20" }, + ]); + const completed = completedOf(yielded); + const [, ...reveals] = node.submitted; + + expect(subscriptions).toBe(2); + expect(reveals.length).toBeGreaterThan(1); + expect(completed.revealTxIds).toEqual(reveals.map((tx) => tx.id)); + }); + test("a wallet that cannot fund the reveal is refused before the commit", async () => { const { node, helper, payee } = setup(BATCHING_COUNT, kaspaToSompi("500")!); diff --git a/tests/fee-estimate-unit.spec.ts b/tests/fee-estimate-unit.spec.ts index 14c72dca..5716a7d1 100644 --- a/tests/fee-estimate-unit.spec.ts +++ b/tests/fee-estimate-unit.spec.ts @@ -42,13 +42,18 @@ test.describe("priority fee derivation (Defects 2 and 3)", () => { // Defect 3: on Back from Confirm, usePriorityFeeEstimate starts over and // DetailsStep's effect used to write 0n until the RPC answered, moving a Max // amount twice. While either input is loading the form's value must stand. + // bigint results are asserted via String(): a failing expect(bigint).toBe() + // cannot be reported by Playwright 1.51 and restarts the worker forever. + const feeOf = (...args: Parameters) => + String(priorityFeeFromEstimate(...args)); + test("is undefined until both the estimate and the base fee have loaded", () => { - expect(priorityFeeFromEstimate(undefined, "medium", BASE_FEE)).toBe( - undefined, - ); - expect(priorityFeeFromEstimate(IDLE_MAINNET, "medium", undefined)).toBe( - undefined, - ); + expect( + priorityFeeFromEstimate(undefined, "medium", BASE_FEE), + ).toBeUndefined(); + expect( + priorityFeeFromEstimate(IDLE_MAINNET, "medium", undefined), + ).toBeUndefined(); }); // Defect 2: the whole feerate was treated as priority, so at the floor the @@ -56,13 +61,9 @@ test.describe("priority fee derivation (Defects 2 and 3)", () => { // fee shown. test("is 0 at the feerate floor, on every bucket", () => { for (const priority of ["low", "medium", "high"] as const) { - expect(priorityFeeFromEstimate(IDLE_MAINNET, priority, BASE_FEE)).toBe( - 0n, - ); + expect(feeOf(IDLE_MAINNET, priority, BASE_FEE)).toBe("0"); } - expect( - priorityFeeFromEstimate(estimateAt(50, 50, 50), "low", BASE_FEE), - ).toBe(0n); + expect(feeOf(estimateAt(50, 50, 50), "low", BASE_FEE)).toBe("0"); }); // krc20-send/DetailsStep derives the "miner fees" tooltip from the commit's @@ -74,28 +75,22 @@ test.describe("priority fee derivation (Defects 2 and 3)", () => { const wholeFeerate = (100 * COMMIT_FEE) / 100; expect(wholeFeerate).toBe(COMMIT_FEE); for (const priority of ["low", "medium", "high"] as const) { - expect(priorityFeeFromEstimate(IDLE_MAINNET, priority, COMMIT_FEE)).toBe( - 0n, - ); + expect(feeOf(IDLE_MAINNET, priority, COMMIT_FEE)).toBe("0"); } - expect( - priorityFeeFromEstimate(estimateAt(100, 200, 1000), "high", COMMIT_FEE), - ).toBe(1_832_400n); - // While either input is loading the form keeps its value (0n default). - expect(priorityFeeFromEstimate(undefined, "low", COMMIT_FEE)).toBe( - undefined, + expect(feeOf(estimateAt(100, 200, 1000), "high", COMMIT_FEE)).toBe( + "1832400", ); + // While either input is loading the form keeps its value (0n default). + expect( + priorityFeeFromEstimate(undefined, "low", COMMIT_FEE), + ).toBeUndefined(); }); test("is the excess over the floor, as a share of the base fee", () => { const congested = estimateAt(100, 200, 1000); - expect(priorityFeeFromEstimate(congested, "low", BASE_FEE)).toBe(0n); - expect(priorityFeeFromEstimate(congested, "medium", BASE_FEE)).toBe( - 315_400n, - ); - expect(priorityFeeFromEstimate(congested, "high", BASE_FEE)).toBe( - 2_838_600n, - ); + expect(feeOf(congested, "low", BASE_FEE)).toBe("0"); + expect(feeOf(congested, "medium", BASE_FEE)).toBe("315400"); + expect(feeOf(congested, "high", BASE_FEE)).toBe("2838600"); }); // The fee DetailsStep and ConfirmStep show is baseFee + priorityFee. Assert @@ -155,8 +150,8 @@ test.describe("priority fee derivation (Defects 2 and 3)", () => { 0n, ); const shown = BigInt(baseFee) + priorityFee; - expect(paid.feeAmount).toBe(shown); - expect(inputs - outputs).toBe(shown); + expect(String(paid.feeAmount)).toBe(String(shown)); + expect(String(inputs - outputs)).toBe(String(shown)); } }); }); diff --git a/tests/generator-errors-unit.spec.ts b/tests/generator-errors-unit.spec.ts index 54a66912..de7574e2 100644 --- a/tests/generator-errors-unit.spec.ts +++ b/tests/generator-errors-unit.spec.ts @@ -91,8 +91,11 @@ test.describe("Generator fragmentation errors (B3)", () => { () => undefined, (e: unknown) => e, ); - // Measured: 69,999 sompi is the last priority fee that builds at 0.3 flat; - // 70,000 throws "Mass calculation error", the high bucket this. + // Measured: 69,999 sompi is the last priority fee that builds at 0.3 flat. + // Two different errors past that point: at 70,000 the Generator throws + // "Mass calculation error"; at the high bucket's 3,154,000 + // (HIGH_PRIORITY_FEE) it throws "Storage mass exceeds maximum", which is + // what is asserted here. expect(String(error)).toContain("Storage mass exceeds maximum"); expect(isFragmentationError(error)).toBe(true); }); diff --git a/tests/kas-send-batch-unit.spec.ts b/tests/kas-send-batch-unit.spec.ts index ed69fac2..ca3f9e41 100644 --- a/tests/kas-send-batch-unit.spec.ts +++ b/tests/kas-send-batch-unit.spec.ts @@ -108,7 +108,7 @@ test.describe("KAS send over a fragmented UTXO set (B2)", () => { expect(broadcast.length).toBe(transactions.length); expect(ids.length).toBe(transactions.length); // The point of the fix: the recipient is paid the whole 3000 KAS. - expect(paidTo(broadcast, destScript)).toBe(REQUEST); + expect(String(paidTo(broadcast, destScript))).toBe(String(REQUEST)); }); test("the batch is broadcast in generator order, payment last", async () => { @@ -145,7 +145,9 @@ test.describe("KAS send over a fragmented UTXO set (B2)", () => { await signAndSubmitBatch(transactions, signer, rpcClient); expect(transactions.length).toBeGreaterThan(1); - expect(paidTo(broadcast, destScript)).toBe(TOTAL - MIN_SUBTRAHEND); + expect(String(paidTo(broadcast, destScript))).toBe( + String(TOTAL - MIN_SUBTRAHEND), + ); }); test("an unfragmented wallet still sends exactly one transaction", async () => { @@ -156,7 +158,7 @@ test.describe("KAS send over a fragmented UTXO set (B2)", () => { expect(transactions.length).toBe(1); expect(ids.length).toBe(1); - expect(paidTo(broadcast, destScript)).toBe(REQUEST); + expect(String(paidTo(broadcast, destScript))).toBe(String(REQUEST)); }); test("progress and incremental ids are reported for every transaction", async () => { diff --git a/tests/mint-loop-utxo-unit.spec.ts b/tests/mint-loop-utxo-unit.spec.ts index f7ab6d89..c5fba62e 100644 --- a/tests/mint-loop-utxo-unit.spec.ts +++ b/tests/mint-loop-utxo-unit.spec.ts @@ -174,6 +174,26 @@ test.describe("mint loop UTXO bookkeeping across iterations", () => { this.emitLast(); } + // The mempool dropped `tx` unmined (node restart, eviction, the 24 h + // expiry): nothing spends its inputs any more and the UTXO index, which + // never reflected the mempool, keeps listing them. + evict(tx: Transaction) { + this.mempool.delete(tx.id); + for (const input of tx.inputs) + this.spentInMempool.delete(outpointKey(input.previousOutpoint)); + } + + mempoolQueryFailure: string | undefined; + + async getMempoolEntry({ transactionId }: { transactionId: string }) { + if (this.mempoolQueryFailure) throw this.mempoolQueryFailure; + if (!this.mempool.has(transactionId)) { + // RpcError::TransactionNotFound + throw `RPC Server (remote error) -> Transaction ${transactionId} not found`; + } + return { mempoolEntry: { transactionId } }; + } + async subscribeUtxosChanged() {} async unsubscribeUtxosChanged() {} addEventListener(_: string, listener: (event: unknown) => void) { @@ -289,4 +309,62 @@ test.describe("mint loop UTXO bookkeeping across iterations", () => { await run(0, 2); expectDisjointInputs(node, 6); }); + + test("a broadcast the mempool dropped does not wedge every retry", async () => { + const { node, run } = setup(200); + // Iteration 0's reveal is accepted, never reported, and evicted unmined. + // The node lists its inputs as unspent from then on, so a spent record + // that is never re-checked refuses every later iteration until the tab + // is reloaded. + node.silence = () => node.submitted.length === 2; + node.onSubmit = (tx) => { + if (node.submitted.length === 2) node.evict(tx); + }; + await run(0, 1); + const dropped = node.submitted[1]; + expect(node.mempool.has(dropped.id)).toBe(false); + + // The next iteration still waits the timeout out (the spend could be a + // lagging index), then builds on what the dropped reveal had spent. + const started = Date.now(); + await run(1, 3); + expect(Date.now() - started).toBeGreaterThanOrEqual(200); + expect(node.submitted.length).toBe(6); + + const droppedInputs = dropped.inputs.map((i) => + outpointKey(i.previousOutpoint), + ); + const later = node.submitted + .slice(2) + .flatMap((tx) => tx.inputs.map((i) => outpointKey(i.previousOutpoint))); + expect(later.some((key) => droppedInputs.includes(key))).toBe(true); + // Everything the mempool still knows is spent exactly once. + expect(new Set(later).size).toBe(later.length); + }); + + test("a mempool query that fails for any other reason keeps failing closed", async () => { + const { node, run } = setup(200); + // Same eviction as above, but the node cannot answer the mempool query + // (transport error, not "not found"). The record must survive: reusing + // the outpoint on a guess is the double spend the set exists to prevent. + node.silence = () => node.submitted.length === 2; + node.onSubmit = (tx) => { + if (node.submitted.length === 2) node.evict(tx); + }; + await run(0, 1); + node.mempoolQueryFailure = + "RPC Server (remote error) -> RPC call timed out"; + + const error = await run(1, 2).then( + () => undefined, + (e: unknown) => e, + ); + expect(String(error)).toContain("still unconfirmed"); + expect(node.submitted.length).toBe(2); + + // Once the node answers again the retry goes through. + node.mempoolQueryFailure = undefined; + await run(1, 2); + expect(node.submitted.length).toBe(4); + }); }); diff --git a/tests/utxo-watcher-unit.spec.ts b/tests/utxo-watcher-unit.spec.ts new file mode 100644 index 00000000..519e7e5a --- /dev/null +++ b/tests/utxo-watcher-unit.spec.ts @@ -0,0 +1,113 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "@playwright/test"; +import init, { + Address, + IUtxoEntry, + PrivateKey, + RpcClient, +} from "@/wasm/core/kaspa"; +import { waitForUtxosChanged } from "@/lib/commit-reveal"; + +const TESTS_DIR = path.dirname(fileURLToPath(import.meta.url)); + +test.beforeAll(async () => { + await init({ + module_or_path: fs.readFileSync( + path.join(TESTS_DIR, "../assets/kaspa_bg.wasm"), + ), + }); +}); + +// Kaspa's utxos-changed subscription is a set of addresses on the connection, +// with no per-caller count. The reveal watcher orphaned by a broadcast failure +// keeps running until its timeout and then unsubscribes; a retry started in +// that window shares the address and used to lose its subscription. +test.describe("utxos-changed watchers sharing a connection", () => { + // Built inside each test: the WASM module is only initialised in beforeAll. + const addressOf = (key: string) => + new PrivateKey(key).toPublicKey().toAddress("mainnet").toString(); + const KEY_A = + "b7e151628aed2a6abf7158809cf4f3c762e7160f38b4da56a784d9045190cfef"; + const KEY_B = + "c90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b14e5c9"; + + class FakeSubscriptions { + subscribed: string[][] = []; + unsubscribed: string[][] = []; + listeners = new Set<(event: unknown) => void>(); + + async subscribeUtxosChanged(addresses: string[]) { + this.subscribed.push(addresses); + } + async unsubscribeUtxosChanged(addresses: string[]) { + this.unsubscribed.push(addresses); + } + addEventListener(_: string, listener: (event: unknown) => void) { + this.listeners.add(listener); + } + removeEventListener(_: string, listener: (event: unknown) => void) { + this.listeners.delete(listener); + } + emit(address: string) { + const added = [{ address: new Address(address) } as IUtxoEntry]; + for (const listener of this.listeners) { + listener({ type: "utxos-changed", data: { added, removed: [] } }); + } + } + asRpcClient() { + return this as unknown as RpcClient; + } + } + + test("an orphaned watcher's timeout does not unsubscribe a live one", async () => { + const ADDRESS = addressOf(KEY_A); + const node = new FakeSubscriptions(); + const orphan = waitForUtxosChanged( + node.asRpcClient(), + [ADDRESS], + () => false, + 50, + ); + const live = waitForUtxosChanged( + node.asRpcClient(), + [ADDRESS], + (added) => added.length > 0, + 1_000, + ); + + await expect(orphan).rejects.toThrow("Timeout"); + expect(node.subscribed).toEqual([[ADDRESS], [ADDRESS]]); + expect(node.unsubscribed).toEqual([]); + + node.emit(ADDRESS); + await live; + expect(node.unsubscribed).toEqual([[ADDRESS]]); + }); + + test("the last watcher standing unsubscribes only what it alone held", async () => { + const ADDRESS = addressOf(KEY_A); + const OTHER = addressOf(KEY_B); + const node = new FakeSubscriptions(); + const shared = waitForUtxosChanged( + node.asRpcClient(), + [ADDRESS], + (added) => added.length > 0, + 1_000, + ); + const pair = waitForUtxosChanged( + node.asRpcClient(), + [ADDRESS, OTHER], + () => false, + 50, + ); + + await expect(pair).rejects.toThrow("Timeout"); + expect(node.unsubscribed).toEqual([[OTHER]]); + + node.emit(ADDRESS); + await shared; + expect(node.unsubscribed).toEqual([[OTHER], [ADDRESS]]); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 6d9c6812..29729ea6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,5 +7,6 @@ "paths": { "@/*": ["./*"] } - } + }, + "exclude": [".output", "prtriage"] } diff --git a/wxt.config.ts b/wxt.config.ts index 7eacbc81..a0bd5707 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -10,17 +10,28 @@ import { readFileSync } from "node:fs"; // manual manifest edit. No new env var: ADR-003 already names CI. const qaVersionName = () => { if (process.env.CI) return undefined; - const { version } = JSON.parse(readFileSync("package.json", "utf8")); - const sha = execSync("git rev-parse --short=12 HEAD").toString().trim(); - return `${version}-qa-${sha}`; + try { + const sha = execSync("git rev-parse --short=12 HEAD", { + stdio: ["ignore", "pipe", "ignore"], + }) + .toString() + .trim(); + const { version } = JSON.parse(readFileSync("package.json", "utf8")); + return `${version}-qa-${sha}`; + } catch { + // Not a git tree (a source zip, a reviewer's download). This runs at + // config load, so throwing would break every wxt command: skip the stamp. + return undefined; + } }; +const versionName = qaVersionName(); // See https://wxt.dev/api/config.html export default defineConfig({ extensionApi: "chrome", modules: ["@wxt-dev/module-react"], manifest: { - ...(qaVersionName() && { version_name: qaVersionName() }), + ...(versionName && { version_name: versionName }), permissions: ["storage", "alarms", "clipboardRead"], content_security_policy: { extension_pages: