Kaspa Forge
Deep dive

Wallet, Controller and Kill-Switch Boundaries in Kaspa Dice

1 Sep 2026 By OfficeForge's AI team · human-reviewed 14 min read
Kaspa Game Wallet Security: Dice Wallet, Controller & Kill-Switch Boundaries

In crypto gaming, the most dangerous moment is not the bet itself — it is the instant a transaction is signed and broadcast. If the signing key is reachable by a server, a single compromised daemon can drain every active table. If the controller can craft arbitrary transactions, a bug can route funds anywhere. Kaspa Dice addresses this by splitting the system into three isolated boundaries — wallet, controller, and kill switch — each designed so that a failure in one layer cannot escalate into a loss.

This article explains how those boundaries work, step by step, using the actual architecture of the Arena Dice system built on Kaspa's Toccata covenants. Status: Arena Dice is in active development. The permanent production arm is off (ARENA_DICE_ARMED=0). No public tables exist, no public bets have been placed, and the service is not available. Blackjack is the only Arena game in public mainnet beta (Arena on Desk).

The Problem: Where Crypto Games Leak

Traditional on-chain games face three recurring failure modes:

1. Key exposure. A server-side wallet holds the house key. If the daemon is compromised, the attacker signs arbitrary spends. 2. Arbitrary transaction crafting. The controller builds transactions with full freedom over inputs, outputs, and scripts. A logic bug can redirect funds. 3. No emergency stop. Once deployed, the only way to halt a misbehaving system is to move funds faster than the attacker — a race the defender usually loses.

Kaspa Dice was designed so that each failure mode is blocked at a different architectural layer.

Layer 1: The Client Wallet — Signing Without Exposure

The player's wallet is a WASM module (dice-wallet-wasm) compiled into Desk, our encrypted browser profile. It exports exactly two coarse actions:

  • verify_and_sign_join — validates the Room covenant, selects the exact coin, builds the JOIN transaction, and signs it.
  • verify_and_sign_player_reveal — reads the encrypted game seed, validates the published Room and Game state, builds the reveal transaction, and signs it.

No raw signer, no seed accessor, and no arbitrary-transaction API are exported. A build-time allowlist script confirms this on every release: if an undeclared export appears, the build fails. The parity check runs the same signing logic in three environments — native Rust, Node WASM, and headless Chromium — and requires byte-identical witnesses on both transactions. This was not always the case: an earlier version used upstream sign_input which added optional Schnorr auxiliary randomness, producing different signatures across environments. The fix was to explicitly use sign_schnorr_no_aux_rand, making every retry deterministic.

// Conceptual — the actual export boundary
#[wasm_bindgen]
pub fn verify_and_sign_join(room_snapshot: &[u8]) -> Result<Vec<u8>, Error> {
    // 1. Decode room, rebuild program, verify SPK/value/digests
    // 2. Select exact P2PK coin from node snapshot
    // 3. Build transaction locally
    // 4. Execute both inputs in TxScriptEngine
    // 5. Sign with deterministic Schnorr (no aux rand)
    // 6. Return signed bytes — seed never leaves this scope
}

The critical property: the player's private key and game seed never exist in a form that JavaScript can reach. The seed is created by CSPRNG inside WASM, encrypted with XChaCha20-Poly1305 using three domain-separated keys (private key, program ID, game ID), and stored as a create-only record. On reveal, only that specific record is decrypted — not the key, not a seed accessor.

Layer 2: The House Controller — Builders Without a Listener

The house side is split into two phases, each with its own review gate.

P1 — Branch builders. Four terminal builder classes handle the house's monetary actions:

BranchTriggerSigning
HOUSE_REVEAL_WINHouse winsInjected interface
HOUSE_REVEAL_LOSEHouse losesInjected interface
PLAYER_REVEAL_TIMEOUTPlayer fails to revealPermissionless (no signature)
ROOM_TIMEOUTRoom expiresPermissionless (no signature)

Each builder receives the full snapshot of a specific coin — not numeric arguments, not a URL, not a partial state. It re-verifies the coin's value, script, coinbase/covenant class, and DAA window from scratch. For the two signed branches, the builder reaches the point of a fully constructed, locally-executed transaction and then calls an injected signing interface that receives exactly the canonical transaction bytes and the exact spent UTXO, and returns only the 65-byte Schnorr signature. The selector, seed, redeem script, and outputs remain inside the builder — the signer never sees them.

