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.
- Bitcoin Inquisition (v28.0+) with
OP_CATandOP_CHECKSIGFROMSTACKenabled on regtest - Python 3.10+
- Dependencies:
python-bitcoinlib,buidl,clii
git clone https://github.com/bitcoin-inquisition/bitcoin.git bitcoin-inquisition
cd bitcoin-inquisition
cmake -B build
cmake --build build -j$(nproc)pip install -r requirements.txtStart a regtest node:
bitcoind -regtest -daemon -txindexRun 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 $VAULTEmergency 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-loopInspect vault state:
python3 main.py show-vault $VAULTCheck if an unvault has been triggered:
python3 main.py alert-on-unvault $VAULT 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 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:
-
Pushes the embedded
sha_single_outputconstant (32 bytes — the SHA256 hash of the expected output amount and scriptPubKey, committed at vault creation time). -
Concatenates:
OP_CATjoins prefix + sha_single_output, thenOP_SWAP OP_CATappends the suffix, producing the full BIP 342 sighash preimage. -
Prepends the
TapSighashtagged-hash prefix (64 bytes ofSHA256("TapSighash") || SHA256("TapSighash")), thenOP_SHA256hashes the entire thing to produce the 32-byte sighash. -
OP_CHECKSIGFROMSTACKverifiessig_csfsagainst this stack-computed sighash using the hot pubkey. -
OP_CHECKSIGverifiessig_checksigagainst 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
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.
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).
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:
- Trigger constrains the output to the vault-loop address (whose scriptPubKey is known at vault creation)
- 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.
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 | 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. |
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.
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
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 seedsCoin— UTXO representationVaultPlan— computes all scripts, taptrees, addresses, and unsigned transactions at construction time. Embeds thesha_single_outputconstants 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 vaultunvault <txid>— trigger the unvault processwithdraw <txid>— complete withdrawal after CSV delayrecover <txid> [--from-loop]— emergency sweep to cold walletgenerate-blocks <n>— mine regtest blocksshow-vault <txid>— display vault state and expected transaction IDsalert-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).
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.
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:
-
Covenant mechanism: MES assumed a hypothetical
OP_CHECKOUTPUTVERIFY. We use the CAT+CSFS dual-verification trick to emulate output introspection indirectly. -
Recovery path: MES proposed constraining recovery to a pre-committed address. Our recover leaf is unconstrained (
cold_pk OP_CHECKSIG) — simpler but weaker. -
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. -
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.
-
No recursive covenants: MES vaults could re-vault indefinitely. Ours is limited to one re-vault because we can't reference our own scriptPubKey.
-
Möser, Eyal, Sirer — "Bitcoin Covenants" (2016): https://maltemoeser.de/paper/covenants.pdf The original vault proposal using a hypothetical covenant opcode.
-
Andrew Poelstra — "CAT and Schnorr Tricks I" (2021, Blockstream): https://blog.blockstream.com/cat-and-schnorr-tricks-i/ The foundational article describing how
OP_CAT+OP_CHECKSIGFROMSTACKenables transaction introspection and covenants via dual signature verification. -
Andrew Poelstra — "CAT and Schnorr Tricks II" (2021, Blockstream): https://medium.com/blockstream/cat-and-schnorr-tricks-ii-2f6ede3d7bb5 Extends the technique to more complex covenant constructions.
-
Blockstream — "Covenants in Production on Liquid" (2023): https://blog.blockstream.com/covenants-in-production-on-liquid/ Documents the first production deployment of CSFS-based covenants, including a Möser-Eyal-Sirer vault proof-of-concept on Liquid's Elements sidechain.
-
BIP 347 — OP_CAT: https://github.com/bitcoin/bips/blob/master/bip-0347.mediawiki Reintroduces
OP_CAT(concatenate two stack elements) to tapscript. -
BIP 348 — OP_CHECKSIGFROMSTACK: https://github.com/bitcoin/bips/blob/master/bip-0348.mediawiki Adds
OP_CHECKSIGFROMSTACK(verify a signature against an arbitrary message on the stack) to tapscript. -
BIP 341 — Taproot (SegWit v1): https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki Defines Taproot output key derivation, taptree commitment, and script-path spending.
-
BIP 342 — Tapscript: https://github.com/bitcoin/bips/blob/master/bip-0342.mediawiki Defines the sighash algorithm for tapscript spending (the preimage format this vault splits and verifies).
MIT. See LICENSE.