A non-custodial Kaspa app that runs in a browser faces a fundamental tension: the page that *proposes* a transaction and the code that *signs* it share the same JavaScript environment. If the signer blindly accepts whatever the page hands it, a compromised frontend — or a malicious backend — can redirect funds without the user ever seeing the real destination. Kaspa browser wallet security therefore depends on one architectural decision: the signer must *rebuild* the transaction from first principles and *verify* every output against independently observed on-chain facts, before a single byte is signed.
This article explains how that boundary works in practice, using Kaspa Forge's Desk and Arena stack as a concrete example. No consensus protocol changes are involved; the protection lives entirely in how the wallet code is structured.
The Problem: Why a Browser Is Not a Safe Place
A browser wallet is a strange beast. The same page that renders the UI also loads the cryptographic library. The same server that returns your UTXO set also suggests which outputs to create. If the signer trusts the page's proposal verbatim — "sign this JSON, it says send 50 KAS to address X" — then any XSS vulnerability, supply-chain attack on an npm dependency, or compromised API response can silently change address X to the attacker's address.
Traditional hardware wallets solve this with a trusted display: the device shows the real destination, and the user confirms physically. A browser has no such display. The "display" *is* the potentially compromised page.
The only honest answer is: do not trust the proposal. Rebuild it.
The Boundary: Rebuild Before You Sign
The core idea is simple to state and subtle to implement:
1. The page (or backend) provides a *description of intent* — "join this Arena room" or "send KAS to this address." 2. The signer independently fetches the current on-chain state — UTXOs, fee rates, room data. 3. The signer *rebuilds* the transaction from scratch using canonical rules. 4. The signer compares the rebuilt transaction byte-for-byte against any proposal it received. 5. Only if every field matches does it ask the user for a password and produce a signature.
This is not a new idea — hardware wallets do something similar with PSBT. But in a browser, the challenge is that the "device" and the "host" share memory. The boundary must therefore be enforced by *code structure*, not by a physical air gap.
What "rebuild" means concretely
Consider a Kaspa transaction. It has inputs (which UTXOs to spend), outputs (where the money goes), and a fee. A malicious proposal might:
- Change an output address from yours to the attacker's.
- Inflate the fee to redirect value to a miner the attacker controls.
- Include an extra input that belongs to a different wallet derivation path.
- Use a different script type that changes the mass — and therefore the fee calculation.
Rebuild means: the signer does not *read* these fields from the proposal. It *computes* them from independent sources — the node's UTXO index, the current fee estimate from the Kaspa network, and the canonical program rules — and then checks that the proposal's bytes are identical to what it computed.
The type-level door
In Kaspa Forge's implementation, the rebuild produces a ProgramRebuild object whose fields are *private* and whose only public constructor is the rebuild_program function — which runs the canonical game generator. There is no new(), no from_parts(), no Default, no Deserialize. If you have a ProgramRebuild, it came from the generator. If you don't run the generator, you don't have the object, and the signer won't proceed.
This is not a proof of correctness — Rust types don't prove provenance across process boundaries. But it eliminates the *accidental* path: a developer cannot hand-construct a "verified" room from fields they read off a wire. The type system makes the wrong thing unrepresentable in normal code.
Step by Step: What Happens When You Click "Sit Down"
Let's trace a concrete flow — joining an Arena Blackjack room — to see the boundary in action.
1. The lobby shows untrusted data
The Arena lobby fetches room listings from a public API. These listings include room addresses, stake amounts, and timeout values. This data is display only. The lobby treats it the way you treat a stranger's business card: useful for finding the room, but not proof of anything.
2. Desk prepares an intent
When you click "Sit down," Desk assembles a *display intent* — a description of what you want to do — and a one-time capability token. It does not build a transaction. It does not have access to your private keys at this stage.
3. The signer fetches independent facts
The signer-only WASM module (compiled from Rust, running in the browser) independently:
- Reads the room's on-chain UTXO from a Kaspa node.
- Fetches the current fee estimate (
getFeeEstimate.priority_bucket.feerate). - Rebuilds the program from the canonical generator, producing a
ProgramRebuildwith private fields. - Verifies that the room's script, value, and covenant parameters match the rebuilt program.
If any field diverges — wrong stake, wrong timeout, wrong script — the signer refuses with a specific error code. There is no "proceed anyway" option.
4. Exact output verification
The signer computes every output of the transaction:
- The room funding output (exact value from the program).
- The player bond (exact value from the program).
- The fee reserve (computed from worst-case path mass × current feerate).
- The change output (if any), back to a key the signer controls.
It then checks that the fee equals signed_mass × node_priority_quote — not a hardcoded constant, not a "reasonable estimate," but the exact product of the measured normalized mass and the live network rate. This prevents a class of bugs where a stale fee rate produces a transaction the mempool rejects.
For P2PK inputs, the signer also enforces the Toccata compute budget: P2PK_COMPUTE_BUDGET=10, because one Schnorr verification costs 100,000 script units. The signer executes each input locally through TxScriptEngine with the committed budget limit. A transaction that passes local execution with an unlimited engine but fails under the committed budget would be rejected by the node — and the signer catches this *before* signing, not after.
5. Password and signature
Only after all checks pass does the signer prompt for the user's password. The password decrypts the profile key material in memory. The signer produces a Schnorr signature, immediately zeroes the temporary key buffers (Uint8Array), and returns only the signed transaction envelope.
6. What the page sees
The React UI receives a *watcher snapshot* — public transaction data suitable for display. It never sees the private key, the seed, or the raw signing material. The backend receives the signed JSON and broadcasts it; it never had the ability to sign.
How Kaspa Forge Uses This Boundary
The signer boundary is not a single wall — it's a family of walls, each tailored to a product surface.
Arena Blackjack (public mainnet beta) uses the most elaborate version. The arena-wallet-wasm crate compiles into two mutually exclusive export sets: the Arena scene gets verify_program_and_build_join (admission only, no signing), while Desk gets the coarse signing exports (verify_program_and_sign_join, verify_program_and_sign_room_creation, verify_program_and_sign_game_move). A game move goes through the same rebuild-and-verify cycle: the signer checks *who* (the package is addressed to the correct role key) and *which game* (the gameId and roomId match the profile's invite identity). Everything else — position, card, deal, transaction bytes — is verified by the Rust signer against its own program opening, because you cannot judge correctness from a JSON display.
Kaspa Safe (/create.html) uses the same vault-core (currently v9) for its covenant-based time-locked vaults. The signer rebuilds the split transaction from the signed Toccata form and the live node feerate, applying exact fixed-point arithmetic — no legacy 0.01 KAS floor.
Kaspa Escrow (/escrow-index.html) and Deposit (/deposit-index.html) share the escrow-core (v5), which performs its own rebuild-before-sign for escrow funding and release paths.
Desk (/desk) is the encrypted browser profile that hosts all of the above. Keys never leave the device; the profile is encrypted at rest with a password-derived key. The wallet tab rebuilds every send transaction from fresh UTXO data and the current fee quote before signing.
Boards (/boards.html) and Marketplace (/market.html) use the same Desk wallet for on-chain posts and escrow-backed deals, inheriting the same signer boundary.
Every Kaspa Forge product — Safe, Escrow, Deposit, Marketplace, Boards, and Arena Blackjack — runs through the same Desk signer boundary. Keys stay on your device; the signer rebuilds and verifies before it signs. The full architecture is documented at Kaspa Forge architecture.
Trade-offs and Honest Limitations
No design is without trade-offs. Here is what this boundary does *not* protect against, stated plainly.
The browser itself is the trust boundary. If the browser's JavaScript engine or WebAssembly runtime is compromised — for example, by a malicious browser extension with broad permissions — the attacker can read memory, including decrypted keys. The signer boundary protects against a compromised *backend* or *frontend code*, not against a compromised *execution environment*. This is the same limitation every browser wallet shares.
The node is a dependency. The signer fetches UTXO data and fee rates from a Kaspa node. If the signer's node connection is intercepted (e.g., via DNS hijacking or a compromised RPC endpoint), the signer could make decisions based on stale or fabricated data. Kaspa Forge mitigates this by using multiple node endpoints for critical operations — Arena reveal broadcasts, for instance, must succeed on both a primary and a secondary node — but the general problem remains: the signer is only as honest as its data source.
The generator must be run. The type system prevents *accidental* construction of a fake ProgramRebuild, but it cannot prevent a determined attacker from patching the WASM binary or the Rust source. The guarantee is: if you run the *unmodified* signer code, you get the *canonical* program. Verifying that the code is unmodified is a separate problem — one addressed by reproducible builds and open-source auditing, not by the signer boundary itself.
Fee rate volatility. The signer pins the fee to signed_mass × feerate at signing time. If the network fee rate changes between signing and mempool acceptance, the transaction may sit in the mempool longer than expected. This is not a security issue — the transaction is still valid — but it is a liveness issue for time-sensitive operations like Arena room joins. The admission cutoff (≤150 sompi/gram) and per-branch fee caps exist to bound this risk.
Product status matters. The signer boundary described here is live technology, in production use across Desk, Safe, Escrow, Deposit, Marketplace, Boards, and Arena Blackjack (public mainnet beta). Dice is engineering work with its permanent production arm off pending review. Duel and baccarat are roadmap ideas, not products. Permissionless Tokens is planned and not live. None of these non-live surfaces should be treated as available services.
The observation gap. The signer can verify what the *node* tells it, but it cannot independently verify that the node is honest. The admission crate makes this limit explicit: the RoomObserver trait returns raw artifacts (script bytes, outpoints, amounts), and the signer rebuilds against them — but if the observer returns fabricated data, the signer will faithfully verify against fiction. The mitigation is open-source code, multiple node sources, and the user's own node if they choose to run one. The gap is real, and naming it honestly is better than pretending it doesn't exist.
FAQ
What is rebuild-before-sign in a Kaspa browser wallet?
The wallet does not trust the transaction proposed by the frontend or backend. It independently fetches on-chain data (UTXOs, fee rates, program rules), reconstructs the transaction from scratch, and verifies every field byte-for-byte before producing a signature. This prevents a compromised page from silently redirecting funds.
Can a Kaspa Forge backend sign transactions on my behalf?
No. The backend never has access to your private keys. It can propose a transaction structure — for example, "here is a room to join" — but the signer in your browser rebuilds and verifies the transaction independently. The backend receives only the already-signed envelope for broadcast.
What happens if the Kaspa network fee changes between proposal and signing?
The signer fetches the live fee rate at signing time and computes fee = signed_mass × feerate exactly. If the fee rate has changed since the proposal was created, the rebuilt transaction will differ from the proposal, and the signer will refuse — forcing a fresh proposal.
Is the browser itself a security risk for Kaspa wallet keys?
Yes. The signer boundary protects against compromised frontend code or backend proposals, but not against a compromised browser environment such as a malicious extension with memory access. This is a fundamental limitation of all browser-based wallets. Keys are encrypted at rest and zeroed from memory after use.
How does Kaspa Forge verify Arena game moves?
Each game move goes through the same signer boundary. The signer checks that the move is addressed to the correct player key from the profile's canary identity and belongs to the correct game and room. The transaction is rebuilt and verified by the Rust signer against the program's canonical opening, independent of what the UI displays.
Does Kaspa Forge store my private keys on its servers?
No. Keys are derived from your seed phrase, encrypted with your password, and stored only in your browser's local storage. The encrypted profile can be backed up, but the backup is encrypted. Forge Sync explicitly excludes Arena canary keys — they exist only on the device that created them.
Put your KAS where theft can be cancelled
A covenant vault on Kaspa mainnet: your keys, your rules, our tooling. Free on-chain, forever.
Create a vault