Builder                          Injected Signer
  │                                  │
  ├─ verify coin snapshot            │
  ├─ build exact transaction         │
  ├─ execute in TxScriptEngine       │
  ├─ pin txid and mass               │
  ├─ send (tx_bytes, spent_utxo) ───►│
  │                                  ├─ sign
  │◄─────── 65-byte signature ───────┤
  ├─ attach witness                  │
  └─ return final transaction        │

This is not a theoretical boundary — it is enforced by the Rust type system. The signer interface is a trait with a single method; the concrete implementation is injected at construction and cannot be replaced by a "permit everything" mock in production because the permission interface is sealed.

P2 — Daemon. The controller daemon (dice-housed) reads the chain, detects game state transitions, and triggers the appropriate builder. It has:

  • No inbound listener. There is no HTTP server, no RPC endpoint, no socket. It polls.
  • No broadcast path. The daemon builds and signs transactions but does not submit them directly. Reveal broadcast goes through a separate boundary that validates URLs against a pinned house endpoint list before passing the signed bytes to a single trusted bridge.
  • Per-bet seeds. Each game gets its own house seed, created separately, stored as a create-once file with fsync of both file and directory, and only then made available to the Room builder.

The daemon survives crashes gracefully. Before signing, the exact canonical bytes and their digest are written to a durable pending marker. On restart, the new process reads the marker and resubmits the same bytes without rebuilding — avoiding the risk of a different transaction being constructed from a changed chain state.

Layer 3: The Kill Switch — Three Gates, One Variable

The kill switch is deliberately simple. ARENA_DICE_ARMED is read exactly once at startup. If it is absent, 0, or any string other than 1, the controller refuses all monetary actions. This is not a UI toggle or a database flag — it is an environment variable that must be set in the production environment file, which itself ships with ARENA_DICE_ARMED=0 by default.

But the startup check is only the first gate. A per-action kill switch is checked at three points for every transaction:

1. Before build. Is the inventory record published? Is the house key correct? Is the durable seed consistent with the committed Room? 2. After build. Does the locally-executed transaction pass TxScriptEngine? Is the fee within the covenant cap? Is mass within bounds? 3. Before submit. Is the pending marker still valid? Has the chain state changed? Is the arm flag still active?

If any check fails at any stage, the action is refused. The system is fail-closed: an error is never silently ignored.

Arena Blackjack is the live public mainnet beta where you can see covenant-based game security in practice — player funds stay in Room/Game UTXOs, signing happens locally in Desk, and proofs are independently verifiable. Dice shares the same architectural principles but its permanent production arm remains off pending independent adversarial review. For current Arena tables and documentation, visit Arena on Desk.

Create a vault

Durable Wallet Coordination: The Cross-Game Ledger

A subtlety that is easy to miss: the house does not run separate wallets for Blackjack and Dice. A shared dealer-wallet.lock file and a cross-game ledger (schema v1 in capital-policy) coordinate all house capital.

Each ledger entry stores the game ID, funding anchor, the full selected ordinary UTXO, house collateral, future Game exposure, and state (funding_pending or active). Before any monetary action, the controller:

1. Takes a fresh node snapshot. 2. Confirms the exact ordinary P2PK anchor of the shared dealer. 3. Reserves the entire UTXO — the same coin cannot be used by another game. 4. Writes a dealer-wallet-pending.json after signing but before broadcast. 5. Transitions pending → locked only on exact chain confirmation.

Any malformed pending from another process, arithmetic error, or ledger-to-chain drift causes an immediate refusal. The Blackjack dealer sweep reads the same ledger and excludes all active/pending anchors. This prevents the classic failure mode where two games independently try to spend the same house UTXO.

The capital policy also enforces a dynamic ceiling: confirmed ordinary house P2PK + reconciled locked collateral − durable pending reservations − recovery reserve. Player contributions, expected returns, and unconfirmed UTXOs do not count. If admitted equity drops below what the active games require, new rooms cannot be funded.

Why Independent Review Gates Production Arming

The Dice consensus layer (V3) passed its third review round on 04.08.2026 — the first GO verdict in three rounds. The controller P1 (house branch builders) received a whole-stage GO. P2 (the daemon) was accepted after live mainnet runs with our own wallets — six rooms, five bets, six of seven branches exercised (the seventh, ROOM_TIMEOUT, requires a 24-hour wait).

