Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

cat-csfs-vault

A Bitcoin vault built on CAT and Schnorr Tricks — using OP_CAT (BIP 347) and OP_CHECKSIGFROMSTACK (BIP 348) to enforce covenant spending rules on regtest.

The core idea comes from Andrew Poelstra's Schnorr tricks: the same Schnorr signature is verified twice — once by OP_CHECKSIGFROMSTACK against a sighash preimage assembled on the stack via OP_CAT, and once by OP_CHECKSIG against the real transaction sighash. If any witness-provided field is forged, the two hashes diverge and one check fails. This gives Bitcoin script transaction introspection without a dedicated covenant opcode.

The design follows the Möser-Eyal-Sirer vault pattern — deposit, trigger, timelock, withdraw-or-recover — adapted to the constraints of what CAT+CSFS can express in tapscript.

Requirements

  • Bitcoin Inquisition (v28.0+) with OP_CAT and OP_CHECKSIGFROMSTACK enabled on regtest
  • Python 3.10+
  • Dependencies: python-bitcoinlib, buidl, clii

Building Bitcoin Inquisition

git clone https://github.com/bitcoin-inquisition/bitcoin.git bitcoin-inquisition
cd bitcoin-inquisition
cmake -B build
cmake --build build -j$(nproc)

Installing Python dependencies

pip install -r requirements.txt

Quick start

Start a regtest node:

bitcoind -regtest -daemon -txindex

Run the full vault lifecycle:

# 1. Create a vault (mines blocks, funds a P2TR vault UTXO)
VAULT=$(python3 main.py vault)

# 2. Trigger the unvault (hot key initiates withdrawal)
python3 main.py unvault $VAULT

# 3. Mine past the CSV delay (10 blocks)
python3 main.py generate-blocks 11

# 4. Complete withdrawal to the pre-committed destination
python3 main.py withdraw $VAULT

Emergency recovery (cold key sweeps funds immediately):

# From vault UTXO (before trigger)
python3 main.py recover $VAULT

# From vault-loop UTXO (after trigger)
python3 main.py recover $VAULT --from-loop

Inspect vault state:

python3 main.py show-vault $VAULT

Check if an unvault has been triggered:

python3 main.py alert-on-unvault $VAULT

How it works

Architecture

      funding source (P2WPKH)
               │
       tovault_tx ──► vault UTXO (P2TR)
               │                  \
       trigger_tx              recover_tx ──► cold wallet
               │
     vault-loop UTXO (P2TR)
               │                  \
      withdraw_tx              recover_tx ──► cold wallet
               │
      destination (P2WPKH)

The vault uses two Taproot addresses, each with a 2-leaf taptree built on a NUMS (Nothing Up My Sleeve) internal key so the key-path is provably unspendable — all spending goes through script-path leaves.

Vault address (first P2TR output):

Leaf Script Purpose
Trigger CAT+CSFS introspection Hot key moves funds to the vault-loop, constrained to a single pre-committed output
Recover cold_pk OP_CHECKSIG Cold key sweeps funds immediately to any address

Vault-loop address (second P2TR output):

Leaf Script Purpose
Withdraw CSV + CAT+CSFS introspection Hot key completes withdrawal to the pre-committed destination after a timelock
Recover cold_pk OP_CHECKSIG Cold key sweeps funds immediately to any address

The dual-verification trick

The trigger and withdraw leaves use the same introspection pattern. Here is how the trigger leaf works, step by step.

The spender provides four witness items (bottom to top): sig_checksig | sig_csfs | suffix | prefix. The script:

  1. Pushes the embedded sha_single_output constant (32 bytes — the SHA256 hash of the expected output amount and scriptPubKey, committed at vault creation time).

  2. Concatenates: OP_CAT joins prefix + sha_single_output, then OP_SWAP OP_CAT appends the suffix, producing the full BIP 342 sighash preimage.

  3. Prepends the TapSighash tagged-hash prefix (64 bytes of SHA256("TapSighash") || SHA256("TapSighash")), then OP_SHA256 hashes the entire thing to produce the 32-byte sighash.

  4. OP_CHECKSIGFROMSTACK verifies sig_csfs against this stack-computed sighash using the hot pubkey.

  5. OP_CHECKSIG verifies sig_checksig against the real transaction sighash using the same hot pubkey.

Since both operations verify the same signature with the same key, the stack-assembled preimage must match the real transaction. The embedded sha_single_output is the only value the spender cannot provide freely — it's baked into the script at vault creation. This forces the output to match.

