Technical Brief · v1.0

How FrostCardcovenants work

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
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.

In plain wordsA covenant is a spending rule that the blockchain itself enforces — not your app, not a server, the chain. This page is about how FrostCard bakes those rules into a Kaspa address so the network refuses any move that breaks them.
1 · The design in one page
The vault address is the policy

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.

P2SH / "script hash"Pay-to-Script-Hash. Instead of locking coins to a single public key, you lock them to the fingerprint of a small program. To spend, you must reveal a program whose fingerprint matches — and then satisfy whatever rules that program contains.
🏦
A vault whose walls are the contract
The card holds the key. The phone shapes the transaction. The chain holds and enforces the policy — who can move funds, where they can go, and when. Figure 1.1, in one breath: the card signs a 32-byte hash, the phone submits the transaction, and the Kaspa chain validates it against the rules baked into the vault's P2SH script.

Five claims that hold simultaneously

1
Keys stay in silicon
The card's private key is generated on-card by its TRNG and never leaves. The phone builds the tx and asks the card to sign a 32-byte hash.
2
The vault address is the policy
Whitelist entries, heir address, freeze height, CSV delays — all of it is baked into the redeem script bytes. State change = different script = different P2SH address.
3
Recovery is a function of public chain data alone
An open-source CLI plus any Kaspa RPC endpoint plus the heir's address is sufficient to reconstruct the vault and spend it.
4
Mutations are public and slow
Whitelist or heir changes go through a 14-day propose / cancel / apply timelock. Theft of the cards alone never produces an instant redirection.
5
Failures are loud
Every rejection path surfaces a specific reason. WYSIWYS — What You See Is What You Sign — at every signing tap.
2 · The three primitives
Whitelist, inheritance, freeze

Every FrostCard policy is built from a combination of these three primitives. Most users want one or two; some want all three.

📋
Whitelist
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.
Inheritance — the dead-man's switch
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.
Dead-man's switchA mechanism that fires because the owner stopped acting. If the owner keeps using the vault, the inactivity timer keeps resetting and the heir sweep never arms. Go quiet long enough — lost cards, death — and the heir path opens automatically.
🧊
Freeze
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.

3 · State-in-script
How the vault address encodes its rules

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.

🔗
From policy bytes to a fundable address
The redeem script (your rules) is run through BLAKE2b, producing a 35-byte P2SH script_pubkeyOP_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.
BLAKE2bA cryptographic hash function — a one-way fingerprint machine. Feed it any data, get a fixed-length 32-byte tag. The same input always gives the same tag; the tiniest change gives a totally different one; you can't run it backward. Kaspa uses BLAKE2b for its sighash and covenant identity hashes.

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:

The script is hidden until first spend
A P2SH output commits only to blake2b-256(redeem_script). Onlookers see a hash; they do not see the heir address, the whitelist entries, or the CSV delay.
"Mutating" the policy means deploying a new vault
There is no in-place edit of a redeem script. A whitelist change is a brand-new P2SH address with a brand-new script, which is why mutations involve a staging slot and a cancel window.
Until first spend, recovery has a hard problem
If the only copy of the script lived on the lost card, the funds would be unrecoverable. We solved this with the inscription transport (Section 5).
4 · The deploy transaction
One transaction, three outputs

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.

output[0] · vault
script_public_key = P2SH(redeem) · value = vault amount.
covenant = Some({ authorizing_input: 0, covenant_id: <genesis hash> })
output[1] · inscription commit
script_public_key = P2SH(inscription_redeem) · value = 2,000,000 sompi. Carries the full vault redeem script in dead-branch pushes.
output[2] · change
P2PK back to owner · value = remainder − fee.

Figure 4.1 — Deploy tx layout. The vault output's covenant field is what KIP-20 will track on mainnet post-Toccata.

Why tx.version = 1

Kaspa 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.

The genesis covenant ID

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
)
What this buysThe 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.
5 · The commit + reveal inscription pair
Forcing the chain to publish the policy

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.

Step 1 — commit
spk = P2SH(inscription_redeem)
value = 2,000,000 sompi (dust)

Visible: only the 32-byte hash. Policy bytes: still hidden.
Step 2 — reveal (later)
input.signature_script = <PUSH inscription_redeem>
Consensus checks blake2b(push) == spent P2SH hash.

