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).
Read the brief ↓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.
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.
script_pubkey — OP_BLAKE2B <32-byte hash> OP_EQUAL — which bech32m-encodes into a public kaspa:p… vault address. Change one byte upstream and the address downstream is unrecognisable. The address is public and fundable, but the policy stays invisible until the first spend.Inside the redeem script, the policy reads roughly like this:
<owner_pubkey>
OP_CHECKSIG
OP_IF
// owner path
// — whitelist enforcement
// <wl_addr_1> ... <wl_addr_3>
OP_ELSE
<csv_seconds> OP_CHECKSEQUENCEVERIFY OP_DROP
// heir sweep to <backup_spk>
OP_ENDIF
Figure 3.1 — From policy bytes to a fundable vault address. The script branches: owner path (top) or heir path (bottom).
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. The input is a UTXO from the owner's P2PK address (funds the deploy), with tx.version = 1 and an empty tx.payload.
script_public_key = P2SH(redeem) · value = vault amount.covenant = Some({ authorizing_input: 0, covenant_id: <genesis hash> })script_public_key = P2SH(inscription_redeem) · value = 2,000,000 sompi. Carries the full vault redeem script in dead-branch pushes.Figure 4.1 — Deploy tx layout. The vault output's 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 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.
spk = P2SH(inscription_redeem)value = 2,000,000 sompi (dust)input.signature_script = <PUSH inscription_redeem>blake2b(push) == spent P2SH hash.The inscription redeem layout, inside the dead branch:
OP_FALSE OP_IF
<version=0x01> <owner_pubkey> <backup_spk> <chunk_count>
<chunk_1 ≤ 520B> <chunk_2 ≤ 520B> ... <chunk_N>
OP_ENDIF OP_TRUE
Figure 5.1 — Commit + reveal. Two transactions, one vault. The reveal forces the chain to publish the policy bytes verbatim.
OP_FALSE OP_IF … OP_ENDIF block never executes — the condition is always false. But the bytes inside still have to parse, so it's a legal place to smuggle arbitrary data onto the chain. The script runs OP_TRUE at the end and succeeds.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.
blake2b-256(inscription_redeem)
== spent_p2sh_hashblake2b-256(vault_redeem_from_payload)
== vault_p2sh_hashdecode_inscription_redeem(...) → Some(DecodedInscription)
Both gates pass · returns owner_pubkey, backup_spk, vault_redeem.
Either gate fails · returns None · scanner discards.
Figure 6.1 — The decoder is the primary defence; the mempool is permissive on purpose.
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.
OP_FALSE) that chooses which branch runs.Figure 7.1 — Two paths through one redeem script. Witness selection is deterministic.
<sig> <owner_pubkey> OP_TRUE. Card taps once. Phone shows destination + amount + fee before the SIGN button is enabled (WYSIWYS). Script enforces output[i].spk ∈ whitelist; change permitted only back to vault SPK; BIP-340 Schnorr CHECKSIG.OP_FALSE (no signature). Anyone can push the tx after CSV elapses. Destination is hard-coded into the script. input.sequence ≥ csv_seconds; output[0].spk == backup_spk (locked); production CSV: 6 months.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.
Figure 8.1 — The 14-day window is the security backbone. Theft of the cards alone never produces an instant redirection.
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 |
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.
check.frostcard.app · recover.frostcard.app.Figure 10.1 — One ground truth, one indexer (replaceable), three front-ends. Every byte the indexer stores is already public.
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.
Every primitive on this page — the redeem-script compiler, the inscription encoder/decoder, the two hash gates, the recovery paths — is open source. You don't have to take the brief's word for any of it.
OwnerHeir … OwnerHeirWhitelistFreeze) into redeem-script bytes, encodes the inscription, and enforces both BLAKE2b hash gates on decode.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.