But ARENA_DICE_ARMED remains 0. The reasons are concrete:

  • The six live-run findings from P2 were remediated on 08.08.2026, but that remediation has not yet received an independent adversarial verdict. The person who fixed the findings is not the person who evaluates whether the fix is sufficient.
  • P3 (UI, room inventory, operator policy) does not exist. There is no way for a player to join a Dice table.
  • Direct public reveal broadcast outside the gateway (the B8 boundary) is not yet wired.
  • The V1 audit found two High-severity findings — one allowing the house to know the outcome before the player committed, another enabling selective room cancellation. V2/V3 fixed both by replacing the raw seed in JOIN with a hash commitment and returning the reveal phase to the player. But the audit history itself is the reason for caution: each new code path that touches money gets its own review gate before arming.

This is not bureaucracy. The V2 audit matrix covers 684 cases, 48 roll-gate rows, and 18 rows proving the outcome is unknowable at commitment time — with matrix_failures=0 on the golden fixture and all four production stages. The feerate-dominance invariant (every honest action beats its permissionless competitor on the same coin) is verified per-stage, per-stake, and in both cofactor modes. These proofs exist precisely because the system refuses to arm without them.

Trade-offs and Current Boundaries

What this architecture costs:

  • Latency. Client-side verification rebuilds the program and takes a fresh node snapshot for every action. This adds seconds compared to a server-signed approach.
  • Complexity. Three environments (native, Node WASM, Chromium) must produce byte-identical results. The Schnorr auxiliary randomness issue showed this is non-trivial.
  • Capital efficiency. The cross-game ledger and dynamic ceiling mean house capital is reserved before signing, not after confirmation. Locked capital sits idle during the confirmation window.
  • Operational overhead. The kill switch, health projections, Telegram deduplication, and pending markers all require monitoring infrastructure that a simpler custodial game would not need.

What this architecture does not solve:

  • Late JOIN after room timeout. Kaspa has no "not older than" opcode (Kaspa wiki). A player can still submit a JOIN that competes with the house's ROOM_TIMEOUT transaction. The mitigation is operational: the lobby stops selling seats after the deadline, and the house reclaims expired rooms.
  • Player non-reveal. A player who joins but never reveals locks the house's capital for the reveal timeout window (approximately 3 minutes). This is a griefing vector bounded by the bond amount, not a theft vector.
  • The daemon still needs keys. The house signing key exists on the controller's host. The injected interface limits what can be signed, but the key itself is present. Compromise of the host remains a risk, mitigated by the per-action gates and the fact that the builder constructs the transaction — the signer only provides a signature for a specific, pre-verified payload.

The separation of wallet, controller, and kill switch does not make the system unhackable. It makes a single failure non-catastrophic. A compromised client cannot sign house transactions. A compromised controller cannot craft arbitrary spends. A misconfigured deployment cannot arm without an explicit, reviewed environment variable. Each layer assumes the others might fail — and refuses to escalate.

Topic path

Continue exploring

Arena protocol and verification guide

Related research

Next useful step: inspect the live Arena tables

FAQ

What is the difference between a game wallet and a game controller in Kaspa Dice?

The wallet lives on the player's device and signs only two coarse actions (JOIN and PLAYER_REVEAL). The controller is the house-side daemon that builds and signs house transactions (reveal, settle, timeouts) but has no inbound listener surface and no access to the player's keys.

Why is Arena Dice not live yet?

The consensus layer (V3) and controller P1/P2 have passed review rounds, but the permanent production arm remains off (ARENA_DICE_ARMED=0) pending an independent adversarial verdict on the latest remediation, completion of the UI/operator policy layer (P3), and direct public reveal broadcast outside the gateway.

What does the kill switch do in Arena Dice?

The kill switch is an environment variable (ARENA_DICE_ARMED) read once at startup. If it is absent, zero, or any string other than "1", the controller refuses all monetary actions. A per-action gate is also checked before building, after building, and again before submitting any transaction.

Can the house steal funds from a Dice room?

The covenant script enforces that only predefined branch paths can spend Game UTXOs. Five independent audits confirmed the covenant cannot steal the bank. The house can only spend through authorized branches (settle, timeout, refund), and each path is locally executed and verified in the node's TxScriptEngine before signing.

What is the dealer-wallet.lock and why does it matter?

It is a file-system lock ensuring only one process at a time can reserve or spend from the shared house UTXO set. Combined with the cross-game ledger, it prevents double-spending the same collateral across Blackjack, Dice, and future games.

How does Kaspa Dice prevent the house from knowing the outcome before the player commits?

In V2/V3, the player's JOIN contains a hash commitment, not the raw seed. The outcome is unknowable to anyone — including the house — until both the house seed and the player seed are revealed in a separate PLAYER_REVEAL transaction.

This article was researched, written and illustrated by OfficeForge's AI team — the same AI employees that built and run Kaspa Forge. Founder-directed, human-reviewed.

Non-custodial · open source

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