├── README.md ├── ctvhash-test-vectors.json ├── main.py ├── requirements.txt ├── rpc.py └── test_main.py /README.md: -------------------------------------------------------------------------------- 1 | # Safer custody with CTV vaults 2 | 3 | This repository demonstrates an implementation of simple, "single-hop" vaults 4 | using the proposed `OP_CHECKTEMPLATEVERIFY` opcode. 5 | 6 | OP_CTV allows the vault strategy to be used without the need to maintain critical 7 | presigned transaction data for the lifetime of the vault, as in the case of earlier 8 | vault implementations. This approach is much simpler operationally, since all relevant 9 | data aside from key material can be regenerated algorithmically. This makes vaulting 10 | more practical at any scale. 11 | 12 | The code included here is intended to be approachable and easy to read, though 13 | it would need review and tweaking before use with real funds. 14 | 15 | ```mermaid 16 | flowchart TD 17 | A(UTXO you want to vault) --> V(Coin in vault) 18 | V --> U("Begin the unvaulting process
(broadcast unvault tx)") 19 | U --> C("To the cold wallet
(immediately)") 20 | U --> D("To the hot wallet
(after an n block delay)") 21 | ``` 22 | 23 | ### Vault basics 24 | 25 | *Vaulting* is a technique for putting constraints around how bitcoins can be spent. 26 | The constraints are designed in such a way to limit the threat of failure 27 | (due to key loss or attempted confiscation) during the custody process. Vaults provide 28 | safety improvements that are significant to both individuals performing self-custody 29 | and institutions securing large amounts of bitcoin on behalf of their customers. 30 | 31 | The basic idea of a vault is that you predetermine the path the coins in the vault 32 | are allowed to travel, which lets you design the flow of funds so that you have 33 | a chance to intervene in a known way if something unexpected 34 | happens. 35 | 36 | For example, in the basic "single-hop" vault structure implemented here, once a 37 | user vaults their coins, they can either unvault the balance to a key designated 38 | as the "cold" wallet immediately, or they can begin the unvault process and, after a 39 | block delay configurable by the user, spend the coins to a key designated as the 40 | "hot" wallet. 41 | 42 | This allows the user to intervene if they see that an unvault process 43 | has been started unexpectedly: if an attacker Mallory gains control of the user Alice's hot wallet and wants to 44 | steal the vaulted coins, Mallory has to broadcast the unvault transaction. If Alice 45 | is watching the mempool/chain, she will see that the unvault transaction has been 46 | unexpectedly broadcast, and she can immediately sweep the balance to her cold wallet, 47 | while Mallory must wait the block delay to succeed in stealing funds from the hot 48 | wallet. 49 | 50 | ![image](https://user-images.githubusercontent.com/73197/156897136-7b230766-4fa0-4c77-ab6f-6e865120f1d9.png) 51 | 52 | This scheme could also be adapted to limit trust in any particular hardware 53 | vendor. Hardware vendor A could serve as the hot wallet, and some multisig combination 54 | of vendors B and C could serve the more secure but less convenient "cold" role. 55 | 56 | 57 | ### Vault complexity 58 | 59 | Vaults can either be *limited* or *recursive*. In a recursive vault, the vault can 60 | feed back into itself, potentially allowing the coins to remain in the vault after 61 | an arbitrary number of steps or partial unvaultings. 62 | 63 | The vault pattern implemented here is "limited" - it entails a single decision point, and atomically 64 | unvaults the entire value. Despite being limited, this still provides high utility 65 | for users. In fact, its simplicity may make it preferable to more complicated schemes. 66 | 67 | ## Demo 68 | 69 | Now that we have background out of the way, let's actually build some vaults. 70 | You can read through the following without actually running the code 71 | yourself. 72 | 73 | ```sh 74 | $ git clone https://github.com/jamesob/simple-ctv-vault 75 | $ cd simple-ctv-vault 76 | $ pip install -r requirements.txt 77 | 78 | # build this bitcoin branch 79 | # https://github.com/JeremyRubin/bitcoin/tree/checktemplateverify-rebase-4-15-21 80 | $ bitcoind -regtest -txindex=1 & 81 | ``` 82 | 83 | Okay, we're ready to go. 84 | 85 | ### Creating a vault 86 | 87 | ```sh 88 | $ TXID=$(./main.py vault) 89 | ``` 90 | 91 | ![image](https://user-images.githubusercontent.com/73197/156897173-c8095fc6-ce39-47cf-85d7-3ac0f86ca2c8.png) 92 | 93 | 94 | At this point, we've generated a coin on regtest and have spent it into a new vault. 95 | `$TXID` corresponds to the transaction ID of the coin we spent into the vault, 96 | which is the only piece of information we need to reconstruct the vault plan and 97 | resume operations. 98 | 99 | We've built a vault which looks like this: 100 | 101 | ```mermaid 102 | flowchart TD 103 | A(UTXO you want to vault) -->|"[some spend] e.g. P2WPKH"| V(to_vault_tx
Coins are now vaulted) 104 | V -->|"<H(unvault_tx)> OP_CHECKTEMPLATEVERIFY"| U(unvault_tx
Begin the unvaulting process) 105 | U -->|"(cold sweep)
<H(tocold_tx)> OP_CHECKTEMPLATEVERIFY"| C(tocold_tx) 106 | U -->|"(delayed hot spend)
<block_delay> OP_CSV
<hot_pubkey> OP_CHECKSIG
"| D(tohot_tx) 107 | C -->|"<cold_pubkey> OP_CHECKSIG"| E(some undefined destination) 108 | ``` 109 | 110 | When we create the vault, we encumber the coin with a `scriptPubKey` that looks like this: 111 | ```python 112 | [unvault_ctv_hash, OP_CHECKTEMPLATEVERIFY] 113 | ``` 114 | where the first item is a hash of the tree of template transactions (basically, the 115 | tree illustrated above). 116 | 117 | #### Why CTV? 118 | 119 | With today's consensus rules, the enforced flow of a vault is only possible if we 120 | presign `tocold_tx` and `tohot_tx`, hang onto them, and then destroy the key. This 121 | locks the spend path of the coins into the two prewritten transactions. But it saddles 122 | us with the operational burden of persisting that critical data indefinitely. 123 | 124 | With large numbers of vaults, ensuring this durability becomes a challenge. And for 125 | small-scale users, the data liability is yet another failure point during self-custody. 126 | 127 | Key deletion during vault creation is also 128 | - hard to prove to auditors, and 129 | - hard to prove to yourself. 130 | 131 | Use of `OP_CHECKTEMPLATEVERIFY` allows us to use a covenant structure and avoid having 132 | to rely on presigned transactions or ephemeral keys. With ` OP_CTV`, we can 133 | ensure that the vault operation is enforced by consensus itself, and the vault 134 | transaction data can be generated deterministically without additional storage needs. 135 | 136 | Other consensus change proposals can do this, but CTV is very simple and easy to reason 137 | about. 138 | 139 | ### Unvaulting 140 | 141 | ![image](https://user-images.githubusercontent.com/73197/156897769-45ee85cc-e626-4b7a-9bd4-df471b1b9026.png) 142 | 143 | 144 | When we initiate an unvault, we broadcast a transaction that satisfies the `OP_CTV` 145 | script above; meaning that the transaction we broadcast has to CTV-hash to the value 146 | cited (including outputs that possibly commit to subsequent CTV encumberances - hence 147 | the tree). This doesn't require any signing, since the authentication is all in the 148 | hash. 149 | 150 | The script encumbering the unvault output looks something like 151 | ```python 152 | def unvault_redeemScript(self) -> CScript: 153 | return CScript( 154 | [ 155 | script.OP_IF, 156 | self.block_delay, script.OP_CHECKSEQUENCEVERIFY, script.OP_DROP, 157 | self.hot_pubkey.sec(), script.OP_CHECKSIG, 158 | script.OP_ELSE, 159 | self.tocold_ctv_hash, OP_CHECKTEMPLATEVERIFY, 160 | ] 161 | ) 162 | ``` 163 | This ensures we have two choices: spend immediately to the cold wallet, or wait a few blocks and spend 164 | to the hot wallet. Of course, more complicated unvault conditions could be written in 165 | here. 166 | 167 | ### Detecting theft 168 | 169 | This unvault step is critical because it allows us to detect unexpected behavior. If an attacker 170 | had stolen our hot wallet keys, their only choice to succeed in the theft is to trigger an unvault. 171 | 172 | ![image](https://user-images.githubusercontent.com/73197/156897788-d2f96a48-ac92-4038-bf59-8d3fbf355685.png) 173 | 174 | We can monitor for such an event and respond by sweeping our keys to the cold wallet. 175 | 176 | ![image](https://user-images.githubusercontent.com/73197/156897846-3e53a7cc-6879-4b28-beb0-5bd7605e563d.png) 177 | 178 | ### Why does `tocold` make use of another CTV? 179 | 180 | You'll notice that the hot-path requires signing with the hot private key to claim the funds. Because we 181 | want to be able to respond immediately, and not have to dig out our cold private keys, we use an 182 | additional `OP_CTV` to encumber the "swept" coins for spending by only the cold wallet key. 183 | 184 | 185 | ### Spending to hot 186 | 187 | Otherwise, if we've intentionally unvaulted, we wait for the timeout to elapse 188 | (`./main.py generate-blocks 10`), and then spend our funds with the hot wallet. 189 | 190 | ![image](https://user-images.githubusercontent.com/73197/156934212-268bb2f8-841b-4247-ad28-49bdf365e410.png) 191 | 192 | 193 | ## Fee management 194 | 195 | Because coins may remain vaulted for long periods of time, the unvault process is 196 | sensitive to changes in the fee market. Because use of OP_CTV requires precommiting to 197 | a tree of all possible specific outputs and the number of inputs, we cannot use RBF to 198 | dynamically adjust feerate of unvaulting transactions. 199 | 200 | In this implementation, we make use of [anchor outputs](https://bitcoinops.org/en/topics/anchor-outputs/) 201 | in order to allow mummified unvault transactions to have their feerate adjusted dynamically. 202 | 203 | ```python 204 | def p2wpkh_tx_template(...) -> CMutableTransaction: 205 | """Create a transaction template paying into a P2WPKH.""" 206 | pay_to_script = CScript([script.OP_0, pay_to_h160]) 207 | 208 | pay_to_fee_script = CScript([script.OP_0, fee_mgmt_pay_to_h160]) 209 | HOPEFULLY_NOT_DUST: Sats = 550 # obviously TOOD? 210 | 211 | tx = CMutableTransaction() 212 | tx.nVersion = 2 213 | tx.vin = vin 214 | tx.vout = [ 215 | CTxOut(nValue, pay_to_script), 216 | # Anchor output for CPFP-based fee bumps 217 | CTxOut(HOPEFULLY_NOT_DUST, pay_to_fee_script), 218 | ] 219 | return tx 220 | ``` 221 | 222 | ### Fee key risk 223 | 224 | One of the downsides of anchor outputs is that their use requires committing to the fee 225 | address that is able to child-pays-for-parent (CPFP) fee bump the unvault transaction. 226 | If the expected lifetime of a vault is for multiple years, ensuring that the fee 227 | key isn't destroyed or compromised is an unfortunate obligation. 228 | 229 | If the fee key were made unavailable for whatever reason, the unvaulting process would 230 | be at the whim of the prevailing fee market during time of unvault. If the fee-bump 231 | keys are lost and the fee market has gone well beyond the predetermined fee rate of the 232 | unvault transaction, the coins basically become unredeemable without unearthing the 233 | cold storage keys for a CPFP bump. 234 | 235 | Note that this is not an inherent risk to vaults per se, but the specific method of 236 | using anchor outpoints for long-term vaults. 237 | 238 | ### Transaction sponsors 239 | 240 | This points to the tension between covenants and fee management. As I noted in 241 | a [mailinglist post](https://lists.linuxfoundation.org/pipermail/bitcoin-dev/2022-February/019879.html), a 242 | fee management technique that doesn't require structural anticipation and chain-waste 243 | like CPFP via anchor outputs would be most welcome. 244 | [Transaction sponsors](https://lists.linuxfoundation.org/pipermail/bitcoin-dev/2020-September/018168.html) 245 | is an interesting approach. 246 | 247 | Another possibility is to have a more granular sighash (like 248 | [SIGHASH_GROUP](https://lists.linuxfoundation.org/pipermail/bitcoin-dev/2021-July/019243.html)) 249 | that allows vault transactions to be combined dynamically with fee-bump input/output 250 | packages. 251 | 252 | ## Patterns for industrial users 253 | 254 | One can imagine that large custodians of bitcoin might "tranche" up a vault pattern like 255 | this into fixed sizes of bitcoin, and have on-demand unvaults that have block-delay 256 | parameters compatible with their service-level agreements. This provides a good deal of 257 | assurance that coin flows are safe and auditable while still remaining readily 258 | liquid. 259 | 260 | 261 | ## Running tests 262 | 263 | ```sh 264 | $ pip install pytest && pytest 265 | ``` 266 | 267 | 268 | ## Prior work 269 | 270 | - Vaults by kanzure: https://github.com/kanzure/python-vaults 271 | - `OP_CTV` PR by JeremyRubin: https://utxos.org, https://github.com/bitcoin/bitcoin/pull/21702 272 | - Vaults by JeremyRubin: https://rubin.io/bitcoin/2021/12/07/advent-10/ (and probably 273 | others) 274 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Implementation of a simple OP_CTV vault. 4 | 5 | output you're spending from 6 | | 7 | tovault_tx output 8 | ( OP_CTV) 9 | | 10 | unvault_tx 11 | (OP_CSV hot_pk | ( OP_CTV) 12 | / \ 13 | tohot_tx tocold_tx 14 | (cold_pk OP_CHECKSIG) 15 | 16 | 17 | Example usage: 18 | 19 | # Initialize a vault 20 | TXID=$(./main.py vault) 21 | 22 | # Trigger an unvault 23 | ./main.py unvault $TXID 24 | 25 | # Detect the unvault 26 | ./main.py alert-on-unvault $TXID 27 | 28 | # Generate some blocks 29 | ./main.py generate-blocks 1 30 | 31 | # Attempt to claim the vault to the hot wallet 32 | ./main.py to-hot $TXID 33 | 34 | # Sweep the vault immediately to the cold wallet 35 | ./main.py to-cold $TXID 36 | 37 | """ 38 | import struct 39 | import hashlib 40 | import sys 41 | import pprint 42 | import typing as t 43 | from dataclasses import dataclass 44 | 45 | from bitcoin import SelectParams 46 | from bitcoin.core import ( 47 | CTransaction, 48 | CMutableTransaction, 49 | CMutableTxIn, 50 | CTxIn, 51 | CTxOut, 52 | CScript, 53 | COutPoint, 54 | CTxWitness, 55 | CTxInWitness, 56 | CScriptWitness, 57 | COIN, 58 | ) 59 | from bitcoin.core import script 60 | from bitcoin.wallet import CBech32BitcoinAddress 61 | from buidl.hd import HDPrivateKey, PrivateKey 62 | from buidl.ecc import S256Point 63 | from rpc import BitcoinRPC, JSONRPCError 64 | from clii import App 65 | 66 | 67 | cli = App(usage=__doc__) 68 | 69 | OP_CHECKTEMPLATEVERIFY = script.OP_NOP4 70 | 71 | Sats = int 72 | SatsPerByte = int 73 | 74 | TxidStr = str 75 | Txid = str 76 | RawTxStr = str 77 | 78 | # For use with template transactions. 79 | BLANK_INPUT = CMutableTxIn 80 | 81 | 82 | @dataclass(frozen=True) 83 | class Coin: 84 | outpoint: COutPoint 85 | amount: Sats 86 | scriptPubKey: bytes 87 | height: int 88 | 89 | @classmethod 90 | def from_txid(cls, txid: str, n: int, rpc: BitcoinRPC) -> "Coin": 91 | tx = rpc.getrawtransaction(txid, True) 92 | txout = tx["vout"][n] 93 | return cls( 94 | COutPoint(txid_to_bytes(txid), n), 95 | amount=int(txout["value"] * COIN), 96 | scriptPubKey=bytes.fromhex(txout["scriptPubKey"]["hex"]), 97 | height=rpc.getblock(tx["blockhash"])["height"], 98 | ) 99 | 100 | 101 | @dataclass 102 | class Wallet: 103 | privkey: PrivateKey 104 | coins: t.List[Coin] 105 | network: str 106 | 107 | @classmethod 108 | def generate(cls, seed: bytes, network: str = "regtest") -> "Wallet": 109 | return cls( 110 | HDPrivateKey.from_seed(seed, network=network).get_private_key(1), 111 | [], 112 | network, 113 | ) 114 | 115 | def fund(self, rpc: BitcoinRPC) -> Coin: 116 | fund_addr = self.privkey.point.p2wpkh_address(network=self.network) 117 | rpc.generatetoaddress(110, fund_addr) 118 | 119 | scan = scan_utxos(rpc, fund_addr) 120 | assert scan["success"] 121 | 122 | for utxo in scan["unspents"]: 123 | self.coins.append( 124 | Coin( 125 | COutPoint(txid_to_bytes(utxo["txid"]), utxo["vout"]), 126 | int(utxo["amount"] * COIN), 127 | bytes.fromhex(utxo["scriptPubKey"]), 128 | utxo["height"], 129 | ) 130 | ) 131 | 132 | # Earliest coins first. 133 | self.coins = [ 134 | c for c in sorted(self.coins, key=lambda i: i.height) if c.amount > COIN 135 | ] 136 | try: 137 | return self.coins.pop(0) 138 | except IndexError: 139 | raise RuntimeError( 140 | "Your regtest is out of subsidy - " 141 | "please wipe the datadir and restart." 142 | ) 143 | 144 | 145 | @dataclass 146 | class VaultPlan: 147 | """ 148 | Template and generate transactions for a one-hop vault structure based on 149 | OP_CHECKTEMPLATEVERIFY. 150 | 151 | 152 | output you're spending from amount0 153 | | 154 | tovault_tx output amount1 155 | ( OP_CTV) 156 | | 157 | unvault_tx amount2 158 | (OP_CSV hot_pk | ( OP_CTV) 159 | / \ 160 | tohot_tx tocold_tx 161 | (cold_pk OP_CHECKSIG) amount3 162 | 163 | """ 164 | 165 | # SEC-encoded public keys associated with various identities in the vault scheme. 166 | hot_pubkey: S256Point 167 | cold_pubkey: S256Point 168 | fees_pubkey: S256Point 169 | 170 | # The coin being committed to the vault. 171 | coin_in: Coin 172 | 173 | # How many blocks to delay the vault -> hot PK path. 174 | block_delay: int 175 | 176 | # What percentage of the amount are we taking in fees at each step of the vault? 177 | # Note this isn't how you'd actually do it (would want to specify feerate), 178 | # but is a simplification for this demo. 179 | fees_per_step: Sats = 10000 180 | 181 | def __post_init__(self): 182 | """ 183 | Plan all (unsigned) vault transactions, which gives us the txid for 184 | everything. 185 | """ 186 | 187 | def get_txid(tx: CMutableTransaction) -> TxidStr: 188 | return bytes_to_txid(tx.GetTxid()) 189 | 190 | self.tovault_txid: TxidStr = get_txid(self.tovault_tx_unsigned) 191 | self.tovault_outpoint = COutPoint(txid_to_bytes(self.tovault_txid), 0) 192 | 193 | self.unvault_txid: TxidStr = get_txid(self.unvault_tx_unsigned) 194 | self.unvault_outpoint = COutPoint(txid_to_bytes(self.unvault_txid), 0) 195 | 196 | self.tohot_txid = get_txid(self.tohot_tx_unsigned) 197 | self.tocold_txid = get_txid(self.tocold_tx_unsigned) 198 | 199 | def amount_at_step(self, step=0) -> Sats: 200 | """ 201 | Compute the amount at each step of the vault, per 202 | "amount[n]" in the diagram above. 203 | """ 204 | # In reality, you'd compute feerate per step and use that. (TODO) 205 | amt = self.coin_in.amount - (self.fees_per_step * step) 206 | assert amt > 0 207 | return amt 208 | 209 | # tovault transaction 210 | # ------------------------------- 211 | 212 | @property 213 | def tovault_tx_unsigned(self) -> CMutableTransaction: 214 | """ 215 | Spend from a P2WPKH output into a new vault. 216 | 217 | The output is a bare OP_CTV script, which consumes less chain space 218 | than a P2(W)SH. 219 | """ 220 | tx = CMutableTransaction() 221 | tx.nVersion = 2 222 | tx.vin = [CTxIn(self.coin_in.outpoint, nSequence=0)] # signal for RBF 223 | tx.vout = [ 224 | CTxOut( 225 | self.amount_at_step(1), 226 | CScript([self.unvault_ctv_hash, OP_CHECKTEMPLATEVERIFY]), 227 | ) 228 | ] 229 | return tx 230 | 231 | def sign_tovault_tx(self, from_privkey: PrivateKey) -> CTransaction: 232 | tx = self.tovault_tx_unsigned 233 | 234 | spend_from_addr = CBech32BitcoinAddress.from_scriptPubKey( 235 | CScript(self.coin_in.scriptPubKey) 236 | ) 237 | 238 | # Standard p2wpkh redeemScript 239 | redeem_script = CScript( 240 | [ 241 | script.OP_DUP, 242 | script.OP_HASH160, 243 | spend_from_addr, 244 | script.OP_EQUALVERIFY, 245 | script.OP_CHECKSIG, 246 | ] 247 | ) 248 | 249 | sighash = script.SignatureHash( 250 | redeem_script, 251 | tx, 252 | 0, # input index 253 | script.SIGHASH_ALL, 254 | amount=self.coin_in.amount, 255 | sigversion=script.SIGVERSION_WITNESS_V0, 256 | ) 257 | sig = from_privkey.sign(int.from_bytes(sighash, "big")).der() + bytes( 258 | [script.SIGHASH_ALL] 259 | ) 260 | wit = [CTxInWitness(CScriptWitness([sig, from_privkey.point.sec()]))] 261 | tx.wit = CTxWitness(wit) 262 | return CTransaction.from_tx(tx) 263 | 264 | # unvault transaction 265 | # ------------------------------- 266 | 267 | @property 268 | def unvault_ctv_hash(self) -> bytes: 269 | """Return the CTV hash for the unvaulting transaction.""" 270 | return get_standard_template_hash(self.unvault_tx_template, 0) 271 | 272 | @property 273 | def unvault_tx_template(self) -> CMutableTransaction: 274 | """ 275 | Return the transaction that initiates the unvaulting process. 276 | 277 | Once this transaction is broadcast, we can either spend to the hot wallet 278 | with a delay or immediately sweep funds to the cold wallet. 279 | 280 | Note that the particular `vin` value still needs to be filled in, though 281 | it doesn't matter for the purposes of computing the CTV hash. 282 | """ 283 | # Used to compute CTV hashes, but not used in any final transactions. 284 | tx = CMutableTransaction() 285 | tx.nVersion = 2 286 | # We can leave this as a dummy input, since the coin we're spending here is 287 | # encumbered solely by CTV, e.g. 288 | # 289 | # ` OP_CTV` 290 | # 291 | # and so doesn't require any kind of scriptSig. Subsequently, it won't affect the 292 | # hash of this transaction. 293 | tx.vin = [BLANK_INPUT()] 294 | tx.vout = [ 295 | CTxOut( 296 | self.amount_at_step(2), 297 | # Standard P2WSH output: 298 | CScript([script.OP_0, sha256(self.unvault_redeemScript)]), 299 | ) 300 | ] 301 | return tx 302 | 303 | @property 304 | def unvault_redeemScript(self) -> CScript: 305 | return CScript( 306 | [ 307 | # fmt: off 308 | script.OP_IF, 309 | self.block_delay, script.OP_CHECKSEQUENCEVERIFY, script.OP_DROP, 310 | self.hot_pubkey.sec(), script.OP_CHECKSIG, 311 | script.OP_ELSE, 312 | self.tocold_ctv_hash, OP_CHECKTEMPLATEVERIFY, 313 | script.OP_ENDIF, 314 | # fmt: on 315 | ] 316 | ) 317 | 318 | @property 319 | def unvault_tx_unsigned(self) -> CMutableTransaction: 320 | tx = self.unvault_tx_template 321 | tx.vin = [CTxIn(self.tovault_outpoint)] 322 | return CTransaction.from_tx(tx) 323 | 324 | def sign_unvault_tx(self): 325 | # No signing necessary with a bare CTV output! 326 | return self.unvault_tx_unsigned 327 | 328 | # tocold transaction 329 | # ------------------------------- 330 | 331 | @property 332 | def tocold_tx_template(self) -> CMutableTransaction: 333 | """Return the transaction that sweeps vault funds to the cold destination.""" 334 | # scriptSig consists of a single push-0 to control the if-block above. 335 | return p2wpkh_tx_template( 336 | [CTxIn()], # blank scriptSig when spending P2WSH 337 | self.amount_at_step(3), 338 | pay_to_h160=self.cold_pubkey.hash160(), 339 | fee_mgmt_pay_to_h160=self.fees_pubkey.hash160(), 340 | ) 341 | 342 | @property 343 | def tocold_ctv_hash(self) -> bytes: 344 | return get_standard_template_hash(self.tocold_tx_template, 0) 345 | 346 | @property 347 | def tocold_tx_unsigned(self) -> CMutableTransaction: 348 | """Sends funds to the cold wallet from the unvault transaction.""" 349 | tx = self.tocold_tx_template 350 | unvault_outpoint = COutPoint(txid_to_bytes(self.unvault_txid), 0) 351 | tx.vin = [CTxIn(unvault_outpoint)] 352 | return tx 353 | 354 | def sign_tocold_tx(self): 355 | tx = self.tocold_tx_unsigned 356 | # Use the amount from the last step for the sighash. 357 | witness = CScriptWitness([b"", self.unvault_redeemScript]) 358 | tx.wit = CTxWitness([CTxInWitness(witness)]) 359 | 360 | return CTransaction.from_tx(tx) 361 | 362 | # tohot transaction 363 | # ------------------------------- 364 | 365 | @property 366 | def tohot_tx_template(self) -> CMutableTransaction: 367 | return p2wpkh_tx_template( 368 | [BLANK_INPUT()], 369 | self.amount_at_step(3), 370 | pay_to_h160=self.hot_pubkey.hash160(), 371 | fee_mgmt_pay_to_h160=self.fees_pubkey.hash160(), 372 | ) 373 | 374 | @property 375 | def tohot_tx_unsigned(self) -> CMutableTransaction: 376 | """Sends funds to the hot wallet from the unvault transaction.""" 377 | tx = self.tohot_tx_template 378 | tx.vin = [CTxIn(self.unvault_outpoint, nSequence=self.block_delay)] 379 | return tx 380 | 381 | def sign_tohot_tx(self, hot_priv: PrivateKey) -> CTransaction: 382 | """ 383 | Return a finalized, signed transaction moving the vault coins to the hot 384 | public key. 385 | """ 386 | tx = self.tohot_tx_unsigned 387 | 388 | sighash = script.SignatureHash( 389 | self.unvault_redeemScript, 390 | tx, 391 | 0, 392 | script.SIGHASH_ALL, 393 | amount=self.amount_at_step(2), # the prior step amount 394 | sigversion=script.SIGVERSION_WITNESS_V0, 395 | ) 396 | sig = hot_priv.sign(int.from_bytes(sighash, "big")).der() + bytes( 397 | [script.SIGHASH_ALL] 398 | ) 399 | witness = CScriptWitness([sig, b"\x01", self.unvault_redeemScript]) 400 | tx.wit = CTxWitness([CTxInWitness(witness)]) 401 | 402 | return CTransaction.from_tx(tx) 403 | 404 | 405 | def p2wpkh_tx_template( 406 | vin: t.List[CTxIn], nValue: int, pay_to_h160: bytes, fee_mgmt_pay_to_h160: bytes 407 | ) -> CMutableTransaction: 408 | """Create a transaction template paying into a P2WPKH.""" 409 | pay_to_script = CScript([script.OP_0, pay_to_h160]) 410 | assert pay_to_script.is_witness_v0_keyhash() 411 | 412 | pay_to_fee_script = CScript([script.OP_0, fee_mgmt_pay_to_h160]) 413 | assert pay_to_fee_script.is_witness_v0_keyhash() 414 | HOPEFULLY_NOT_DUST: Sats = 550 # obviously TOOD? 415 | 416 | tx = CMutableTransaction() 417 | tx.nVersion = 2 418 | tx.vin = vin 419 | tx.vout = [ 420 | CTxOut(nValue, pay_to_script), 421 | # Anchor output for CPFP-based fee bumps 422 | CTxOut(HOPEFULLY_NOT_DUST, pay_to_fee_script), 423 | ] 424 | return tx 425 | 426 | 427 | def make_color(start, end: str) -> t.Callable[[str], str]: 428 | def color_func(s: str) -> str: 429 | return start + t_(s) + end 430 | 431 | return color_func 432 | 433 | 434 | def esc(*codes: t.Union[int, str]) -> str: 435 | """ 436 | Produces an ANSI escape code from a list of integers 437 | """ 438 | return t_("\x1b[{}m").format(t_(";").join(t_(str(c)) for c in codes)) 439 | 440 | 441 | def t_(b: t.Union[bytes, t.Any]) -> str: 442 | """ensure text type""" 443 | if isinstance(b, bytes): 444 | return b.decode() 445 | return b 446 | 447 | 448 | FG_END = esc(39) 449 | red = make_color(esc(31), FG_END) 450 | green = make_color(esc(32), FG_END) 451 | yellow = make_color(esc(33), FG_END) 452 | blue = make_color(esc(34), FG_END) 453 | cyan = make_color(esc(36), FG_END) 454 | bold = make_color(esc(1), esc(22)) 455 | 456 | 457 | def no_output(*args, **kwargs): 458 | pass 459 | 460 | 461 | @dataclass 462 | class VaultExecutor: 463 | plan: VaultPlan 464 | rpc: BitcoinRPC 465 | coin_in: Coin 466 | 467 | log: t.Callable = no_output 468 | 469 | def send_to_vault(self, coin: Coin, spend_key: PrivateKey) -> TxidStr: 470 | self.log(bold("# Sending to vault\n")) 471 | 472 | self.log(f"Spending coin ({coin.outpoint}) {bold(f'({coin.amount} sats)')}") 473 | (tx, hx) = self._print_signed_tx(self.plan.sign_tovault_tx, spend_key) 474 | 475 | txid = self.rpc.sendrawtransaction(hx) 476 | assert txid == tx.GetTxid()[::-1].hex() == self.plan.tovault_txid 477 | 478 | self.log() 479 | self.log(f"Coins are vaulted at {green(txid)}") 480 | return txid 481 | 482 | def start_unvault(self) -> TxidStr: 483 | self.log(bold("# Starting unvault")) 484 | 485 | _, hx = self._print_signed_tx(self.plan.sign_unvault_tx) 486 | txid = self.rpc.sendrawtransaction(hx) 487 | self.unvault_outpoint = COutPoint(txid_to_bytes(txid), 0) 488 | return txid 489 | 490 | def get_tocold_tx(self) -> CTransaction: 491 | cold_addr = self.plan.cold_pubkey.p2wpkh_address(self.rpc.net_name) 492 | self.log(bold(f"# Sweep to cold ({cold_addr})\n")) 493 | 494 | (tx, _) = self._print_signed_tx(self.plan.sign_tocold_tx) 495 | return tx 496 | 497 | def get_tohot_tx(self, hot_privkey) -> CTransaction: 498 | hot_addr = self.plan.hot_pubkey.p2wpkh_address(self.rpc.net_name) 499 | self.log(bold(f"# Sweep to hot ({hot_addr})")) 500 | 501 | (tx, _) = self._print_signed_tx(self.plan.sign_tohot_tx, hot_privkey) 502 | return tx 503 | 504 | def search_for_unvault(self) -> t.Optional[str]: 505 | """ 506 | Return the location of the unvault transaction, if one exists. 507 | 508 | This can be used for alerting on unexpected unvaulting attempts. 509 | """ 510 | mempool_txids = self.rpc.getrawmempool(False) 511 | 512 | if self.plan.unvault_txid in mempool_txids: 513 | self.log("Unvault transaction detected in mempool") 514 | return "mempool" 515 | 516 | confirmed_txout = self.rpc.gettxout(self.plan.unvault_txid, 0, False) 517 | if confirmed_txout: 518 | self.log(f"Unvault transaction confirmed: {confirmed_txout}") 519 | return "chain" 520 | 521 | return None 522 | 523 | def _print_signed_tx( 524 | self, signed_txn_fnc, *args, **kwargs 525 | ) -> t.Tuple[CTransaction, RawTxStr]: 526 | """Plan a finalized transaction and print its broadcast information.""" 527 | tx = signed_txn_fnc(*args, **kwargs) 528 | hx = tx.serialize().hex() 529 | 530 | self.log(bold(f"\n## Transaction {yellow(tx.GetTxid()[::-1].hex())}")) 531 | self.log(f"{tx}") 532 | self.log() 533 | self.log("### Raw hex") 534 | self.log(hx) 535 | 536 | return tx, hx 537 | 538 | 539 | def generateblocks(rpc: BitcoinRPC, n: int = 1, addr: str = None) -> t.List[str]: 540 | if not addr: 541 | addr = ( 542 | HDPrivateKey.from_seed(b"yaddayah") 543 | .get_private_key(1) 544 | .point.p2wpkh_address(network=rpc.net_name) 545 | ) 546 | return rpc.generatetoaddress(n, addr) 547 | 548 | 549 | def sha256(s) -> bytes: 550 | return hashlib.sha256(s).digest() 551 | 552 | 553 | def ser_compact_size(l) -> bytes: 554 | r = b"" 555 | if l < 253: 556 | r = struct.pack("B", l) 557 | elif l < 0x10000: 558 | r = struct.pack(" bytes: 567 | return ser_compact_size(len(s)) + s 568 | 569 | 570 | def get_standard_template_hash(tx: CTransaction, nIn: int) -> bytes: 571 | r = b"" 572 | r += struct.pack(" bytes: 587 | """Convert the txids output by Bitcoin Core (little endian) to bytes.""" 588 | return bytes.fromhex(txid)[::-1] 589 | 590 | 591 | def bytes_to_txid(b: bytes) -> str: 592 | """Convert big-endian bytes to Core-style txid str.""" 593 | return b[::-1].hex() 594 | 595 | 596 | def to_outpoint(txid: TxidStr, n: int) -> COutPoint: 597 | return COutPoint(txid_to_bytes(txid), n) 598 | 599 | 600 | def scan_utxos(rpc, addr): 601 | return rpc.scantxoutset("start", [f"addr({addr})"]) 602 | 603 | 604 | @dataclass 605 | class VaultScenario: 606 | """Instantiate everything needed to do vault operations.""" 607 | 608 | network: str 609 | rpc: BitcoinRPC 610 | 611 | from_wallet: Wallet 612 | fee_wallet: Wallet 613 | cold_wallet: Wallet 614 | hot_wallet: Wallet 615 | coin_in: Coin 616 | 617 | plan: VaultPlan 618 | exec: VaultExecutor 619 | 620 | @classmethod 621 | def from_network(cls, network: str, seed: bytes, coin: Coin = None, **plan_kwargs): 622 | SelectParams(network) 623 | from_wallet = Wallet.generate(b"from-" + seed) 624 | fee_wallet = Wallet.generate(b"fee-" + seed) 625 | cold_wallet = Wallet.generate(b"cold-" + seed) 626 | hot_wallet = Wallet.generate(b"hot-" + seed) 627 | 628 | rpc = BitcoinRPC(net_name=network) 629 | coin = coin or from_wallet.fund(rpc) 630 | plan = VaultPlan( 631 | hot_wallet.privkey.point, 632 | cold_wallet.privkey.point, 633 | fee_wallet.privkey.point, 634 | coin, 635 | **plan_kwargs, 636 | ) 637 | 638 | return cls( 639 | network, 640 | rpc, 641 | from_wallet=from_wallet, 642 | fee_wallet=fee_wallet, 643 | cold_wallet=cold_wallet, 644 | hot_wallet=hot_wallet, 645 | coin_in=coin, 646 | plan=plan, 647 | exec=VaultExecutor(plan, rpc, coin), 648 | ) 649 | 650 | @classmethod 651 | def for_demo(cls, original_coin_txid: TxidStr = None) -> 'VaultScenario': 652 | """ 653 | Instantiate a scenario for the demo, optionally resuming an existing 654 | vault using the txid of the coin we spent into it. 655 | """ 656 | coin_in = None 657 | if original_coin_txid: 658 | # We're resuming a vault 659 | rpc = BitcoinRPC(net_name="regtest") 660 | coin_in = Coin.from_txid(original_coin_txid, 0, rpc) 661 | 662 | c = VaultScenario.from_network( 663 | "regtest", seed=b"demo", coin=coin_in, block_delay=10 664 | ) 665 | c.exec.log = lambda *args, **kwargs: print(*args, file=sys.stderr, **kwargs) 666 | 667 | return c 668 | 669 | 670 | @cli.cmd 671 | def vault(): 672 | """ 673 | Returns the txid of the coin spent into the vault, which is used to resume vault 674 | operations. 675 | """ 676 | c = VaultScenario.for_demo() 677 | 678 | c.exec.send_to_vault(c.coin_in, c.from_wallet.privkey) 679 | assert not c.exec.search_for_unvault() 680 | original_coin_txid = c.coin_in.outpoint.hash[::-1].hex() 681 | print(original_coin_txid) 682 | 683 | 684 | @cli.cmd 685 | def unvault(original_coin_txid: TxidStr): 686 | """ 687 | Start the unvault process with an existing vault, based on the orignal coin 688 | input. 689 | 690 | We assume the original coin has a vout index of 0. 691 | 692 | Args: 693 | original_coin_txid: the txid of the original coin we spent into the vault. 694 | """ 695 | c = VaultScenario.for_demo(original_coin_txid) 696 | c.exec.start_unvault() 697 | 698 | 699 | @cli.cmd 700 | def generate_blocks(n: int): 701 | rpc = BitcoinRPC(net_name="regtest") 702 | pprint.pprint(generateblocks(rpc, n)) 703 | 704 | 705 | @cli.cmd 706 | def alert_on_unvault(original_coin_txid: TxidStr): 707 | c = VaultScenario.for_demo(original_coin_txid) 708 | c.exec.log = no_output 709 | unvault_location = c.exec.search_for_unvault() 710 | 711 | if unvault_location: 712 | print(f"Unvault txn detected in {red(unvault_location)}!") 713 | print(f"If this is unexpected, {red('sweep to cold now')} with ") 714 | print(yellow(f"\n ./main.py to-cold {original_coin_txid}")) 715 | sys.exit(1) 716 | 717 | 718 | @cli.cmd 719 | def to_hot(original_coin_txid: TxidStr): 720 | """Spend funds to the hot wallet.""" 721 | c = VaultScenario.for_demo(original_coin_txid) 722 | tx = c.exec.get_tohot_tx(c.hot_wallet.privkey) 723 | _broadcast_final(c, tx, 'hot') 724 | 725 | 726 | @cli.cmd 727 | def to_cold(original_coin_txid: TxidStr): 728 | """Sweep funds to the cold wallet.""" 729 | c = VaultScenario.for_demo(original_coin_txid) 730 | tx = c.exec.get_tocold_tx() 731 | _broadcast_final(c, tx, 'cold') 732 | 733 | 734 | def _broadcast_final(c: VaultScenario, tx: CTransaction, hot_or_cold: str): 735 | print() 736 | if hot_or_cold == 'cold': 737 | title = f"CTV-encumbering to {cyan('cold')}" 738 | else: 739 | title = f"spending to {red('hot')}" 740 | 741 | if input(f"Broadcast transaction {title}? (y/n) ") == 'y': 742 | try: 743 | txid = c.rpc.sendrawtransaction(tx.serialize().hex()) 744 | except JSONRPCError as e: 745 | if 'non-BIP68' in e.msg: 746 | print("!!! can't broadcast to hot - OP_CSV fails") 747 | sys.exit(2) 748 | elif 'missingorspent' in e.msg: 749 | print("!!! can't broadcast - unvault txn hasn't been seen yet") 750 | sys.exit(3) 751 | else: 752 | raise 753 | 754 | print(f"Broadcast done: {green(txid)}") 755 | print() 756 | pprint.pprint(c.rpc.gettxout(txid, 0)) 757 | 758 | 759 | if __name__ == "__main__": 760 | cli.run() 761 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | python-bitcoinlib 2 | clii 3 | git+https://github.com/buidl-bitcoin/buidl-python 4 | -------------------------------------------------------------------------------- /rpc.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2007 Jan-Klaas Kollhof 2 | # Copyright (C) 2011-2018 The python-bitcoinlib developers 3 | # Copyright (C) 2022 James O'Beirne 4 | # 5 | # This file is part of python-bitcoinlib. 6 | # 7 | # It is subject to the license terms in the LICENSE file found in the top-level 8 | # directory of this distribution. 9 | # 10 | # No part of python-bitcoinlib, including this file, may be copied, modified, 11 | # propagated, or distributed except according to the terms contained in the 12 | # LICENSE file. 13 | import json 14 | import logging 15 | import typing as t 16 | import re 17 | import base64 18 | import time 19 | import platform 20 | import urllib.parse as urlparse 21 | import socket 22 | import http.client 23 | import os 24 | import http.client as httplib 25 | 26 | from typing import IO, Optional as Op 27 | from decimal import Decimal 28 | 29 | 30 | DEFAULT_USER_AGENT = "AuthServiceProxy/0.1" 31 | DEFAULT_HTTP_TIMEOUT = 30 32 | 33 | rpc_logger = logging.getLogger("rpc") 34 | rpc_logger.setLevel(logging.INFO) 35 | 36 | 37 | class JSONRPCError(Exception): 38 | """JSON-RPC protocol error base class 39 | Subclasses of this class also exist for specific types of errors; the set 40 | of all subclasses is by no means complete. 41 | """ 42 | 43 | def __init__(self, rpc_error): 44 | super(JSONRPCError, self).__init__( 45 | "msg: %r code: %r" % (rpc_error["message"], rpc_error["code"]) 46 | ) 47 | self.error = rpc_error 48 | 49 | @property 50 | def code(self) -> int: 51 | return int(self.error["code"]) 52 | 53 | @property 54 | def msg(self) -> str: 55 | return self.error["message"] 56 | 57 | 58 | PORTS = { 59 | 'mainnet': 8332, 60 | 'testnet': 18332, 61 | 'regtest': 18443, 62 | 'signet': 38332, 63 | } 64 | PORT_TO_NET = {v: k for k, v in PORTS.items()} 65 | 66 | 67 | class BitcoinRPC(object): 68 | """Base JSON-RPC proxy class. Contains only private methods; do not use 69 | directly.""" 70 | 71 | def __init__( 72 | self, 73 | service_url=None, 74 | service_port=None, 75 | btc_conf_file=None, 76 | net_name=None, 77 | timeout=DEFAULT_HTTP_TIMEOUT, 78 | debug_stream: Op[IO] = None, 79 | wallet_name=None, 80 | ): 81 | 82 | self.debug_stream = debug_stream 83 | authpair = None 84 | net_name = net_name or "mainnet" 85 | self.timeout = timeout 86 | self.net_name = net_name 87 | if self.net_name not in PORTS: 88 | raise ValueError(f"unrecognized network '{self.net_name}'") 89 | 90 | # Figure out the path to the bitcoin.conf file 91 | if btc_conf_file is None: 92 | if platform.system() == "Darwin": 93 | btc_conf_file = os.path.expanduser( 94 | "~/Library/Application Support/Bitcoin/" 95 | ) 96 | elif platform.system() == "Windows": 97 | btc_conf_file = os.path.join(os.environ["APPDATA"], "Bitcoin") 98 | else: 99 | btc_conf_file = os.path.expanduser("~/.bitcoin") 100 | btc_conf_file = os.path.join(btc_conf_file, "bitcoin.conf") 101 | 102 | if not service_url: 103 | # Bitcoin Core accepts empty rpcuser, not specified in btc_conf_file 104 | conf = self._get_bitcoind_conf_from_filesystem(btc_conf_file) 105 | service_port = service_port or PORTS[net_name] 106 | 107 | conf["rpcport"] = int(conf.get("rpcport", service_port)) # type: ignore 108 | conf["rpchost"] = conf.get("rpcconnect", "localhost") 109 | 110 | service_url = f"http://{conf['rpchost']}:{conf['rpcport']}" 111 | 112 | authpair = self._get_bitcoind_cookie_authpair(conf, btc_conf_file, net_name) 113 | else: 114 | url = urlparse.urlparse(service_url) 115 | authpair = "%s:%s" % (url.username or "", url.password or "") 116 | 117 | # Do our best to autodetect testnet. 118 | if url.port: 119 | self.net_name = net_name = PORT_TO_NET.get(url.port, 'mainnet') 120 | 121 | # Try and pull in auth information from the filesystem if it's missing. 122 | if authpair == ":": 123 | conf = self._get_bitcoind_conf_from_filesystem(btc_conf_file) 124 | authpair = self._get_bitcoind_cookie_authpair( 125 | conf, btc_conf_file, net_name 126 | ) 127 | rpc_logger.debug("pulling authpair from cookie despite intaking URL") 128 | 129 | if wallet_name: 130 | service_url = service_url.rstrip("/") 131 | service_url += f"/wallet/{wallet_name}" 132 | 133 | rpc_logger.info(f"Connecting to bitcoind: {service_url}") 134 | self.url = service_url 135 | 136 | # Credential redacted 137 | self.public_url = re.sub(r":[^/]+@", ":***@", self.url, 1) 138 | self._parsed_url = urlparse.urlparse(service_url) 139 | self.host = self._parsed_url.hostname 140 | 141 | rpc_logger.info(f"Initializing RPC client at {self.public_url}") 142 | # XXX keep for debugging, but don't ship: 143 | # logger.info(f"[REMOVE THIS] USING AUTHPAIR {authpair}") 144 | 145 | if self._parsed_url.scheme not in ("http",): 146 | raise ValueError("Unsupported URL scheme %r" % self._parsed_url.scheme) 147 | 148 | self.__id_count = 0 149 | 150 | self.__auth_header = None 151 | if authpair: 152 | self.__auth_header = b"Basic " + base64.b64encode(authpair.encode("utf8")) 153 | 154 | def _get_bitcoind_conf_from_filesystem(self, btc_conf_file: str) -> t.Dict: 155 | conf = {"rpcuser": ""} 156 | 157 | # Extract contents of bitcoin.conf to build service_url 158 | try: 159 | with open(btc_conf_file, "r") as fd: 160 | for line in fd.readlines(): 161 | if "#" in line: 162 | line = line[: line.index("#")] 163 | if "=" not in line: 164 | continue 165 | k, v = line.split("=", 1) 166 | conf[k.strip()] = v.strip() 167 | 168 | # Treat a missing bitcoin.conf as though it were empty 169 | except FileNotFoundError: 170 | pass 171 | 172 | return conf 173 | 174 | def _get_bitcoind_cookie_authpair( 175 | self, conf: dict, btc_conf_file: str, net_name: str 176 | ) -> t.Optional[str]: 177 | """Get an authpair from the cookie or configuration files.""" 178 | authpair = "" 179 | cookie_dir = conf.get("datadir", os.path.dirname(btc_conf_file)) 180 | if net_name != "mainnet": 181 | cookie_dir = os.path.join(cookie_dir, net_name) 182 | cookie_file = os.path.join(cookie_dir, ".cookie") 183 | try: 184 | with open(cookie_file, "r") as fd: 185 | authpair = fd.read() 186 | rpc_logger.debug("read authpair from cookie") 187 | except (IOError, FileNotFoundError) as err: 188 | rpc_logger.debug("couldn't read authpair from cookie", exc_info=True) 189 | if "rpcpassword" in conf: 190 | authpair = "%s:%s" % (conf["rpcuser"], conf["rpcpassword"]) 191 | rpc_logger.debug("read authpair from conf") 192 | else: 193 | raise ValueError( 194 | "Cookie file unusable (%s) and rpcpassword not specified " 195 | "in the configuration file: %r" % (err, btc_conf_file) 196 | ) 197 | 198 | return authpair 199 | 200 | @property 201 | def port(self) -> int: 202 | if self._parsed_url.port is None: 203 | return httplib.HTTP_PORT 204 | else: 205 | return self._parsed_url.port 206 | 207 | def _getconn(self, timeout=None): 208 | assert self._parsed_url.hostname 209 | return httplib.HTTPConnection( 210 | self._parsed_url.hostname, 211 | port=self.port, 212 | timeout=timeout, 213 | ) 214 | 215 | def _call(self, service_name, *args, **kwargs): 216 | self.__id_count += 1 217 | kwargs.setdefault("timeout", self.timeout) 218 | 219 | postdata = json.dumps( 220 | { 221 | "version": "1.1", 222 | "method": service_name, 223 | "params": args, 224 | "id": self.__id_count, 225 | } 226 | ) 227 | 228 | rpc_logger.debug(f"[{self.public_url}] calling %s%s", service_name, args) 229 | 230 | headers = { 231 | "Host": self._parsed_url.hostname, 232 | "User-Agent": DEFAULT_USER_AGENT, 233 | "Content-type": "application/json", 234 | } 235 | 236 | if self.__auth_header is not None: 237 | headers["Authorization"] = self.__auth_header 238 | 239 | path = self._parsed_url.path 240 | tries = 5 241 | backoff = 0.3 242 | conn = None 243 | while tries: 244 | try: 245 | conn = self._getconn(timeout=kwargs["timeout"]) 246 | conn.request("POST", path, postdata, headers) 247 | except (BlockingIOError, http.client.CannotSendRequest, socket.gaierror): 248 | rpc_logger.exception( 249 | f"hit request error: {path}, {postdata}, {self._parsed_url}" 250 | ) 251 | tries -= 1 252 | if not tries: 253 | raise 254 | time.sleep(backoff) 255 | backoff *= 2 256 | else: 257 | break 258 | 259 | assert conn 260 | response = self._get_response(conn) 261 | err = response.get("error") 262 | if err is not None: 263 | if isinstance(err, dict): 264 | raise JSONRPCError( 265 | { 266 | "code": err.get("code", -345), 267 | "message": err.get("message", "error message not specified"), 268 | } 269 | ) 270 | raise JSONRPCError({"code": -344, "message": str(err)}) 271 | elif "result" not in response: 272 | raise JSONRPCError({"code": -343, "message": "missing JSON-RPC result"}) 273 | else: 274 | return response["result"] 275 | 276 | def _get_response(self, conn): 277 | http_response = conn.getresponse() 278 | if http_response is None: 279 | raise JSONRPCError( 280 | {"code": -342, "message": "missing HTTP response from server"} 281 | ) 282 | 283 | rdata = http_response.read().decode("utf8") 284 | try: 285 | loaded = json.loads(rdata, parse_float=Decimal) 286 | rpc_logger.debug(f"[{self.public_url}] -> {loaded}") 287 | return loaded 288 | except Exception: 289 | raise JSONRPCError( 290 | { 291 | "code": -342, 292 | "message": ( 293 | "non-JSON HTTP response with '%i %s' from server: '%.20s%s'" 294 | % ( 295 | http_response.status, 296 | http_response.reason, 297 | rdata, 298 | "..." if len(rdata) > 20 else "", 299 | ) 300 | ), 301 | } 302 | ) 303 | 304 | def __getattr__(self, name): 305 | if name.startswith("__") and name.endswith("__"): 306 | # Prevent RPC calls for non-existing python internal attribute 307 | # access. If someone tries to get an internal attribute 308 | # of RawProxy instance, and the instance does not have this 309 | # attribute, we do not want the bogus RPC call to happen. 310 | raise AttributeError 311 | 312 | # Create a callable to do the actual call 313 | def _call_wrapper(*args, **kwargs): 314 | return self._call(name, *args, **kwargs) 315 | 316 | # Make debuggers show rather than > 318 | _call_wrapper.__name__ = name 319 | return _call_wrapper 320 | -------------------------------------------------------------------------------- /test_main.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | import pytest 5 | from bitcoin.core import CTransaction, COIN 6 | 7 | from rpc import JSONRPCError 8 | from main import ( 9 | VaultScenario, 10 | generateblocks, 11 | get_standard_template_hash, 12 | ) 13 | 14 | 15 | def test_functional(): 16 | """Run functional test. Requires bitcoind -regtest running.""" 17 | _run_functional_test(ends_in_hot=True) 18 | _run_functional_test(ends_in_hot=False) 19 | 20 | 21 | def _run_functional_test(ends_in_hot=True): 22 | """ 23 | Exercise the full lifecycle of a vault. 24 | 25 | Args: 26 | ends_in_hot: if true, the to-hot unvault txn is ultimately confirmed. Otherwise 27 | we preempt and sweep to cold. 28 | """ 29 | block_delay = 3 30 | c = VaultScenario.from_network("regtest", seed=b"functest", block_delay=block_delay) 31 | exec = c.exec 32 | plan = c.plan 33 | rpc = c.rpc 34 | coin = c.coin_in 35 | 36 | initial_amount = coin.amount 37 | expected_amount_per_step = [ 38 | initial_amount, # before vaulting 39 | initial_amount - (plan.fees_per_step * 1), # step 1: vaulted output 40 | initial_amount - (plan.fees_per_step * 2), # step 2: unvaulted output 41 | initial_amount - (plan.fees_per_step * 3), # step 3: spent to hot or cold 42 | ] 43 | 44 | def check_amount(txid, n, expected_amount): 45 | got_amt = (rpc.gettxout(txid, n) or {}).get('value', 0) 46 | assert int(got_amt * COIN) == expected_amount 47 | 48 | vaulted_txid = exec.send_to_vault(coin, c.from_wallet.privkey) 49 | assert not exec.search_for_unvault() 50 | 51 | check_amount(vaulted_txid, 0, expected_amount_per_step[1]) 52 | 53 | tocold_tx = exec.get_tocold_tx() 54 | tocold_hex = tocold_tx.serialize().hex() 55 | 56 | tohot_tx = exec.get_tohot_tx(c.hot_wallet.privkey) 57 | tohot_hex = tohot_tx.serialize().hex() 58 | 59 | # Shouldn't be able to send particular unvault txs yet. 60 | with pytest.raises(JSONRPCError): 61 | rpc.sendrawtransaction(tocold_hex) 62 | 63 | with pytest.raises(JSONRPCError): 64 | rpc.sendrawtransaction(tohot_hex) 65 | 66 | unvaulted_txid = exec.start_unvault() 67 | 68 | assert exec.search_for_unvault() == "mempool" 69 | check_amount(unvaulted_txid, 0, expected_amount_per_step[2]) 70 | check_amount(vaulted_txid, 0, 0) 71 | 72 | with pytest.raises(JSONRPCError): 73 | # to-hot should fail due to OP_CSV 74 | rpc.sendrawtransaction(tohot_hex) 75 | 76 | # Unvault tx confirms 77 | generateblocks(rpc, 1) 78 | assert exec.search_for_unvault() == "chain" 79 | 80 | with pytest.raises(JSONRPCError): 81 | # to-hot should *still* fail due to OP_CSV 82 | rpc.sendrawtransaction(tohot_hex) 83 | 84 | if ends_in_hot: 85 | # Mine enough blocks to allow the to-hot to be valid, send it. 86 | generateblocks(rpc, block_delay - 1) 87 | 88 | txid = rpc.sendrawtransaction(tohot_hex) 89 | assert txid == tohot_tx.GetTxid()[::-1].hex() 90 | else: 91 | # "Sweep" the funds to the cold wallet because this is an unvaulting 92 | # we didn't expect. 93 | txid = rpc.sendrawtransaction(tocold_hex) 94 | assert txid == tocold_tx.GetTxid()[::-1].hex() 95 | 96 | generateblocks(rpc, 1) 97 | txout = rpc.gettxout(txid, 0) 98 | assert txout["confirmations"] == 1 99 | check_amount(txid, 0, expected_amount_per_step[3]) 100 | check_amount(vaulted_txid, 0, 0) 101 | check_amount(unvaulted_txid, 0, 0) 102 | 103 | anchor_txout = rpc.gettxout(txid, 1) 104 | print(anchor_txout) 105 | fees_addr = plan.fees_pubkey.p2wpkh_address(rpc.net_name) 106 | assert anchor_txout['value'] > 0 107 | assert anchor_txout['scriptPubKey']['address'] == fees_addr 108 | 109 | 110 | def test_ctv_hash(): 111 | data = json.loads(Path("ctvhash-test-vectors.json").read_bytes())[1:-1] 112 | tests = 0 113 | 114 | for case in data: 115 | tx = CTransaction.deserialize(bytearray.fromhex(case["hex_tx"])) 116 | 117 | for idx, res in zip(case["spend_index"], case["result"]): 118 | assert get_standard_template_hash(tx, idx).hex() == res 119 | tests += 1 120 | 121 | print(tests) 122 | assert tests > 0 123 | --------------------------------------------------------------------------------