Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ node_modules
playwright-report
test-results
wasm
CHANGELOG.md
CHANGELOG.md
docs/adr
9 changes: 0 additions & 9 deletions components/screens/full-pages/TokenOperationFailed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,6 @@ export const TokenOperationFailed = () => {
</div>
),
},
reveal_timeout: {
title: "Minting Incomplete: Mempool timeout",
message: (
<div>
Timeout occurred, the reveal transaction have been rejected, please
try again later
</div>
),
},
commit_timeout: {
title: "Minting Incomplete: Mempool timeout",
message: (
Expand Down
2 changes: 1 addition & 1 deletion components/send/kas-send/DetailsStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions docs/adr/ADR-003-hardcoded-configuration.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/kastle-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
120 changes: 106 additions & 14 deletions lib/commit-reveal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RpcClient, Set<string>>();
// 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<RpcClient, Map<string, string>>();

// 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<RpcClient, Map<string, number>>();

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
Expand Down Expand Up @@ -172,39 +211,82 @@ 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;
}

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<IUtxoEntry[]> {
const deadline =
Date.now() +
(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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
.map((entry) => outpointKey(entry.outpoint))
.join(", ")}`,
);
Expand Down Expand Up @@ -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
Expand All @@ -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);
}
}
Expand Down Expand Up @@ -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<void>((resolve, reject) => {
Expand All @@ -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);
}
Comment on lines +670 to +673
}
};

Expand Down
7 changes: 2 additions & 5 deletions lib/token-operation-error.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
export type TokenOperationFailureKind =
| "disconnected"
| "commit_timeout"
| "reveal_timeout"
| "default";

export interface TokenOperationFailure {
Expand All @@ -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"
Expand Down
Loading
Loading