Technical Brief · v1.0

How FrostCard Covenants 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).

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.

  1. The design in one page
  2. The three primitives — whitelist, inheritance, freeze
  3. State-in-script: how the vault address encodes its rules
  4. The deploy transaction
  5. The commit + reveal inscription pair
  6. The two BLAKE2b hash gates
  7. Spending the vault
  8. Mutations — the 14-day timelock
  9. Recovery — four scenarios
  10. The off-chain layer

1 · The design in one page

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.

FrostCard smartcard private key never leaves silicon signs hash Phone wallet app builds tx, taps card SIGN submit tx Kaspa chain consensus enforces the rules vault P2SH inscription commit 2M sompi change to owner reveal tx (later)
Figure 1.1 — End-to-end signing path. Card holds the key; phone shapes the transaction; the chain holds and enforces the policy.

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 at every signing tap.

2 · The three primitives

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.

heir

Inheritance

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.

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.

Redeem script (state) <owner_pubkey> OP_CHECKSIG OP_IF // owner path // — whitelist enforcement // <wl_addr_1> ... <wl_addr_3> OP_ELSE <csv_seconds> OP_CHECKSEQUENCE- VERIFY OP_DROP // heir sweep to <backup_spk> OP_ENDIF BLAKE2b P2SH script_pubkey 35 bytes OP_BLAKE2B <32-byte hash> OP_EQUAL bech32m Vault address kaspa:p… (public, fundable, policy invisible until first spend)
Figure 3.1 — From policy bytes to a fundable vault address. Change one byte upstream and the address downstream is unrecognisable.

Three consequences of this choice are worth internalising:

4 · The deploy transaction

A FrostCard vault is created in a single Kaspa transaction with three outputs. The shape is fixed; the recovery layer depends on it.

Inputs UTXO from owner P2PK (funds the deploy) tx.version = 1 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
)

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.

5 · The commit + reveal inscription pair

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 Inscription commit output spk = P2SH(inscription_redeem) value = 2,000,000 sompi (dust) visible: only the 32-byte hash policy bytes: still hidden later Step 2 — reveal Reveal tx 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 inscription_redeem layout (in 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.

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

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.

7 · Spending the vault

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.

Vault UTXO P2SH at deploy address 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
Figure 7.1 — Two paths through one redeem script. Witness selection is deterministic.

8 · Mutations — the 14-day timelock

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.

t = 0 t = 7d t = 14d later Propose owner signs · staging slot funded 14-day window old policy still active · cancel allowed Apply anyone can push · new policy active cancel owner OR designated cancel key
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

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

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.

10 · The off-chain layer

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 frostcard-indexer open source · Docker · self-hostable · ~500 LOC frostcard-viewer-core (Rust → WASM) 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.

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.