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: