diff --git a/tests/README.md b/tests/README.md index a6d920b4b..eb726db34 100644 --- a/tests/README.md +++ b/tests/README.md @@ -44,6 +44,16 @@ From the root of the repository: pytest tests/ ``` +For running the tests under Taproot a `bitcoind` version 26.0 or superior must be used. It can be +pointed to using the `BITCOIND_PATH` variable. For now, one must also compile the `taproot_signer` +Rust program: +``` +(cd tests/tools/taproot_signer && cargo build --release) +``` + +Then the test suite can be run by using Taproot descriptors instead of P2WSH descriptors by setting +the `USE_TAPROOT` environment variable to `1`. + ### Tips and tricks #### Logging diff --git a/tests/fixtures.py b/tests/fixtures.py index ee8d5b7c3..68b6e9ee1 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -5,10 +5,9 @@ from test_framework.bitcoind import Bitcoind from test_framework.lianad import Lianad from test_framework.signer import SingleSigner, MultiSigner -from test_framework.utils import ( - EXECUTOR_WORKERS, -) +from test_framework.utils import EXECUTOR_WORKERS, USE_TAPROOT +import hashlib import os import pytest import shutil @@ -120,22 +119,36 @@ def xpub_fingerprint(hd): return _pubkey_to_fingerprint(hd.pubkey).hex() +def single_key_desc(prim_fg, prim_xpub, reco_fg, reco_xpub, csv_value, is_taproot): + if is_taproot: + return f"tr([{prim_fg}]{prim_xpub}/<0;1>/*,and_v(v:pk([{reco_fg}]{reco_xpub}/<0;1>/*),older({csv_value})))" + else: + 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}))))" + + @pytest.fixture def lianad(bitcoind, directory): datadir = os.path.join(directory, "lianad") os.makedirs(datadir, exist_ok=True) bitcoind_cookie = os.path.join(bitcoind.bitcoin_dir, "regtest", ".cookie") - signer = SingleSigner() + signer = SingleSigner(is_taproot=USE_TAPROOT) (prim_fingerprint, primary_xpub), (reco_fingerprint, recovery_xpub) = ( (xpub_fingerprint(signer.primary_hd), signer.primary_hd.get_xpub()), (xpub_fingerprint(signer.recovery_hd), signer.recovery_hd.get_xpub()), ) csv_value = 10 - # NOTE: origins are the actual xpub themselves which is incorrect but make it + # NOTE: origins are the actual xpub themselves which is incorrect but makes it # possible to differentiate them. main_desc = Descriptor.from_str( - f"wsh(or_d(pk([{prim_fingerprint}]{primary_xpub}/<0;1>/*),and_v(v:pkh([{reco_fingerprint}]{recovery_xpub}/<0;1>/*),older({csv_value}))))" + single_key_desc( + prim_fingerprint, + primary_xpub, + reco_fingerprint, + recovery_xpub, + csv_value, + is_taproot=USE_TAPROOT, + ) ) lianad = Lianad( @@ -156,8 +169,19 @@ def lianad(bitcoind, directory): lianad.cleanup() -def multi_expression(thresh, keys): - exp = f"multi({thresh}," +def unspendable_internal_xpub(xpubs): + """Deterministic, unique, unspendable internal key. + See See https://delvingbitcoin.org/t/unspendable-keys-in-descriptors/304/21 + """ + chaincode = hashlib.sha256(b"".join(xpub.pubkey for xpub in xpubs)).digest() + bip341_nums = bytes.fromhex( + "0250929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0" + ) + return BIP32(chaincode, pubkey=bip341_nums, network="test") + + +def multi_expression(thresh, keys, is_taproot): + exp = f"multi_a({thresh}," if is_taproot else f"multi({thresh}," for i, key in enumerate(keys): # NOTE: origins are the actual xpub themselves which is incorrect but make it # possible to differentiate them. @@ -168,6 +192,21 @@ def multi_expression(thresh, keys): return exp + ")" +def multisig_desc(multi_signer, csv_value, is_taproot): + prim_multi, recov_multi = ( + multi_expression(3, multi_signer.prim_hds, is_taproot), + multi_expression(2, multi_signer.recov_hds[csv_value], is_taproot), + ) + if is_taproot: + all_xpubs = [ + hd for hd in multi_signer.prim_hds + multi_signer.recov_hds[csv_value] + ] + internal_key = unspendable_internal_xpub(all_xpubs).get_xpub() + return f"tr([00000000]{internal_key}/<0;1>/*,{{{prim_multi},and_v(v:{recov_multi},older({csv_value}))}})" + else: + return f"wsh(or_d({prim_multi},and_v(v:{recov_multi},older({csv_value}))))" + + @pytest.fixture def lianad_multisig(bitcoind, directory): datadir = os.path.join(directory, "lianad") @@ -176,13 +215,9 @@ def lianad_multisig(bitcoind, directory): # A 3-of-4 that degrades into a 2-of-5 after 10 blocks csv_value = 10 - signer = MultiSigner(4, {csv_value: 5}) - prim_multi, recov_multi = ( - multi_expression(3, signer.prim_hds), - multi_expression(2, signer.recov_hds[csv_value]), - ) + signer = MultiSigner(4, {csv_value: 5}, is_taproot=USE_TAPROOT) main_desc = Descriptor.from_str( - f"wsh(or_d({prim_multi},and_v(v:{recov_multi},older({csv_value}))))" + multisig_desc(signer, csv_value, is_taproot=USE_TAPROOT) ) lianad = Lianad( @@ -203,6 +238,28 @@ def lianad_multisig(bitcoind, directory): lianad.cleanup() +def multipath_desc(multi_signer, csv_values, is_taproot): + prim_multi = multi_expression(3, multi_signer.prim_hds, is_taproot) + first_recov_multi = multi_expression( + 3, multi_signer.recov_hds[csv_values[0]], is_taproot + ) + second_recov_multi = multi_expression( + 1, multi_signer.recov_hds[csv_values[1]], is_taproot + ) + if is_taproot: + all_xpubs = [ + hd + for hd in multi_signer.prim_hds + + multi_signer.recov_hds[csv_values[0]] + + multi_signer.recov_hds[csv_values[1]] + ] + internal_key = unspendable_internal_xpub(all_xpubs).get_xpub() + # On purpose we use a single leaf instead of 3 different ones. It shouldn't be an issue. + 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]})))))" + else: + 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]})))))" + + @pytest.fixture def lianad_multipath(bitcoind, directory): datadir = os.path.join(directory, "lianad") @@ -211,12 +268,11 @@ def lianad_multipath(bitcoind, directory): # A 3-of-4 that degrades into a 3-of-5 after 10 blocks and into a 1-of-10 after 20 blocks. csv_values = [10, 20] - signer = MultiSigner(4, {csv_values[0]: 5, csv_values[1]: 10}) - prim_multi = multi_expression(3, signer.prim_hds) - first_recov_multi = multi_expression(3, signer.recov_hds[csv_values[0]]) - second_recov_multi = multi_expression(1, signer.recov_hds[csv_values[1]]) + signer = MultiSigner( + 4, {csv_values[0]: 5, csv_values[1]: 10}, is_taproot=USE_TAPROOT + ) main_desc = Descriptor.from_str( - 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]})))))" + multipath_desc(signer, csv_values, is_taproot=USE_TAPROOT) ) lianad = Lianad( diff --git a/tests/test_chain.py b/tests/test_chain.py index e93a088ae..6a45fb0ca 100644 --- a/tests/test_chain.py +++ b/tests/test_chain.py @@ -9,6 +9,7 @@ COIN, sign_and_broadcast, sign_and_broadcast_psbt, + USE_TAPROOT, ) from test_framework.serializations import PSBT @@ -353,6 +354,9 @@ def test_rescan_and_recovery(lianad, bitcoind): sign_and_broadcast(lianad, bitcoind, reco_psbt, recovery=True) +@pytest.mark.skipif( + USE_TAPROOT, reason="Needs a finalizer implemented in the Python test framework." +) def test_conflicting_unconfirmed_spend_txs(lianad, bitcoind): """Test we'll update the spending txid of a coin if a conflicting spend enters our mempool.""" # Get an (unconfirmed, on purpose) coin to be spent by 2 different txs. diff --git a/tests/test_framework/signer.py b/tests/test_framework/signer.py index 480045567..b0332eb9e 100644 --- a/tests/test_framework/signer.py +++ b/tests/test_framework/signer.py @@ -1,5 +1,6 @@ import logging import os +import subprocess from bip32 import BIP32 from bip32.utils import coincurve @@ -9,10 +10,16 @@ PSBT_IN_BIP32_DERIVATION, PSBT_IN_WITNESS_SCRIPT, PSBT_IN_PARTIAL_SIG, + PSBT_IN_TAP_KEY_SIG, + PSBT_IN_TAP_SCRIPT_SIG, + PSBT_IN_TAP_LEAF_SCRIPT, + PSBT_IN_TAP_BIP32_DERIVATION, + PSBT_IN_TAP_INTERNAL_KEY, + PSBT_IN_TAP_MERKLE_ROOT, ) -def sign_psbt(psbt, hds): +def sign_psbt_wsh(psbt, hds): """Sign a transaction. This will fill the 'partial_sigs' field of all inputs. @@ -58,12 +65,47 @@ def sign_psbt(psbt, hds): return psbt +def sign_psbt_taproot(psbt, hds): + """Sign a transaction. + + This will fill the 'tap_script_sig' / 'tap_key_sig' field of all inputs. + + :param psbt: PSBT of the transaction to be signed. + :param hds: the BIP32 objects to sign the transaction with. + :returns: PSBT with a signature in each input for the given keys. + """ + assert isinstance(psbt, PSBT) + + # This file is under tests/test_framework/ and we want tests/tools/taproot_signer/target/release/taproot_signer. + bin_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "tools", + "taproot_signer", + "target", + "release", + "taproot_signer", + ) + if not os.path.exists(bin_path): + raise Exception( + "Please compile the Taproot signer under tests/tools using 'cargo bin --release'." + ) + + psbt_str = psbt.to_base64() + for hd in hds: + xprv = hd.get_xpriv() + proc = subprocess.run([bin_path, psbt_str, xprv], capture_output=True, check=True) + psbt_str = proc.stdout.decode("utf-8") + + return PSBT.from_base64(psbt_str) + + class SingleSigner: """Assumes a simple 1-primary path 1-recovery path Liana descriptor.""" - def __init__(self): + def __init__(self, is_taproot): self.primary_hd = BIP32.from_seed(os.urandom(32), network="test") self.recovery_hd = BIP32.from_seed(os.urandom(32), network="test") + self.is_taproot = is_taproot def sign_psbt(self, psbt, recovery=False): """Sign a transaction. @@ -75,13 +117,17 @@ def sign_psbt(self, psbt, recovery=False): :returns: PSBT with a signature in each input for the specified key. """ assert isinstance(recovery, bool) - return sign_psbt(psbt, [self.recovery_hd if recovery else self.primary_hd]) + if self.is_taproot: + return sign_psbt_taproot( + psbt, [self.recovery_hd if recovery else self.primary_hd] + ) + return sign_psbt_wsh(psbt, [self.recovery_hd if recovery else self.primary_hd]) class MultiSigner: """A signer that has multiple keys and may have multiple recovery path.""" - def __init__(self, primary_hds_count, recovery_hds_counts): + def __init__(self, primary_hds_count, recovery_hds_counts, is_taproot): self.prim_hds = [ BIP32.from_seed(os.urandom(32), network="test") for _ in range(primary_hds_count) @@ -91,6 +137,7 @@ def __init__(self, primary_hds_count, recovery_hds_counts): self.recov_hds[timelock] = [ BIP32.from_seed(os.urandom(32), network="test") for _ in range(count) ] + self.is_taproot = is_taproot def sign_psbt(self, psbt, key_indices): """Sign a transaction with the keys at the specified indices. @@ -106,4 +153,6 @@ def sign_psbt(self, psbt, key_indices): ] else: hds = [self.prim_hds[i] for i in key_indices] - return sign_psbt(psbt, hds) + if self.is_taproot: + return sign_psbt_taproot(psbt, hds) + return sign_psbt_wsh(psbt, hds) diff --git a/tests/test_framework/utils.py b/tests/test_framework/utils.py index b38a88e1c..510f8f48e 100644 --- a/tests/test_framework/utils.py +++ b/tests/test_framework/utils.py @@ -24,6 +24,7 @@ BITCOIND_PATH = os.getenv("BITCOIND_PATH", DEFAULT_BITCOIND_PATH) OLD_LIANAD_PATH = os.getenv("OLD_LIANAD_PATH", None) IS_BITCOIND_25 = bool(int(os.getenv("IS_BITCOIND_25", True))) +USE_TAPROOT = bool(int(os.getenv("USE_TAPROOT", False))) # TODO: switch to True in a couple releases. COIN = 10**8 @@ -55,6 +56,21 @@ def get_txid(hex_tx): return tx.txid().hex() +def sign_and_broadcast(lianad, bitcoind, psbt, recovery=False): + """Sign a PSBT, finalize it, extract the transaction and broadcast it.""" + signed_psbt = lianad.signer.sign_psbt(psbt, recovery) + # Under Taproot i didn't bother implementing a finalizer in the test suite. + if USE_TAPROOT: + lianad.rpc.updatespend(signed_psbt.to_base64()) + txid = signed_psbt.tx.txid().hex() + lianad.rpc.broadcastspend(txid) + lianad.rpc.delspendtx(txid) + return txid + finalized_psbt = lianad.finalize_psbt(signed_psbt) + tx = finalized_psbt.tx.serialize_with_witness().hex() + return bitcoind.rpc.sendrawtransaction(tx) + + def spend_coins(lianad, bitcoind, coins): """Spend these coins, no matter how. This will create a single transaction spending them all at once at the minimum @@ -68,21 +84,8 @@ def spend_coins(lianad, bitcoind, coins): bitcoind.rpc.getnewaddress(): total_value - 11 - 31 - 300 * len(coins) } res = lianad.rpc.createspend(destinations, [c["outpoint"] for c in coins], 1) - - signed_psbt = lianad.signer.sign_psbt(PSBT.from_base64(res["psbt"])) - finalized_psbt = lianad.finalize_psbt(signed_psbt) - tx = finalized_psbt.tx.serialize_with_witness().hex() - bitcoind.rpc.sendrawtransaction(tx) - - return tx - - -def sign_and_broadcast(lianad, bitcoind, psbt, recovery=False): - """Sign a PSBT, finalize it, extract the transaction and broadcast it.""" - signed_psbt = lianad.signer.sign_psbt(psbt, recovery) - finalized_psbt = lianad.finalize_psbt(signed_psbt) - tx = finalized_psbt.tx.serialize_with_witness().hex() - return bitcoind.rpc.sendrawtransaction(tx) + txid = sign_and_broadcast(lianad, bitcoind, PSBT.from_base64(res["psbt"])) + return bitcoind.rpc.getrawtransaction(txid) def sign_and_broadcast_psbt(lianad, psbt): diff --git a/tests/test_misc.py b/tests/test_misc.py index 7add9ad5f..226eeaea2 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -66,9 +66,7 @@ def receive_and_send(lianad, bitcoind): psbt = PSBT.from_base64(res["psbt"]) txid = psbt.tx.txid().hex() # If we sign only with two keys it won't be able to finalize - with pytest.raises( - RpcError, match="Miniscript Error: could not satisfy at index 0" - ): + with pytest.raises(RpcError, match="ould not satisfy.* at index 0"): signed_psbt = lianad.signer.sign_psbt(psbt, range(2)) lianad.rpc.updatespend(signed_psbt.to_base64()) lianad.rpc.broadcastspend(txid) diff --git a/tests/test_rpc.py b/tests/test_rpc.py index 904d17b8b..57b0716df 100644 --- a/tests/test_rpc.py +++ b/tests/test_rpc.py @@ -17,12 +17,13 @@ spend_coins, sign_and_broadcast, sign_and_broadcast_psbt, + USE_TAPROOT, ) def test_getinfo(lianad): res = lianad.rpc.getinfo() - assert 'timestamp' in res.keys() + assert "timestamp" in res.keys() assert res["version"] == "4.0.0-dev" assert res["network"] == "regtest" wait_for(lambda: lianad.rpc.getinfo()["block_height"] == 101) @@ -405,13 +406,21 @@ def test_create_spend(lianad, bitcoind): assert len(spend_psbt.o) == 4 assert len(spend_psbt.tx.vout) == 4 - # The transaction must contain the spent transaction for each input. + # The transaction must contain the spent transaction for each input for P2WSH. But not for Taproot. # We don't make assumptions about the ordering of PSBT inputs. - assert sorted( - [psbt_in.map[PSBT_IN_NON_WITNESS_UTXO] for psbt_in in spend_psbt.i] - ) == sorted( - [bytes.fromhex(bitcoind.rpc.gettransaction(op[:64])["hex"]) for op in outpoints] - ) + if USE_TAPROOT: + assert all( + PSBT_IN_NON_WITNESS_UTXO not in psbt_in.map for psbt_in in spend_psbt.i + ) + else: + assert sorted( + [psbt_in.map[PSBT_IN_NON_WITNESS_UTXO] for psbt_in in spend_psbt.i] + ) == sorted( + [ + bytes.fromhex(bitcoind.rpc.gettransaction(op[:64])["hex"]) + for op in outpoints + ] + ) # We can sign it and broadcast it. sign_and_broadcast(lianad, bitcoind, PSBT.from_base64(res["psbt"])) @@ -1058,9 +1067,9 @@ def test_rbfpsbt_bump_fee(lianad, bitcoind): rbf_1_res = lianad.rpc.rbfpsbt(first_txid, False, 10) rbf_1_psbt = PSBT.from_base64(rbf_1_res["psbt"]) # The inputs are the same in both (no new inputs needed in the replacement). - assert sorted( - psbt_in.map[PSBT_IN_NON_WITNESS_UTXO] for psbt_in in first_psbt.i - ) == sorted(psbt_in.map[PSBT_IN_NON_WITNESS_UTXO] for psbt_in in rbf_1_psbt.i) + assert sorted(i.prevout.serialize() for i in first_psbt.tx.vin) == sorted( + i.prevout.serialize() for i in rbf_1_psbt.tx.vin + ) # Check non-change output is the same in both. assert first_psbt.tx.vout[0].nValue == rbf_1_psbt.tx.vout[0].nValue assert first_psbt.tx.vout[0].scriptPubKey == rbf_1_psbt.tx.vout[0].scriptPubKey @@ -1080,8 +1089,9 @@ def test_rbfpsbt_bump_fee(lianad, bitcoind): # transaction: with pytest.raises(RpcError, match=f"Feerate too low: 10."): lianad.rpc.rbfpsbt(first_txid, False, 10) - # Using 11 for feerate works. - lianad.rpc.rbfpsbt(first_txid, False, 11) + # Using 11 for feerate works for P2WSH. For Taproot we need 12. + feerate = 12 if USE_TAPROOT else 11 + lianad.rpc.rbfpsbt(first_txid, False, feerate) # Add a new transaction spending the change from the first RBF. desc_1_destinations = { bitcoind.rpc.getnewaddress(): 500_000, @@ -1115,12 +1125,12 @@ def test_rbfpsbt_bump_fee(lianad, bitcoind): ) ) # Now replace the first RBF, which will also remove its descendants. - rbf_2_res = lianad.rpc.rbfpsbt(rbf_1_txid, False, 11) + rbf_2_res = lianad.rpc.rbfpsbt(rbf_1_txid, False, feerate) rbf_2_psbt = PSBT.from_base64(rbf_2_res["psbt"]) # The inputs are the same in both (no new inputs needed in the replacement). - assert sorted( - psbt_in.map[PSBT_IN_NON_WITNESS_UTXO] for psbt_in in rbf_1_psbt.i - ) == sorted(psbt_in.map[PSBT_IN_NON_WITNESS_UTXO] for psbt_in in rbf_2_psbt.i) + assert sorted(i.prevout.serialize() for i in rbf_1_psbt.tx.vin) == sorted( + i.prevout.serialize() for i in rbf_2_psbt.tx.vin + ) # Check non-change output is the same in both. assert rbf_1_psbt.tx.vout[0].nValue == rbf_2_psbt.tx.vout[0].nValue assert rbf_1_psbt.tx.vout[0].scriptPubKey == rbf_2_psbt.tx.vout[0].scriptPubKey @@ -1170,7 +1180,8 @@ def test_rbfpsbt_insufficient_funds(lianad, bitcoind): spend_txid_1 = sign_and_broadcast_psbt(lianad, spend_psbt_1) # We don't have sufficient funds to bump the fee. - assert "missing" in lianad.rpc.rbfpsbt(spend_txid_1, False, 2) + feerate = 3 if USE_TAPROOT else 2 + assert "missing" in lianad.rpc.rbfpsbt(spend_txid_1, False, feerate) # We can still cancel it as the coin has enough value to create a single # output at a higher feerate. assert "psbt" in lianad.rpc.rbfpsbt(spend_txid_1, True) @@ -1232,17 +1243,15 @@ def test_rbfpsbt_cancel(lianad, bitcoind): # But we can't set the feerate explicitly. with pytest.raises( RpcError, - match=re.escape( - "A feerate must not be provided if creating a cancel." - ), + match=re.escape("A feerate must not be provided if creating a cancel."), ): rbf_1_res = lianad.rpc.rbfpsbt(first_txid, True, 2) rbf_1_psbt = PSBT.from_base64(rbf_1_res["psbt"]) # Replacement only has a single input. assert len(rbf_1_psbt.i) == 1 # This input is one of the two from the previous transaction. - assert rbf_1_psbt.i[0].map[PSBT_IN_NON_WITNESS_UTXO] in [ - psbt_in.map[PSBT_IN_NON_WITNESS_UTXO] for psbt_in in rbf_1_psbt.i + assert rbf_1_psbt.tx.vin[0].prevout.serialize() in [ + i.prevout.serialize() for i in first_psbt.tx.vin ] # The replacement only has a change output. assert len(rbf_1_psbt.tx.vout) == 1 @@ -1308,13 +1317,12 @@ def test_rbfpsbt_cancel(lianad, bitcoind): # Now cancel the first RBF, which will also remove its descendants. rbf_2_res = lianad.rpc.rbfpsbt(rbf_1_txid, True) rbf_2_psbt = PSBT.from_base64(rbf_2_res["psbt"]) - # - assert len(rbf_2_psbt.i) == 1 + # The inputs are the same in both (no new inputs needed in the replacement). + assert len(rbf_2_psbt.tx.vin) == 1 assert ( - rbf_1_psbt.i[0].map[PSBT_IN_NON_WITNESS_UTXO] - == rbf_2_psbt.i[0].map[PSBT_IN_NON_WITNESS_UTXO] + rbf_1_psbt.tx.vin[0].prevout.serialize() + == rbf_2_psbt.tx.vin[0].prevout.serialize() ) - # The inputs are the same in both (no new inputs needed in the replacement). # Only a single output (change) in the replacement. assert len(rbf_2_psbt.tx.vout) == 1 diff --git a/tests/test_spend.py b/tests/test_spend.py index 4ae752293..a5ccb0bf7 100644 --- a/tests/test_spend.py +++ b/tests/test_spend.py @@ -1,6 +1,6 @@ from fixtures import * from test_framework.serializations import PSBT, uint256_from_str -from test_framework.utils import wait_for, COIN, RpcError +from test_framework.utils import wait_for, COIN, RpcError, USE_TAPROOT def test_spend_change(lianad, bitcoind): @@ -129,11 +129,12 @@ def sign_and_broadcast(psbt): res = lianad.rpc.createspend(destinations, [outpoint], 1) psbt = PSBT.from_base64(res["psbt"]) sign_and_broadcast(psbt) + change_amount = 840 if USE_TAPROOT else 830 assert len(psbt.o) == 1 assert len(res["warnings"]) == 1 assert ( res["warnings"][0] - == "Change amount of 830 sats added to fee as it was too small to create a transaction output." + == f"Change amount of {change_amount} sats added to fee as it was too small to create a transaction output." ) # Spend the third coin to an address of ours, no change @@ -145,11 +146,12 @@ def sign_and_broadcast(psbt): res = lianad.rpc.createspend(destinations, [outpoint_3], 1) psbt = PSBT.from_base64(res["psbt"]) sign_and_broadcast(psbt) + change_amount = 828 if USE_TAPROOT else 818 assert len(psbt.o) == 1 assert len(res["warnings"]) == 1 assert ( res["warnings"][0] - == "Change amount of 818 sats added to fee as it was too small to create a transaction output." + == f"Change amount of {change_amount} sats added to fee as it was too small to create a transaction output." ) # Spend the fourth coin to an address of ours, with change @@ -222,7 +224,8 @@ def test_send_to_self(lianad, bitcoind): assert len(spend_psbt.o) == len(spend_psbt.tx.vout) == 1 # Note they may ask for an impossible send-to-self. In this case we'll report missing amount. - assert "missing" in lianad.rpc.createspend({}, outpoints, 40500) + huge_feerate = 50_000 if USE_TAPROOT else 40_500 + assert "missing" in lianad.rpc.createspend({}, outpoints, huge_feerate) # Sign and broadcast the send-to-self transaction created above. signed_psbt = lianad.signer.sign_psbt(spend_psbt) @@ -236,7 +239,12 @@ def test_send_to_self(lianad, bitcoind): # FIXME: a 15% increase is huge. res = bitcoind.rpc.getmempoolentry(spend_txid) spend_feerate = int(res["fees"]["base"] * COIN / res["vsize"]) - assert specified_feerate <= spend_feerate <= int(specified_feerate * 115 / 100) + if not USE_TAPROOT: + assert specified_feerate <= spend_feerate <= int(specified_feerate * 115 / 100) + else: + # FIXME: under Taproot we should not consider the max feerate of all leaves if there + # is a spendable internal key. + assert specified_feerate <= spend_feerate <= int(specified_feerate * 125 / 100) # We should by now only have one coin. bitcoind.generate_block(1, wait_for_mempool=spend_txid) diff --git a/tests/tools/taproot_signer/Cargo.lock b/tests/tools/taproot_signer/Cargo.lock new file mode 100644 index 000000000..cab0bff60 --- /dev/null +++ b/tests/tools/taproot_signer/Cargo.lock @@ -0,0 +1,90 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bech32" +version = "0.10.0-beta" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98f7eed2b2781a6f0b5c903471d48e15f56fb4e1165df8a9a2337fd1a59d45ea" + +[[package]] +name = "bitcoin" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd00f3c09b5f21fb357abe32d29946eb8bb7a0862bae62c0b5e4a692acbbe73c" +dependencies = [ + "base64", + "bech32", + "bitcoin-internals", + "bitcoin_hashes", + "hex-conservative", + "hex_lit", + "secp256k1", +] + +[[package]] +name = "bitcoin-internals" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb" + +[[package]] +name = "bitcoin_hashes" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b" +dependencies = [ + "bitcoin-internals", + "hex-conservative", +] + +[[package]] +name = "cc" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f9fa1897e4325be0d68d48df6aa1a71ac2ed4d27723887e7754192705350730" + +[[package]] +name = "hex-conservative" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30ed443af458ccb6d81c1e7e661545f94d3176752fb1df2f543b902a1e0f51e2" + +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + +[[package]] +name = "secp256k1" +version = "0.28.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24b59d129cdadea20aea4fb2352fa053712e5d713eee47d700cd4b2bc002f10" +dependencies = [ + "bitcoin_hashes", + "secp256k1-sys", +] + +[[package]] +name = "secp256k1-sys" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d1746aae42c19d583c3c1a8c646bfad910498e2051c551a7f2e3c0c9fbb7eb" +dependencies = [ + "cc", +] + +[[package]] +name = "taproot_signer" +version = "0.1.0" +dependencies = [ + "bitcoin", +] diff --git a/tests/tools/taproot_signer/Cargo.toml b/tests/tools/taproot_signer/Cargo.toml new file mode 100644 index 000000000..c7554761b --- /dev/null +++ b/tests/tools/taproot_signer/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "taproot_signer" +version = "0.1.0" +edition = "2021" + +[dependencies] +bitcoin = { version = "0.31", features = ["base64"] } + +# Avoid it being built under Liana's target dir. +[workspace] diff --git a/tests/tools/taproot_signer/README.md b/tests/tools/taproot_signer/README.md new file mode 100644 index 000000000..00638e280 --- /dev/null +++ b/tests/tools/taproot_signer/README.md @@ -0,0 +1,13 @@ +A quick and dirty program to sign Taproot PSBTs with a given xpriv. This reuses the hot signer Rust +code so i don't have to reimplement everything in Python. + +Usage: +``` +cargo build --release +./target/release/taproot_signer "cHNidP8BAFICAAAAASLJVdEybEXc7wUWShMaYhEOjwJL5MYbzg+7zeR44BNGEwAAAAD9////AfRHAAAAAAAAFgAUZb+ZwCt1P282vnthp4cBgN4YNCgAAAAAAAEBKzhKAAAAAAAAIlEggmdBglq+Jt7mG8eFAZJPZ9TNEoqsfUXX+e0tRR4on0pCFcHpCSnxiCBSU6Ddvmwxv9nF+529tCGtEuCM1/zRYlksP2wAEs3HghglLpv2X3mSwNNzlaHY8BKNNas/13WibyuQaSCNnIknSSMfLln9wxqrj+HdoimEWq8EF48QCVphtE8of6wgQC+hw/bp6Z3LosvoFKmVGbi5BhTFxhJ88YlUkHwkyx26IFRPnR3hiTo2F1MW+PzS6hKLjdiQA+aD3DlMZkolBdKAulKcwEIVwekJKfGIIFJToN2+bDG/2cX7nb20Ia0S4IzX/NFiWSw/2HFGAyQNqMHzx0CejOgXB8dPeu9cm47MEKqCRr08ntsmIEkh6Q/Tvcb69Lv4z1KGhY5rH2SVgNb+0L6jjg9jTCA7rQEussAhFkAvocP26emdy6LL6BSplRm4uQYUxcYSfPGJVJB8JMsdOQHYcUYDJA2owfPHQJ6M6BcHx09671ybjswQqoJGvTye29gS2ksSAACAGAAAgB8AAAAAAAAAKgAAACEWSSHpD9O9xvr0u/jPUoaFjmsfZJWA1v7QvqOOD2NMIDtFAWwAEs3HghglLpv2X3mSwNNzlaHY8BKNNas/13WibyuQsLhRtwEAAAACAACAAwAAAAQAAIAFAAAABgAAAAAAAAAqAAAAIRZUT50d4Yk6NhdTFvj80uoSi43YkAPmg9w5TGZKJQXSgDUB2HFGAyQNqMHzx0CejOgXB8dPeu9cm47MEKqCRr08ntuwuFG3EgAAgBkAAIAAAAAAKgAAACEWjZyJJ0kjHy5Z/cMaq4/h3aIphFqvBBePEAlaYbRPKH9BAdhxRgMkDajB88dAnozoFwfHT3rvXJuOzBCqgka9PJ7bJxAoeAAAAIAMAACAKgAAAKQBAAA4AAAAAAAAACoAAAAhFukJKfGIIFJToN2+bDG/2cX7nb20Ia0S4IzX/NFiWSw/DQB8Rh5dAAAAACoAAAABFyDpCSnxiCBSU6Ddvmwxv9nF+529tCGtEuCM1/zRYlksPwEYIKKzzcTwLrwB4DXcLXJYeZVMlpkb1zTIpFzsTWdZJU7nAAA=" xprv9s21ZrQH143K3fRVbj9JuCc3Eaph4F7AKXUtLGs9AWxWmhyMGq5ur5NqH3fDuiQZ6GamXbW7L3msaCwRbstxVqc3eP2FX3oa9wABxVzHr1k +``` + +Output: +``` +cHNidP8BAFICAAAAASLJVdEybEXc7wUWShMaYhEOjwJL5MYbzg+7zeR44BNGEwAAAAD9////AfRHAAAAAAAAFgAUZb+ZwCt1P282vnthp4cBgN4YNCgAAAAAAAEBKzhKAAAAAAAAIlEggmdBglq+Jt7mG8eFAZJPZ9TNEoqsfUXX+e0tRR4on0pBFI2ciSdJIx8uWf3DGquP4d2iKYRarwQXjxAJWmG0Tyh/2HFGAyQNqMHzx0CejOgXB8dPeu9cm47MEKqCRr08nttAJ148PhMM/k/GnKCQ09RkpEKYCAHwVh3UMueWbq0TcIMW8s3XKNk1vyFy3LrXdnGghS8Up/i4bdNR2ikX1XdlYEIVwekJKfGIIFJToN2+bDG/2cX7nb20Ia0S4IzX/NFiWSw/bAASzceCGCUum/ZfeZLA03OVodjwEo01qz/XdaJvK5BpII2ciSdJIx8uWf3DGquP4d2iKYRarwQXjxAJWmG0Tyh/rCBAL6HD9unpncuiy+gUqZUZuLkGFMXGEnzxiVSQfCTLHbogVE+dHeGJOjYXUxb4/NLqEouN2JAD5oPcOUxmSiUF0oC6UpzAQhXB6Qkp8YggUlOg3b5sMb/ZxfudvbQhrRLgjNf80WJZLD/YcUYDJA2owfPHQJ6M6BcHx09671ybjswQqoJGvTye2yYgSSHpD9O9xvr0u/jPUoaFjmsfZJWA1v7QvqOOD2NMIDutAS6ywCEWQC+hw/bp6Z3LosvoFKmVGbi5BhTFxhJ88YlUkHwkyx05AdhxRgMkDajB88dAnozoFwfHT3rvXJuOzBCqgka9PJ7b2BLaSxIAAIAYAACAHwAAAAAAAAAqAAAAIRZJIekP073G+vS7+M9ShoWOax9klYDW/tC+o44PY0wgO0UBbAASzceCGCUum/ZfeZLA03OVodjwEo01qz/XdaJvK5CwuFG3AQAAAAIAAIADAAAABAAAgAUAAAAGAAAAAAAAACoAAAAhFlRPnR3hiTo2F1MW+PzS6hKLjdiQA+aD3DlMZkolBdKANQHYcUYDJA2owfPHQJ6M6BcHx09671ybjswQqoJGvTye27C4UbcSAACAGQAAgAAAAAAqAAAAIRaNnIknSSMfLln9wxqrj+HdoimEWq8EF48QCVphtE8of0EB2HFGAyQNqMHzx0CejOgXB8dPeu9cm47MEKqCRr08ntsnECh4AAAAgAwAAIAqAAAApAEAADgAAAAAAAAAKgAAACEW6Qkp8YggUlOg3b5sMb/ZxfudvbQhrRLgjNf80WJZLD8NAHxGHl0AAAAAKgAAAAEXIOkJKfGIIFJToN2+bDG/2cX7nb20Ia0S4IzX/NFiWSw/ARggorPNxPAuvAHgNdwtclh5lUyWmRvXNMikXOxNZ1klTucAAA== +``` diff --git a/tests/tools/taproot_signer/src/main.rs b/tests/tools/taproot_signer/src/main.rs new file mode 100644 index 000000000..9798424a7 --- /dev/null +++ b/tests/tools/taproot_signer/src/main.rs @@ -0,0 +1,119 @@ +//! A quick and dirty program which reads a PSBT and an xpriv from stding and outputs the signed +//! PSBT to stdout. Uses function copied from Liana's hot signer and adapted. + +use std::{ + env, + io::{self, Write}, + str::FromStr, +}; + +use bitcoin::{ + self, + bip32::{self, Xpriv}, + hashes::Hash, + key::TapTweak, + psbt::{Input as PsbtIn, Psbt}, + secp256k1, sighash, +}; + +fn sign_taproot( + secp: &secp256k1::Secp256k1, + sighash_cache: &mut sighash::SighashCache<&bitcoin::Transaction>, + master_xpriv: Xpriv, + master_fingerprint: bip32::Fingerprint, + prevouts: &[bitcoin::TxOut], + psbt_in: &mut PsbtIn, + input_index: usize, +) { + let sig_type = sighash::TapSighashType::Default; + let prevouts = sighash::Prevouts::All(prevouts); + + // If the details of the internal key are filled, provide a keypath signature. + if let Some(ref int_key) = psbt_in.tap_internal_key { + // NB: we don't check for empty leaf hashes on purpose, in case the internal key also + // appears in a leaf. + if let Some((_, (fg, der_path))) = psbt_in.tap_key_origins.get(int_key) { + if *fg == master_fingerprint { + let privkey = master_xpriv.derive_priv(secp, der_path).unwrap().to_priv(); + let keypair = secp256k1::Keypair::from_secret_key(secp, &privkey.inner); + assert_eq!(keypair.x_only_public_key().0, *int_key); + let keypair = keypair.tap_tweak(secp, psbt_in.tap_merkle_root).to_inner(); + let sighash = sighash_cache + .taproot_key_spend_signature_hash(input_index, &prevouts, sig_type) + .unwrap(); + let sighash = secp256k1::Message::from_digest_slice(sighash.as_byte_array()) + .expect("Sighash is always 32 bytes."); + let sig = secp.sign_schnorr_no_aux_rand(&sighash, &keypair); + let sig = bitcoin::taproot::Signature { + sig, + hash_ty: sig_type, + }; + psbt_in.tap_key_sig = Some(sig); + } + } + } + + // Now sign for all the public keys derived from our master secret, in all the leaves where + // they are present. + for (pubkey, (leaf_hashes, (fg, der_path))) in &psbt_in.tap_key_origins { + if *fg != master_fingerprint { + continue; + } + + for leaf_hash in leaf_hashes { + let privkey = master_xpriv.derive_priv(secp, der_path).unwrap().to_priv(); + let keypair = secp256k1::Keypair::from_secret_key(secp, &privkey.inner); + let sighash = sighash_cache + .taproot_script_spend_signature_hash(input_index, &prevouts, *leaf_hash, sig_type) + .unwrap(); + let sighash = secp256k1::Message::from_digest_slice(sighash.as_byte_array()) + .expect("Sighash is always 32 bytes."); + let sig = secp.sign_schnorr_no_aux_rand(&sighash, &keypair); + let sig = bitcoin::taproot::Signature { + sig, + hash_ty: sig_type, + }; + psbt_in.tap_script_sigs.insert((*pubkey, *leaf_hash), sig); + } + } +} + +fn sign_psbt(psbt: &mut Psbt, master_xpriv: Xpriv, secp: &secp256k1::Secp256k1) { + let mut sighash_cache = sighash::SighashCache::new(&psbt.unsigned_tx); + let master_fingerprint = master_xpriv.fingerprint(secp); + + let prevouts: Vec<_> = psbt + .inputs + .iter() + .filter_map(|psbt_in| psbt_in.witness_utxo.clone()) + .collect(); + assert_eq!(prevouts.len(), psbt.inputs.len()); + + // Sign each input in the PSBT. + for i in 0..psbt.inputs.len() { + sign_taproot( + secp, + &mut sighash_cache, + master_xpriv, + master_fingerprint, + &prevouts, + &mut psbt.inputs[i], + i, + ); + } +} + +fn main() { + let args: Vec = env::args().collect(); + assert_eq!(args.len(), 3); + + let mut psbt = Psbt::from_str(&args[1]).unwrap(); + let xprv = Xpriv::from_str(&args[2]).unwrap(); + + let secp = secp256k1::Secp256k1::new(); + sign_psbt(&mut psbt, xprv, &secp); + + let psbt_str = psbt.to_string(); + print!("{}", psbt_str); + io::stdout().flush().unwrap(); +}