In script:

<sha_single_output>          # 32 bytes, embedded in script
OP_CAT                       # prefix || sha_single_output
OP_SWAP OP_CAT               # prefix || sha_single_output || suffix  (= preimage)
<tapsighash_tag_prefix>      # 64 bytes
OP_SWAP OP_CAT               # tag || preimage
OP_SHA256                    # sighash = SHA256(tag || preimage)
<hot_pubkey> OP_CHECKSIGFROMSTACK OP_VERIFY
<hot_pubkey> OP_CHECKSIG

Sighash type: SIGHASH_SINGLE|ANYONECANPAY (0x83)

The covenant signature uses SIGHASH_SINGLE|ANYONECANPAY, which commits to exactly one input (the vault UTXO) and one output (at the matching index). This is a deliberate design choice for fee management: anyone can attach additional inputs to pay fees without breaking the covenant signature. The tradeoff is that the covenant cannot constrain additional outputs — it only binds the output at index 0.

BIP 342 sighash preimage layout

For SIGHASH_SINGLE|ANYONECANPAY, the preimage splits cleanly into three parts:

┌─────────────────────────────────────────────────────────┐
│ PREFIX (~94 bytes)                                      │
│   epoch (1) | hash_type (1) | nVersion (4) |            │
│   nLockTime (4) | spend_type (1) |                      │
│   outpoint (36) | amount (8) | scriptPubKey (35) |      │
│   nSequence (4)                                         │
├─────────────────────────────────────────────────────────┤
│ SHA_SINGLE_OUTPUT (32 bytes) — embedded in script       │
│   SHA256(output_amount || output_scriptPubKey)           │
├─────────────────────────────────────────────────────────┤
│ SUFFIX (37 bytes)                                       │
│   tapleaf_hash (32) | key_version (1) |                 │
│   codesep_pos (4)                                       │
└─────────────────────────────────────────────────────────┘

The prefix and suffix are provided as witness data. The sha_single_output is the embedded constant. The full preimage is 163 bytes. With the 64-byte TapSighash tag prefix, the OP_CAT stack element reaches 227 bytes — well within the 520-byte consensus limit (293 bytes of headroom).

Vault-loop (change management)

The trigger transaction sends funds to a vault-loop address rather than directly to the destination. This is a second P2TR address with a withdraw leaf (guarded by OP_CHECKSEQUENCEVERIFY) and its own recover leaf. The vault-loop exists because Bitcoin script can't reference its own scriptPubKey — there's no recursive covenant. Instead, the destination constraint is split across two steps:

  1. Trigger constrains the output to the vault-loop address (whose scriptPubKey is known at vault creation)
  2. Withdraw constrains the output to the final destination (after the CSV delay)

This two-step pattern is analogous to the trigger-then-complete flow in CTV vaults and OP_VAULT, but here it's a consequence of the CAT+CSFS mechanism rather than a design choice.

Recovery path

The recover leaf is a simple cold_pk OP_CHECKSIG — no introspection, no CSV delay, no output constraint. The cold key can sweep funds to any address immediately. This is the simplest possible recovery mechanism but has an important security implication: cold key compromise means immediate, unrestricted theft. Unlike OP_VAULT's OP_VAULT_RECOVER (which pre-commits the recovery destination) or CCV's mode-constrained recovery, this vault's recovery path has no covenant protection.

Key roles

Key Role Capability
Hot key Triggers unvault, completes withdrawal Can only send to pre-committed outputs (vault-loop, then destination). Cannot redirect funds.
Cold key Emergency recovery Can sweep funds to any address. No timelock.
Destination Pre-committed at vault creation Fixed. Cannot be changed without recovery + re-vaulting.

Fee management

The SIGHASH_SINGLE|ANYONECANPAY flag means the covenant signature only commits to one input and one output. A separate fee wallet (or any third party) can attach additional inputs to cover fees via CPFP, without invalidating the covenant. This is the same fee-management approach used in Lightning Network commitment transactions.

File structure

cat-csfs-vault/
├── main.py            # CLI entry point (vault, unvault, withdraw, recover, show-vault, alert-on-unvault, generate-blocks)
├── vault.py           # VaultPlan (plans all txs), VaultExecutor (signs and broadcasts)
├── taproot.py         # Taproot primitives, script builders, sighash computation, signing
├── rpc.py             # Bitcoin Core JSON-RPC client
└── requirements.txt   # Python dependencies

