A consensus-anchored, state-in-script vault design for Kaspa — written for readers who want to understand the mechanism end-to-end. Targets the post-Toccata mainnet design (KIP-10 + KIP-20 covenant IDs).
Status — TN12 today emits this wire shape; mainnet flips at Toccata activation. This brief describes the post-Toccata Kaspa wire shape (transaction version 1, on-wire CovenantBinding, consensus-tracked covenant IDs). FrostCard ships after Toccata activates, so this is the wire shape every real user will ever see. TN12 (the public testnet) runs the post-Toccata rules today, and FrostCard emits this exact wire shape on TN12 right now — every test, probe, and dev build that touches TN12 exercises the same bytes mainnet will accept post-flip. Mainnet itself still runs pre-Toccata rules at the time of this writing; the wallet's mainnet code path stays at tx.version = 0 until the Toccata hard fork ratifies (~June 2026), at which point a single named constant (KIP20_MAINNET_ACTIVE) flips to true and mainnet picks up the post-Toccata shape. No other code change is required for the cutover — the activation point is one line, gated by a tripwire test that fails the moment it's edited.
A FrostCard vault is a Kaspa P2SH address whose redeem script is the policy. Once you fund that address, the chain itself enforces who can move the coins, where they can go, and when.
Every FrostCard policy is built from a combination of these three primitives. Most users want one or two; some want all three.
Owner-signed spends, but every non-change output must match a pre-committed address. Up to 3 entries. Change (recursive self-spend) stays in the vault.
After a long inactivity window (CSV), an irrevocable sweep moves all funds to a heir address baked into the script at deploy. No heir signature required; anyone can push it.
Funds locked until a future DAA score. Nobody spends before then — not the owner, not the heir, not with all cards present. Permanent by design.
Variant names you will see in code and tests: OwnerHeir, OwnerHeirWhitelist, OwnerHeirFreeze, OwnerHeirWhitelistFreeze, plus a heirless OwnerWhitelist. Each has its own redeem script template; they share a Rust compiler in frostcard-core.
The defining choice of the FrostCard design is that policy state lives in the redeem script, not in a payload. There is no "covenant configuration document" floating somewhere. The vault address is a hash of the script; the script is the configuration.
Three consequences of this choice are worth internalising:
blake2b-256(redeem_script). Onlookers see a hash; they do not see the heir address, the whitelist entries, or the CSV delay.A FrostCard vault is created in a single Kaspa transaction with three outputs. The shape is fixed; the recovery layer depends on it.
covenant field is what KIP-20 will track on mainnet post-Toccata.tx.version = 1Kaspa today defaults to tx.version = 0. KIP-20 introduces a covenant field on transaction outputs and gates it on v ≥ 1. Once the Toccata hard fork activates, FrostCard deploys must emit v1 transactions or the vault output's covenant binding will fail consensus.
For a v2 vault, the consensus-tracked identifier is computed at the moment of deploy:
covenant_id = BLAKE2b-256(
key = "CovenantID",
data =
O.tx_id (32 bytes)
|| le_u32(O.index)
|| le_u64(N) (count of authorizing outputs)
|| for each (i, out) in auth_outputs sorted by i:
le_u32(i)
|| le_u64(out.value)
|| le_u16(out.script_public_key.version)
|| le_u64(len(out.script_public_key.script))
|| out.script_public_key.script
)
The genesis ID binds the vault to its deploy outpoint plus the byte content of every authorizing output. Forging a vault that claims to be in the same lineage is uncreatable, not merely unspendable.
The inscription transport solves the "policy hidden until first spend" problem. We adapted the KRC-20 / Kasplex inscription pattern: every deploy emits a tiny extra P2SH output whose redeem script encodes the full vault redeem inside a dead branch. A follow-up reveal tx spends that dust output and, by Kaspa's hash-equality rule for P2SH, pushes the inscription redeem onto the chain in plain bytes — permanently, indexably.
Kaspa enforces MAX_SCRIPT_ELEMENT_SIZE = 520 bytes per push at parse time, even inside dead branches. The largest variant (OwnerHeirWhitelistFreeze with three whitelist entries) is 686 bytes of redeem, so the encoder splits the inner redeem into N ≤ 520-byte chunks preceded by a single-byte chunk count. The decoder reassembles in order. Live-proven with 758-byte and 770-byte inscriptions on TN12 (probes R-003, R-005).
The decoder accepts an inscription only if both hash gates verify. Either one failing returns None; the scanner silently discards the candidate. Both hashes are consensus-committed; neither is forgeable.
This dual-gate design is what lets us trust an inscription scraped from any RPC endpoint without trusting the endpoint. We do not rely on the mempool to filter adversarial bytes — adversarial bytes can confirm — we rely on the decoder rejecting them at parse time.
A funded vault has two execution paths inside the redeem script. Which one runs depends on which witness is provided in the spending transaction's signature script.
The whitelist and the heir address are mutable, but every change is a public, on-chain proposal that takes 14 days to apply and is cancellable for the entire window. Freeze is not mutable.
It is intrusion detection plus user response time. If an attacker grabs the cards and tries to redirect funds, the proposal sits on chain for 14 days before it takes effect. The real owner has that window to notice and cancel from a backup or recovery path. Without this, theft equals instant redirection. With it, theft of the cards is no longer sufficient — the thief also has to keep the owner from seeing and cancelling for 14 days.
While funds are at the staging address, the wallet's home screen still folds them into the headline KAS balance and adds a yellow staging annotation next to the unit. A "Funds location" strip below the balance breaks down vault vs staging.
A vault is recoverable the moment its inscription reveal confirms. Recovery requires only the heir's backup Kaspa address, an open-source recovery tool, and any Kaspa RPC endpoint.
| Scenario | Path | RPCs | Wall-clock |
|---|---|---|---|
| Card + blob written | Tracer | 1 + N (mutations) | < 1 s |
| Card, no blob, app wiped | P2pkAnchoredHydrator | 2 | ~1 s |
| No card, heir address only | HeirAnchoredScanner (fast) | 2 | ~500 ms |
| No card, heir address only, pre-2a deploy | Block-walking scan (slow) | (many) | hours / days |
| Nothing | 6-month heir CSV timelock | (off-chain wait) | 6 months |
The on-card recovery blob is now strictly an optimisation — it saves RPC round-trips. The inscription on chain is the canonical, public, consensus-anchored truth. Any one of three independent identifiers reaches the vault in seconds.
FrostCard's off-chain components are convenience layers, never load-bearing. The wallet must work fully without them; an indexer is a performance optimisation, not a trust root.
Pull, not push. The system does not send routine alerts; users audit on demand from the in-app Vault tab or from check.frostcard.app in any browser. The web tool exists as a second-surface verification surface independent of the wallet app's sandbox.
The indexer is a small open-source Rust crate, distributed as a Docker image. check.frostcard.app hosts a "build it yourself" page for users who prefer to point the web tool at their own indexer. One hop for normies (app → website → source), the full stack for paranoids.
v1.0 · Post-Toccata Kaspa wire shape (KIP-10 + KIP-20 covenant IDs). Live on TN12 today; activates on mainnet at Toccata ratification (~June 2026) via the KIP20_MAINNET_ACTIVE constant.