A Kaspa wallet that holds funds across safes, escrow deals, and Arena game rooms needs dozens of keys. Storing each one separately would be fragile: lose one file and those funds are gone. The standard answer — used by Bitcoin wallets since BIP-32 and adapted here for Kaspa's UTXO model — is to derive every key from a single master seed. One backup covers everything, past and future.
This article walks through how Kaspa Forge Desk implements that derivation, how the gap-scan restore mechanism finds all on-chain activity without the server ever holding a private key, and where the design makes deliberate trade-offs.
The master seed
When a user creates a Desk profile, the browser generates a master seed through WebCrypto's getRandomValues. This seed is the single root of every key the wallet will ever produce. It lives inside the encrypted profile (schema v3), which is itself sealed under a user-chosen password using the age encryption format.
The seed never leaves the browser in plaintext. The only portable copy is the .age key-file export, encrypted with a passphrase. If the service disappears tomorrow, that file plus any Kaspa node is enough to recover every key.
Domain-separated derivation: a technique where the same master seed produces completely independent key sets by mixing a different string (the "domain") into each derivation call. Vault keys, wallet keys, and Arena keys all come from one seed but cannot be confused with each other.
How derivation works step by step
The core lives in the derive.rs module of the kaspa-safe-core Rust crate, compiled to both native (server-side tools) and WebAssembly (browser Desk). The function is straightforward:
HMAC-SHA512(key = master_seed, message = "kaspaforge/v1/<domain>/<index>")
The first 32 bytes of the 64-byte HMAC output become the derived secret key. The remaining 32 bytes are discarded.
The domain string is what separates one key family from another:
| Domain path | Purpose | Used by |
|---|---|---|
kaspaforge/v1/vault/0, …/1, …/2 | Safe vault keys (hot, alarm, funding) | Safe creation, manage |
kaspaforge/v1/wallet/0, …/1, … | Wallet receive addresses | Wallet tab, Send |
arena/canary/dealer/0 | Arena dealer identity | Arena room creation |
arena/canary/player/0 | Arena player identity | Arena join |
kaspaforge/v1/vault~token/0 | Token-related keys (planned) | Not live |
| Kasia even-parity subspace | Chat encryption keys | Encrypted chat |
The index after the domain is a simple incrementing counter. For vault keys, the first safe uses index 0, the second uses index 1, and so on. The architecture notes confirm that "the first backup covers future safes" — because the seed already contains the material for keys that have not been derived yet.
This is not BIP-44 derivation. There is no hardened/unhardened split, no purpose/coin/account levels. The path scheme is Kaspa Forge-specific, and the Kaspa wiki notes that Kaspa's developer tooling uses its own conventions rather than inheriting Bitcoin's full BIP-32 tree. The trade-off is simplicity and explicit domain isolation at the cost of cross-wallet compatibility.
Why domain separation matters
Consider what would happen without it. If vault key index 0 and wallet key index 0 were derived from the same HMAC input, they would be the same key. A transaction spending from the wallet could accidentally sweep a vault UTXO, or an Arena game move could drain the user's regular balance.
Domain strings make this impossible. The HMAC for "kaspaforge/v1/vault/0" and "kaspaforge/v1/wallet/0" produce unrelated 256-bit keys even though they share the same master seed and the same index. The Arena subsystem takes this further: its derive_canary_keyset function produces keys under arena/canary/dealer/0 and arena/canary/player/0, and the resulting child-key vault is explicitly excluded from the regular wallet/walletOld address set. The general Send function cannot see Arena UTXOs, and Arena signing cannot access wallet funds.
The wallet address pool
A single Desk profile may control many addresses. The current receive address is stored in the profile field wallet. Previous addresses — from before the user rotated — are stored in walletOld (an array). Desk does not require a manual "sweep" or "collect" operation when the address changes. Instead, the wallet tab displays a balance that is the sum of UTXOs across all derived addresses:
displayed_balance = Σ UTXOs(wallet) + Σ UTXOs(walletOld[0]) + Σ UTXOs(walletOld[1]) + …
When the user sends KAS, the transaction builder (build_wallet_split in the v9 WASM core) selects UTXOs from across the entire pool. Each input is signed with the key that owns it — the key derived for that specific address. Change always goes to the current receive address, so funds naturally consolidate over time without an explicit consolidation step.
This design mirrors how Kaspa's UTXO model works at the protocol level: there is no "account balance" in the DAG, only discrete unspent outputs. The wallet merely aggregates them for display and spending. Kaspa's fee model charges per UTXO consumed, which is why the Desk WASM core reads a fresh fee-rate quote from the node before every send and computes the exact fee as mass × quote without an artificial floor.
Gap scanning: finding activity during restore
Here is the core problem of HD wallet restore: given a seed, how do you know which derived addresses have ever received funds?
The brute-force approach — check every possible index — is impractical. If the user has used 5 addresses out of a vast keyspace, scanning all of them wastes time and bandwidth. The standard solution is gap-limit scanning: derive addresses in order, check each one for activity, and stop after encountering a configurable number of consecutive unused addresses (the "gap").
Desk implements this in two layers.
Layer 1: Client-side derivation
The restore.js module in the browser takes the master seed and begins deriving addresses sequentially for each domain (vault keys, wallet keys). For each index, it:
1. Computes the HMAC-SHA512 derivation. 2. Extracts the public key. 3. Derives the Kaspa address.
This happens entirely in the browser. Private keys are not transmitted.
Layer 2: Server-assisted matching
The browser sends batches of public keys — not addresses, not private keys — to the endpoint POST /api/safe/restore/scan. The server-side restore_api.rs module checks each public key against known activity. Critically, the server matches by an exact pk+token pair — it is not a general-purpose oracle that reveals whether any arbitrary address exists on-chain. The token is the user's profile token, which authenticates the request without exposing the seed.
If a match is found, the server reports which indices are active. The client then knows to keep scanning past that point. When a full gap of unused addresses is encountered, scanning stops.
The "⟲ Rescan" button in the Desk UI triggers a forced rescan. Automatic scanning runs once per device on first unlock.
Why the server helps (and what it cannot see)
A purely client-side scan would need to query a Kaspa node for every derived address. Kaspa nodes index the UTXO set, not full address history. Checking transaction history requires a separate indexer, which Desk accesses through a proxy endpoint (GET /api/safe/tx-history) to avoid leaking the client's IP to the indexer directly.
The server-assisted scan short-circuits this: instead of the client making dozens of node queries, it sends public keys in one request and gets back the set of active indices. The server never sees private keys, never sees the seed, and cannot sign transactions.
How the pieces fit across Kaspa Forge products
The single-seed, domain-separated design means every Kaspa Forge product that needs keys gets them from the same encrypted profile:
- Kaspa Safe: vault keys for hot wallet, alarm key, and funding key are derived under
kaspaforge/v1/vault/<index>. Each safe gets its own index. The alarm key can optionally live on a physical card outside the profile entirely.
- Kaspa Escrow and Deposit: escrow deal keys share the same derivation infrastructure. The escrow WASM core and the production Desk core both read from the same master seed.
- Arena (public mainnet beta): canary keys for dealer and player identities are derived under
arena/canary/dealer/0andarena/canary/player/0. These keys are stored in the encrypted profile'sarenaCanaryKeysfield but are excluded from Forge Sync — they exist only on the device where they were generated. This is a deliberate security boundary: if a synced copy of the profile were compromised, Arena game funds would not be at risk.
- Boards: post signing uses the wallet's current key. No separate derivation domain is needed because board posts are standard P2PK signatures.
- Marketplace: listing ownership ties to the Desk profile identity. The marketplace itself does not hold funds — escrow deals handle that — so no separate key domain is required.
One seed, one backup, every product. Kaspa Forge Desk derives all keys — wallet, vault, escrow, Arena — from a single master seed stored in your encrypted browser profile. The .age key-file export is the only backup you need. Restore it on any device, run a gap scan, and every safe, deal, and address reappears. Keys never leave your device. Open Desk →
Trade-offs and honest boundaries
Custom path scheme, not BIP-44. The kaspaforge/v1/… derivation paths are not compatible with other Kaspa wallets. If you import your seed into a different wallet application, it will not find your funds unless that wallet implements the same HMAC-SHA512 domain scheme. This is a conscious trade-off: explicit domain isolation and simplicity over interoperability. The open-source vaultctl CLI and the published WASM core document the exact derivation for anyone building compatible tools.
Gap limit is finite. Scanning stops after a configurable number of consecutive unused addresses. If a user generated many addresses without using them — for example, by repeatedly clicking "new address" during testing — the gap could be exceeded and some older active addresses might be missed. The "⟲ Rescan" button exists for this case, but it requires the user to know something might be missing.
Server sees public keys during restore. The scan endpoint learns which public keys belong to a given profile token. This is a privacy trade-off: the alternative — querying the node for every derived address — leaks even more information (IP plus address pattern) to the indexer. The server-assisted approach concentrates the exposure into a single authenticated request.
Arena canary keys are device-local. Forge Sync replicates the encrypted profile across devices but explicitly excludes arenaCanaryKeys. If you switch devices, Arena identity does not transfer automatically. This protects game funds from sync-related compromise but means Arena players must re-establish identity on a new device.
No key rotation without address change. Kaspa's UTXO model means that "rotating a key" is equivalent to generating a new receive address. Old addresses remain valid and their UTXOs remain spendable, but funds do not move automatically. The wallet aggregation across wallet plus walletOld handles this transparently for the user, but the address pool grows monotonically.
WASM core versioning. The derivation logic lives in derive.rs, compiled into versioned WASM snapshots (v7, v8, v9). Production Desk uses v9. Each snapshot is immutable once deployed — a new derivation scheme would require a new version directory and explicit migration. Pinned known-vector tests in derive.rs guard against accidental drift between versions.
---
The fundamental insight is that a single seed, combined with domain-separated HMAC derivation, can securely partition keys across unrelated product surfaces — wallet, vault, escrow, game — without any surface being able to forge another's keys. Gap scanning then makes the seed a practical recovery mechanism, not just a theoretical one. The trade-offs — custom paths, finite gap, server-assisted scan — are visible and documented, which is exactly how a non-custodial system should work: you can verify every claim against the open-source code.
FAQ
What is Kaspa HD wallet derivation?
HD (Hierarchical Deterministic) derivation generates all wallet keys from a single master seed using a deterministic function. In Kaspa Forge Desk, this uses HMAC-SHA512 with domain-separated paths so that vault, wallet, escrow, and Arena keys never overlap.
How does gap scanning work when restoring a Kaspa wallet?
The wallet derives addresses sequentially from the master seed and checks each one for on-chain activity (UTOs or transaction history). When a configurable number of consecutive unused addresses is found, scanning stops. The server assists by matching public keys against known activity without ever seeing private keys.
Can I restore my Kaspa wallet from a backup created before new safes were added?
Yes. Because all keys derive deterministically from the same master seed, a backup of the seed — stored in the .age key-file — can regenerate every key created before or after the backup was made. Gap scanning then discovers which derived addresses have on-chain activity.
Do Arena game keys share the same derivation path as my wallet keys?
No. Arena canary keys use separate domain paths (arena/canary/dealer/0, arena/canary/player/0) so that the general wallet send function cannot accidentally spend Arena UTXOs, and Arena signing cannot access regular wallet funds.
Is my private key ever sent to the server during a restore scan?
No. The browser derives addresses locally and sends only public keys. The server matches them against known activity using an exact pk+token pair — it is not a general oracle that reveals whether an address exists on-chain.
What happens if I change my receive address — do I lose access to old addresses?
No. Desk sums UTXOs across all derived addresses (wallet and walletOld). Each UTXO is spent with its own key in a single transaction. There is no separate sweep step required.
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
