├── 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 | 
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 | 
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 | 
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 | 
173 |
174 | We can monitor for such an event and respond by sweeping our keys to the cold wallet.
175 |
176 | 
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 | 
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 |
--------------------------------------------------------------------------------