Full inscription redeem now on chain, in cleartext → vault redeem extractable by anyone.

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.

"Dead branch"An 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.

Chunking

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).

6 · Two BLAKE2b hash gates
The decoder is the primary defence

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.

Gate 1 — outer hash
blake2b-256(inscription_redeem)
== spent_p2sh_hash


"the bytes we parsed are the bytes consensus admitted"
Gate 2 — inner hash
blake2b-256(vault_redeem_from_payload)
== vault_p2sh_hash


"the redeem the inscription claims is the one funded"

decode_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.

Why this mattersThis 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.
7 · Spending the vault
Two paths through one redeem script

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_CHECKSIG · OP_CHECKSEQUENCEVERIFY · the witnessOP_CHECKSIG verifies a signature against a public key. OP_CHECKSEQUENCEVERIFY (CSV) is a relative timelock — it makes the spend fail unless the coin has been sitting unspent for at least a set number of seconds. The witness is the data the spender supplies (a signature, or just OP_FALSE) that chooses which branch runs.

<owner_pubkey>
OP_CHECKSIG
OP_IF // owner path — true when a valid sig is supplied
// output[i].spk ∈ whitelist
// change permitted only back to vault SPK
// BIP-340 Schnorr CHECKSIG
OP_ELSE // heir path — witness is OP_FALSE
<csv_seconds> OP_CHECKSEQUENCEVERIFY OP_DROP
// input.sequence ≥ csv_seconds
// output[0].spk == backup_spk (locked)
OP_ENDIF
Pick a witness to trace which branch the chain executes.

Figure 7.1 — Two paths through one redeem script. Witness selection is deterministic.

Owner path (with whitelist)
Witness · <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.
Heir path (sweep)
Witness · 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.
8 · Mutations — the 14-day timelock
Theft of the cards never produces an instant redirection

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.

TimelockA rule that says "this can't take effect until time T." Here it's enforced on-chain: the new policy is staged immediately but the chain refuses to activate it until 14 days have passed, leaving a wide-open window to cancel.
1
Propose t = 0
Owner signs · staging slot funded. The new policy now sits on chain, visible to everyone.
2
14-day window t = 0 → 14d
Old policy still active · cancel allowed for the entire window.
Cancel (any time in window) t ≤ 14d
Owner OR designated cancel key tears down the proposal. The staged redirection never happens.
3
Apply t = 14d
Anyone can push · new policy active. Only reachable if no cancel fired.
A whitelist or heir change is staged, then either applies after 14 days or is cancelled.

Figure 8.1 — The 14-day window is the security backbone. Theft of the cards alone never produces an instant redirection.

Why 14 days

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.

UI surface

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.

9 · Recovery — four scenarios
Recoverable the moment the inscription reveal confirms

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.

ScenarioPathRPCsWall-clock
Card + blob writtenTracer1 + N (mutations)< 1 s
Card, no blob, app wipedP2pkAnchoredHydrator2~1 s
No card, heir address onlyHeirAnchoredScanner (fast)2~500 ms
No card, heir address only, pre-2a deployBlock-walking scan (slow)(many)hours / days
Nothing6-month heir CSV timelock(off-chain wait)6 months
Heir-only recoveryThe 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.
10 · The off-chain layer
Convenience layers, never load-bearing

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.

Kaspa chain ground truth
The single source of truth. Every byte downstream is already public on chain.
frostcard-indexer ~500 LOC
Open source · Docker · self-hostable. A scanner over the chain — replaceable, never a trust root.
frostcard-viewer-core (Rust → WASM)
Shared decode/verify logic compiled to WASM, feeding all three front-ends.
Three front-ends
In-app Vault tab · 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.

Audit model

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.

Self-hosting

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.

Don't Trust — Verify
Read the code that enforces the covenant

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.

The covenant compiler — frostcard-core
The Rust crate that compiles every policy variant (OwnerHeirOwnerHeirWhitelistFreeze) into redeem-script bytes, encodes the inscription, and enforces both BLAKE2b hash gates on decode.
The security contract
The vault address is the policy; the chain enforces it. Keys stay in silicon, mutations are public and slow, recovery is a function of public chain data alone, and every rejection path is loud. These aren't preferences — they fall out of the project's non-negotiables.

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.

FrostCard — the first fully open-source NFC cold wallet for Kaspa