Skip to content

Commit 43e7c44

Browse files
committed
qa: functional tests lianad using Taproot descriptors
We introduce Taproot support in the test framework through a global toggle. A few modifications are made to some tests to adapt them under Taproot (notably the hardcoded fees / amounts). This is based on my introduction of a quick and dirty support for TapMiniscript in my python-bip380 library: darosior/python-bip380#23. In addition to this i didn't want to implement a signer in the Python test suite so here we introduce a simple Rust program based on our "hot signer" which will sign a PSBT with an xpriv provided through its stdin and output the signed PSBT on its stdout. Eventually it would be nicer to have a Python signer instead of having to call a program. The whole test suite should pass under both Taproot and P2WSH. Only a single test is skipped for now under Taproot since it needs a finalizer in the test suite. I also caught a bug in the RBF tests which i fixed in place.
1 parent 26b0de3 commit 43e7c44

13 files changed

+443
-75
lines changed

tests/README.md

+10
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,16 @@ From the root of the repository:
4444
pytest tests/
4545
```
4646

47+
For running the tests under Taproot a `bitcoind` version 26.0 or superior must be used. It can be
48+
pointed to using the `BITCOIND_PATH` variable. For now, one must also compile the `taproot_signer`
49+
Rust program:
50+
```
51+
(cd tests/tools/taproot_signer && cargo build --release)
52+
```
53+
54+
Then the test suite can be run by using Taproot descriptors instead of P2WSH descriptors by setting
55+
the `USE_TAPROOT` environment variable to `1`.
56+
4757
### Tips and tricks
4858
#### Logging
4959

tests/fixtures.py

+75-19
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,9 @@
55
from test_framework.bitcoind import Bitcoind
66
from test_framework.lianad import Lianad
77
from test_framework.signer import SingleSigner, MultiSigner
8-
from test_framework.utils import (
9-
EXECUTOR_WORKERS,
10-
)
8+
from test_framework.utils import EXECUTOR_WORKERS, USE_TAPROOT
119

10+
import hashlib
1211
import os
1312
import pytest
1413
import shutil
@@ -120,22 +119,36 @@ def xpub_fingerprint(hd):
120119
return _pubkey_to_fingerprint(hd.pubkey).hex()
121120

122121

122+
def single_key_desc(prim_fg, prim_xpub, reco_fg, reco_xpub, csv_value, is_taproot):
123+
if is_taproot:
124+
return f"tr([{prim_fg}]{prim_xpub}/<0;1>/*,and_v(v:pk([{reco_fg}]{reco_xpub}/<0;1>/*),older({csv_value})))"
125+
else:
126+
return f"wsh(or_d(pk([{prim_fg}]{prim_xpub}/<0;1>/*),and_v(v:pkh([{reco_fg}]{reco_xpub}/<0;1>/*),older({csv_value}))))"
127+
128+
123129
@pytest.fixture
124130
def lianad(bitcoind, directory):
125131
datadir = os.path.join(directory, "lianad")
126132
os.makedirs(datadir, exist_ok=True)
127133
bitcoind_cookie = os.path.join(bitcoind.bitcoin_dir, "regtest", ".cookie")
128134

129-
signer = SingleSigner()
135+
signer = SingleSigner(is_taproot=USE_TAPROOT)
130136
(prim_fingerprint, primary_xpub), (reco_fingerprint, recovery_xpub) = (
131137
(xpub_fingerprint(signer.primary_hd), signer.primary_hd.get_xpub()),
132138
(xpub_fingerprint(signer.recovery_hd), signer.recovery_hd.get_xpub()),
133139
)
134140
csv_value = 10
135-
# NOTE: origins are the actual xpub themselves which is incorrect but make it
141+
# NOTE: origins are the actual xpub themselves which is incorrect but makes it
136142
# possible to differentiate them.
137143
main_desc = Descriptor.from_str(
138-
f"wsh(or_d(pk([{prim_fingerprint}]{primary_xpub}/<0;1>/*),and_v(v:pkh([{reco_fingerprint}]{recovery_xpub}/<0;1>/*),older({csv_value}))))"
144+
single_key_desc(
145+
prim_fingerprint,
146+
primary_xpub,
147+
reco_fingerprint,
148+
recovery_xpub,
149+
csv_value,
150+
is_taproot=USE_TAPROOT,
151+
)
139152
)
140153

141154
lianad = Lianad(
@@ -156,8 +169,19 @@ def lianad(bitcoind, directory):
156169
lianad.cleanup()
157170

158171

159-
def multi_expression(thresh, keys):
160-
exp = f"multi({thresh},"
172+
def unspendable_internal_xpub(xpubs):
173+
"""Deterministic, unique, unspendable internal key.
174+
See See https://delvingbitcoin.org/t/unspendable-keys-in-descriptors/304/21
175+
"""
176+
chaincode = hashlib.sha256(b"".join(xpub.pubkey for xpub in xpubs)).digest()
177+
bip341_nums = bytes.fromhex(
178+
"0250929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0"
179+
)
180+
return BIP32(chaincode, pubkey=bip341_nums, network="test")
181+
182+
183+
def multi_expression(thresh, keys, is_taproot):
184+
exp = f"multi_a({thresh}," if is_taproot else f"multi({thresh},"
161185
for i, key in enumerate(keys):
162186
# NOTE: origins are the actual xpub themselves which is incorrect but make it
163187
# possible to differentiate them.
@@ -168,6 +192,21 @@ def multi_expression(thresh, keys):
168192
return exp + ")"
169193

170194

195+
def multisig_desc(multi_signer, csv_value, is_taproot):
196+
prim_multi, recov_multi = (
197+
multi_expression(3, multi_signer.prim_hds, is_taproot),
198+
multi_expression(2, multi_signer.recov_hds[csv_value], is_taproot),
199+
)
200+
if is_taproot:
201+
all_xpubs = [
202+
hd for hd in multi_signer.prim_hds + multi_signer.recov_hds[csv_value]
203+
]
204+
internal_key = unspendable_internal_xpub(all_xpubs).get_xpub()
205+
return f"tr([00000000]{internal_key}/<0;1>/*,{{{prim_multi},and_v(v:{recov_multi},older({csv_value}))}})"
206+
else:
207+
return f"wsh(or_d({prim_multi},and_v(v:{recov_multi},older({csv_value}))))"
208+
209+
171210
@pytest.fixture
172211
def lianad_multisig(bitcoind, directory):
173212
datadir = os.path.join(directory, "lianad")
@@ -176,13 +215,9 @@ def lianad_multisig(bitcoind, directory):
176215

177216
# A 3-of-4 that degrades into a 2-of-5 after 10 blocks
178217
csv_value = 10
179-
signer = MultiSigner(4, {csv_value: 5})
180-
prim_multi, recov_multi = (
181-
multi_expression(3, signer.prim_hds),
182-
multi_expression(2, signer.recov_hds[csv_value]),
183-
)
218+
signer = MultiSigner(4, {csv_value: 5}, is_taproot=USE_TAPROOT)
184219
main_desc = Descriptor.from_str(
185-
f"wsh(or_d({prim_multi},and_v(v:{recov_multi},older({csv_value}))))"
220+
multisig_desc(signer, csv_value, is_taproot=USE_TAPROOT)
186221
)
187222

188223
lianad = Lianad(
@@ -203,6 +238,28 @@ def lianad_multisig(bitcoind, directory):
203238
lianad.cleanup()
204239

205240

241+
def multipath_desc(multi_signer, csv_values, is_taproot):
242+
prim_multi = multi_expression(3, multi_signer.prim_hds, is_taproot)
243+
first_recov_multi = multi_expression(
244+
3, multi_signer.recov_hds[csv_values[0]], is_taproot
245+
)
246+
second_recov_multi = multi_expression(
247+
1, multi_signer.recov_hds[csv_values[1]], is_taproot
248+
)
249+
if is_taproot:
250+
all_xpubs = [
251+
hd
252+
for hd in multi_signer.prim_hds
253+
+ multi_signer.recov_hds[csv_values[0]]
254+
+ multi_signer.recov_hds[csv_values[1]]
255+
]
256+
internal_key = unspendable_internal_xpub(all_xpubs).get_xpub()
257+
# On purpose we use a single leaf instead of 3 different ones. It shouldn't be an issue.
258+
return f"tr([00000000]{internal_key}/<0;1>/*,or_d({prim_multi},or_i(and_v(v:{first_recov_multi},older({csv_values[0]})),and_v(v:{second_recov_multi},older({csv_values[1]})))))"
259+
else:
260+
return f"wsh(or_d({prim_multi},or_i(and_v(v:{first_recov_multi},older({csv_values[0]})),and_v(v:{second_recov_multi},older({csv_values[1]})))))"
261+
262+
206263
@pytest.fixture
207264
def lianad_multipath(bitcoind, directory):
208265
datadir = os.path.join(directory, "lianad")
@@ -211,12 +268,11 @@ def lianad_multipath(bitcoind, directory):
211268

212269
# A 3-of-4 that degrades into a 3-of-5 after 10 blocks and into a 1-of-10 after 20 blocks.
213270
csv_values = [10, 20]
214-
signer = MultiSigner(4, {csv_values[0]: 5, csv_values[1]: 10})
215-
prim_multi = multi_expression(3, signer.prim_hds)
216-
first_recov_multi = multi_expression(3, signer.recov_hds[csv_values[0]])
217-
second_recov_multi = multi_expression(1, signer.recov_hds[csv_values[1]])
271+
signer = MultiSigner(
272+
4, {csv_values[0]: 5, csv_values[1]: 10}, is_taproot=USE_TAPROOT
273+
)
218274
main_desc = Descriptor.from_str(
219-
f"wsh(or_d({prim_multi},or_i(and_v(v:{first_recov_multi},older({csv_values[0]})),and_v(v:{second_recov_multi},older({csv_values[1]})))))"
275+
multipath_desc(signer, csv_values, is_taproot=USE_TAPROOT)
220276
)
221277

222278
lianad = Lianad(

tests/requirements.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@ pytest-timeout==1.3.4
44
ephemeral_port_reserve==1.1.1
55

66
bip32~=3.0
7-
https://github.com/darosior/python-bip380/archive/f25eb2add9a5d461e382635231a5f971652fc8e1.zip
7+
https://github.com/darosior/python-bip380/archive/fb61971d9128e663f110ea2734c1d023e7e0266b.zip

tests/test_chain.py

+4
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
COIN,
1010
sign_and_broadcast,
1111
sign_and_broadcast_psbt,
12+
USE_TAPROOT,
1213
)
1314
from test_framework.serializations import PSBT
1415

@@ -353,6 +354,9 @@ def test_rescan_and_recovery(lianad, bitcoind):
353354
sign_and_broadcast(lianad, bitcoind, reco_psbt, recovery=True)
354355

355356

357+
@pytest.mark.skipif(
358+
USE_TAPROOT, reason="Needs a finalizer implemented in the Python test framework."
359+
)
356360
def test_conflicting_unconfirmed_spend_txs(lianad, bitcoind):
357361
"""Test we'll update the spending txid of a coin if a conflicting spend enters our mempool."""
358362
# Get an (unconfirmed, on purpose) coin to be spent by 2 different txs.

tests/test_framework/signer.py

+54-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
import os
3+
import subprocess
34

45
from bip32 import BIP32
56
from bip32.utils import coincurve
@@ -9,10 +10,16 @@
910
PSBT_IN_BIP32_DERIVATION,
1011
PSBT_IN_WITNESS_SCRIPT,
1112
PSBT_IN_PARTIAL_SIG,
13+
PSBT_IN_TAP_KEY_SIG,
14+
PSBT_IN_TAP_SCRIPT_SIG,
15+
PSBT_IN_TAP_LEAF_SCRIPT,
16+
PSBT_IN_TAP_BIP32_DERIVATION,
17+
PSBT_IN_TAP_INTERNAL_KEY,
18+
PSBT_IN_TAP_MERKLE_ROOT,
1219
)
1320

1421

15-
def sign_psbt(psbt, hds):
22+
def sign_psbt_wsh(psbt, hds):
1623
"""Sign a transaction.
1724
1825
This will fill the 'partial_sigs' field of all inputs.
@@ -58,12 +65,47 @@ def sign_psbt(psbt, hds):
5865
return psbt
5966

6067

68+
def sign_psbt_taproot(psbt, hds):
69+
"""Sign a transaction.
70+
71+
This will fill the 'tap_script_sig' / 'tap_key_sig' field of all inputs.
72+
73+
:param psbt: PSBT of the transaction to be signed.
74+
:param hds: the BIP32 objects to sign the transaction with.
75+
:returns: PSBT with a signature in each input for the given keys.
76+
"""
77+
assert isinstance(psbt, PSBT)
78+
79+
# This file is under tests/test_framework/ and we want tests/tools/taproot_signer/target/release/taproot_signer.
80+
bin_path = os.path.join(
81+
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
82+
"tools",
83+
"taproot_signer",
84+
"target",
85+
"release",
86+
"taproot_signer",
87+
)
88+
if not os.path.exists(bin_path):
89+
raise Exception(
90+
"Please compile the Taproot signer under tests/tools using 'cargo bin --release'."
91+
)
92+
93+
psbt_str = psbt.to_base64()
94+
for hd in hds:
95+
xprv = hd.get_xpriv()
96+
proc = subprocess.run([bin_path, psbt_str, xprv], capture_output=True, check=True)
97+
psbt_str = proc.stdout.decode("utf-8")
98+
99+
return PSBT.from_base64(psbt_str)
100+
101+
61102
class SingleSigner:
62103
"""Assumes a simple 1-primary path 1-recovery path Liana descriptor."""
63104

64-
def __init__(self):
105+
def __init__(self, is_taproot):
65106
self.primary_hd = BIP32.from_seed(os.urandom(32), network="test")
66107
self.recovery_hd = BIP32.from_seed(os.urandom(32), network="test")
108+
self.is_taproot = is_taproot
67109

68110
def sign_psbt(self, psbt, recovery=False):
69111
"""Sign a transaction.
@@ -75,13 +117,17 @@ def sign_psbt(self, psbt, recovery=False):
75117
:returns: PSBT with a signature in each input for the specified key.
76118
"""
77119
assert isinstance(recovery, bool)
78-
return sign_psbt(psbt, [self.recovery_hd if recovery else self.primary_hd])
120+
if self.is_taproot:
121+
return sign_psbt_taproot(
122+
psbt, [self.recovery_hd if recovery else self.primary_hd]
123+
)
124+
return sign_psbt_wsh(psbt, [self.recovery_hd if recovery else self.primary_hd])
79125

80126

81127
class MultiSigner:
82128
"""A signer that has multiple keys and may have multiple recovery path."""
83129

84-
def __init__(self, primary_hds_count, recovery_hds_counts):
130+
def __init__(self, primary_hds_count, recovery_hds_counts, is_taproot):
85131
self.prim_hds = [
86132
BIP32.from_seed(os.urandom(32), network="test")
87133
for _ in range(primary_hds_count)
@@ -91,6 +137,7 @@ def __init__(self, primary_hds_count, recovery_hds_counts):
91137
self.recov_hds[timelock] = [
92138
BIP32.from_seed(os.urandom(32), network="test") for _ in range(count)
93139
]
140+
self.is_taproot = is_taproot
94141

95142
def sign_psbt(self, psbt, key_indices):
96143
"""Sign a transaction with the keys at the specified indices.
@@ -106,4 +153,6 @@ def sign_psbt(self, psbt, key_indices):
106153
]
107154
else:
108155
hds = [self.prim_hds[i] for i in key_indices]
109-
return sign_psbt(psbt, hds)
156+
if self.is_taproot:
157+
return sign_psbt_taproot(psbt, hds)
158+
return sign_psbt_wsh(psbt, hds)

tests/test_framework/utils.py

+18-15
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
BITCOIND_PATH = os.getenv("BITCOIND_PATH", DEFAULT_BITCOIND_PATH)
2525
OLD_LIANAD_PATH = os.getenv("OLD_LIANAD_PATH", None)
2626
IS_NOT_BITCOIND_24 = bool(int(os.getenv("IS_NOT_BITCOIND_24", True)))
27+
USE_TAPROOT = bool(int(os.getenv("USE_TAPROOT", False))) # TODO: switch to True in a couple releases.
2728

2829

2930
COIN = 10**8
@@ -55,6 +56,21 @@ def get_txid(hex_tx):
5556
return tx.txid().hex()
5657

5758

59+
def sign_and_broadcast(lianad, bitcoind, psbt, recovery=False):
60+
"""Sign a PSBT, finalize it, extract the transaction and broadcast it."""
61+
signed_psbt = lianad.signer.sign_psbt(psbt, recovery)
62+
# Under Taproot i didn't bother implementing a finalizer in the test suite.
63+
if USE_TAPROOT:
64+
lianad.rpc.updatespend(signed_psbt.to_base64())
65+
txid = signed_psbt.tx.txid().hex()
66+
lianad.rpc.broadcastspend(txid)
67+
lianad.rpc.delspendtx(txid)
68+
return txid
69+
finalized_psbt = lianad.finalize_psbt(signed_psbt)
70+
tx = finalized_psbt.tx.serialize_with_witness().hex()
71+
return bitcoind.rpc.sendrawtransaction(tx)
72+
73+
5874
def spend_coins(lianad, bitcoind, coins):
5975
"""Spend these coins, no matter how.
6076
This will create a single transaction spending them all at once at the minimum
@@ -68,21 +84,8 @@ def spend_coins(lianad, bitcoind, coins):
6884
bitcoind.rpc.getnewaddress(): total_value - 11 - 31 - 300 * len(coins)
6985
}
7086
res = lianad.rpc.createspend(destinations, [c["outpoint"] for c in coins], 1)
71-
72-
signed_psbt = lianad.signer.sign_psbt(PSBT.from_base64(res["psbt"]))
73-
finalized_psbt = lianad.finalize_psbt(signed_psbt)
74-
tx = finalized_psbt.tx.serialize_with_witness().hex()
75-
bitcoind.rpc.sendrawtransaction(tx)
76-
77-
return tx
78-
79-
80-
def sign_and_broadcast(lianad, bitcoind, psbt, recovery=False):
81-
"""Sign a PSBT, finalize it, extract the transaction and broadcast it."""
82-
signed_psbt = lianad.signer.sign_psbt(psbt, recovery)
83-
finalized_psbt = lianad.finalize_psbt(signed_psbt)
84-
tx = finalized_psbt.tx.serialize_with_witness().hex()
85-
return bitcoind.rpc.sendrawtransaction(tx)
87+
txid = sign_and_broadcast(lianad, bitcoind, PSBT.from_base64(res["psbt"]))
88+
return bitcoind.rpc.getrawtransaction(txid)
8689

8790

8891
def sign_and_broadcast_psbt(lianad, psbt):

tests/test_misc.py

+1-3
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,7 @@ def receive_and_send(lianad, bitcoind):
6666
psbt = PSBT.from_base64(res["psbt"])
6767
txid = psbt.tx.txid().hex()
6868
# If we sign only with two keys it won't be able to finalize
69-
with pytest.raises(
70-
RpcError, match="Miniscript Error: could not satisfy at index 0"
71-
):
69+
with pytest.raises(RpcError, match="ould not satisfy.* at index 0"):
7270
signed_psbt = lianad.signer.sign_psbt(psbt, range(2))
7371
lianad.rpc.updatespend(signed_psbt.to_base64())
7472
lianad.rpc.broadcastspend(txid)

0 commit comments

Comments
 (0)