Module overview

taproot.py — Low-level Taproot and BIP 342 primitives:

  • Opcode constants (OP_CAT, OP_CHECKSIGFROMSTACK)
  • Tagged hashing (BIP 340/341)
  • Tapscript leaf hashing and 2-leaf taptree construction
  • Full BIP 342 sighash preimage computation (build_sighash_preimage)
  • Preimage splitting for witness construction (split_preimage_for_witness)
  • Script builders: make_trigger_leaf(), make_withdraw_leaf(), make_recover_leaf()
  • Schnorr signing (schnorr_sign)
  • P2TR output key derivation with NUMS internal key

vault.py — Vault logic:

  • Wallet — deterministic key derivation from seeds
  • Coin — UTXO representation
  • VaultPlan — computes all scripts, taptrees, addresses, and unsigned transactions at construction time. Embeds the sha_single_output constants that lock the withdrawal path.
  • VaultExecutor — signs transactions using the plan's key material and broadcasts via RPC

main.py — CLI commands:

  • vault — create and fund a new vault
  • unvault <txid> — trigger the unvault process
  • withdraw <txid> — complete withdrawal after CSV delay
  • recover <txid> [--from-loop] — emergency sweep to cold wallet
  • generate-blocks <n> — mine regtest blocks
  • show-vault <txid> — display vault state and expected transaction IDs
  • alert-on-unvault <txid> — check mempool/chain for unvault activity

rpc.py — JSON-RPC client for Bitcoin Core (from python-bitcoinlib, modified for regtest auto-detection).

Security properties

The CAT+CSFS vault has a distinctive security profile compared to other covenant vault designs:

Hot key theft resistance — A compromised hot key can only trigger unvaulting to the pre-committed vault-loop output. It cannot redirect funds to an attacker address because the sha_single_output is embedded in the script. The worst case is a grief attack: the attacker triggers unnecessary unvaults, but funds remain recoverable. This is arguably stronger than CCV and OP_VAULT, where a compromised trigger key with the right parameters could potentially redirect within the covenant's flexibility.

Witness manipulation resistance — The dual-verification pattern catches any tampering with the witness-provided preimage fields. Changing even a single byte in the prefix or suffix causes the stack-computed sighash to diverge from the real transaction sighash, failing one of the two signature checks.

Destination lock — The withdrawal destination is fixed at vault creation time (the sha_single_output hash is computed from the destination address and amount, then embedded in the script). This is the most rigid output constraint of any vault design. To change the destination, you must recover and re-vault. CCV and OP_VAULT allow specifying the destination at trigger time, which is more flexible but also more exposed to trigger-key compromise.

Cold key recovery vulnerability — The recover leaf has no covenant constraint. Cold key compromise means immediate, unrestricted fund theft with no timelock defense and no watchtower window. This is the weakest recovery path of the four vault designs (CTV, CCV, OP_VAULT all constrain recovery to some degree).

No recursive covenants — The vault can only re-vault once (vault -> vault-loop). There's no way to chain indefinitely because Bitcoin script can't embed its own future scriptPubKey hash. CCV and OP_VAULT both support deeper re-vaulting.

Comparison with Möser-Eyal-Sirer vaults

This implementation follows the original Möser-Eyal-Sirer vault concept — the two-phase withdrawal with a cold-key recovery escape hatch — but diverges in several ways:

  1. Covenant mechanism: MES assumed a hypothetical OP_CHECKOUTPUTVERIFY. We use the CAT+CSFS dual-verification trick to emulate output introspection indirectly.

  2. Recovery path: MES proposed constraining recovery to a pre-committed address. Our recover leaf is unconstrained (cold_pk OP_CHECKSIG) — simpler but weaker.

  3. Output commitment model: We use SIGHASH_SINGLE|ANYONECANPAY, committing to one output at a matching index. MES didn't specify this detail because they assumed a more powerful opcode.

  4. Vault-loop: MES described re-vaulting conceptually. Our implementation has an explicit two-step structure (vault -> vault-loop -> destination) as a consequence of the CAT+CSFS mechanism.

  5. No recursive covenants: MES vaults could re-vault indefinitely. Ours is limited to one re-vault because we can't reference our own scriptPubKey.

References

License

MIT. See LICENSE.

About

A Bitcoin vault built on CAT and Schnorr Tricks — using OP_CAT (BIP 347) and OP_CHECKSIGFROMSTACK (BIP 348) to enforce covenant spending rules on regtest.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages