From 6d05c1039c76acfc23ba653e7b26aa3971945deb Mon Sep 17 00:00:00 2001 From: zebra-lucky Date: Wed, 18 Dec 2024 21:39:53 +0200 Subject: [PATCH] add TaprootWallet --- conftest.py | 4 +- pyproject.toml | 5 +- scripts/joinmarket-qt.py | 6 +- src/jmbitcoin/scripteval.py | 243 +++++++++++++++++++++++++ src/jmbitcoin/secp256k1_transaction.py | 90 ++++++--- src/jmclient/__init__.py | 4 +- src/jmclient/blockchaininterface.py | 69 ++++++- src/jmclient/configure.py | 6 + src/jmclient/cryptoengine.py | 31 +++- src/jmclient/descriptor.py | 49 +++++ src/jmclient/maker.py | 3 + src/jmclient/support.py | 10 +- src/jmclient/taker.py | 36 +++- src/jmclient/wallet.py | 95 +++++++++- src/jmclient/wallet_service.py | 103 +++++++++-- src/jmclient/wallet_utils.py | 45 +++-- src/jmclient/yieldgenerator.py | 10 +- src/jmdaemon/orderbookwatch.py | 5 +- src/jmdaemon/protocol.py | 4 + test/jmclient/test_wallet.py | 22 ++- 20 files changed, 761 insertions(+), 79 deletions(-) create mode 100644 src/jmbitcoin/scripteval.py create mode 100644 src/jmclient/descriptor.py diff --git a/conftest.py b/conftest.py index 1cc56d5..5df761f 100644 --- a/conftest.py +++ b/conftest.py @@ -75,7 +75,7 @@ def pytest_addoption(parser: Any) -> None: default='bitcoinrpc', help="the RPC username for your test bitcoin instance (default=bitcoinrpc)") parser.addoption("--nirc", - type="int", + type=int, action="store", default=1, help="the number of local miniircd instances") @@ -136,7 +136,7 @@ def setup_regtest_bitcoind(pytestconfig): bitcoin_path = pytestconfig.getoption("--btcroot") bitcoind_path = os.path.join(bitcoin_path, "bitcoind") bitcoincli_path = os.path.join(bitcoin_path, "bitcoin-cli") - start_cmd = f'{bitcoind_path} -regtest -daemon -conf={conf}' + start_cmd = f'{bitcoind_path} -regtest -daemon -txindex -conf={conf}' stop_cmd = f'{bitcoincli_path} -regtest -rpcuser={rpcuser} -rpcpassword={rpcpassword} stop' # determine bitcoind version diff --git a/pyproject.toml b/pyproject.toml index 2d31de3..d198913 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,8 @@ jmdaemon = [ "libnacl==1.8.0", "pyopenssl==24.0.0", ] +jmfrost = [ +] jmqtui = [ "PyQt5!=5.15.0,!=5.15.1,!=5.15.2,!=6.0", "PySide2!=5.15.0,!=5.15.1,!=5.15.2,!=6.0", # https://bugreports.qt.io/browse/QTBUG-88688 @@ -44,6 +46,7 @@ jmqtui = [ client = [ "joinmarket[jmclient]", "joinmarket[jmbitcoin]", + "joinmarket[jmfrost]", ] daemon = [ "joinmarket[jmdaemon]", @@ -76,4 +79,4 @@ where = ["src"] exclude = ["*.test"] [tool.pytest.ini_options] -testpaths = ["test"] \ No newline at end of file +testpaths = ["test"] diff --git a/scripts/joinmarket-qt.py b/scripts/joinmarket-qt.py index 39a0e3a..c5435f8 100755 --- a/scripts/joinmarket-qt.py +++ b/scripts/joinmarket-qt.py @@ -992,10 +992,12 @@ class SpendTab(QWidget): mbinfo.append("Counterparties chosen:") mbinfo.append('Name, Order id, Coinjoin fee (sat.)') for k, o in offers.items(): - if o['ordertype'] in ['sw0reloffer', 'swreloffer', 'reloffer']: + if o['ordertype'] in ['trreloffer', 'sw0reloffer', + 'swreloffer', 'reloffer']: display_fee = int(self.taker.cjamount * float(o['cjfee'])) - int(o['txfee']) - elif o['ordertype'] in ['sw0absoffer', 'swabsoffer', 'absoffer']: + elif o['ordertype'] in ['trabsoffer', 'sw0absoffer', + 'swabsoffer', 'absoffer']: display_fee = int(o['cjfee']) - int(o['txfee']) else: log.debug("Unsupported order type: " + str(o['ordertype']) + diff --git a/src/jmbitcoin/scripteval.py b/src/jmbitcoin/scripteval.py new file mode 100644 index 0000000..757ec79 --- /dev/null +++ b/src/jmbitcoin/scripteval.py @@ -0,0 +1,243 @@ +# -*- coding: utf-8 -*- + +import hashlib +from typing import List, Optional, Tuple, Union, Set, Type + +import bitcointx +from bitcointx.core.key import XOnlyPubKey +from bitcointx.core.script import SIGVERSION_TAPROOT, SignatureHashSchnorr +from bitcointx.core import CTxOut +from bitcointx.core.scripteval import ( + CScript, ScriptVerifyFlag_Type, CScriptWitness, VerifyScriptError, + STANDARD_SCRIPT_VERIFY_FLAGS, UNHANDLED_SCRIPT_VERIFY_FLAGS, EvalScript, + SCRIPT_VERIFY_CLEANSTACK, script_verify_flags_to_string, _CastToBool, + ensure_isinstance, SCRIPT_VERIFY_DISCOURAGE_UPGRADABLE_WITNESS_PROGRAM, + MAX_SCRIPT_ELEMENT_SIZE, OP_CHECKSIG, OP_EQUALVERIFY, OP_HASH160, OP_DUP) +from bitcointx.core.scripteval import ( + SCRIPT_VERIFY_WITNESS, SCRIPT_VERIFY_P2SH, SIGVERSION_WITNESS_V0) + + +def VerifyScriptWithTaproot( + scriptSig: CScript, scriptPubKey: CScript, + txTo: 'bitcointx.core.CTransaction', inIdx: int, + flags: Optional[Union[Tuple[ScriptVerifyFlag_Type, ...], + Set[ScriptVerifyFlag_Type]]] = None, + amount: int = 0, witness: Optional[CScriptWitness] = None, + *, + spent_outputs: Optional[List[CTxOut]] = None +) -> None: + """Verify a scriptSig satisfies a scriptPubKey + + scriptSig - Signature + + scriptPubKey - PubKey + + txTo - Spending transaction + + inIdx - Index of the transaction input containing scriptSig + + Raises a ValidationError subclass if the validation fails. + """ + + ensure_isinstance(scriptSig, CScript, 'scriptSig') + if not type(scriptSig) == type(scriptPubKey): # noqa: exact class check + raise TypeError( + "scriptSig and scriptPubKey must be of the same script class") + + script_class = scriptSig.__class__ + + if flags is None: + flags = STANDARD_SCRIPT_VERIFY_FLAGS - UNHANDLED_SCRIPT_VERIFY_FLAGS + else: + flags = set(flags) # might be passed as tuple + + if flags & UNHANDLED_SCRIPT_VERIFY_FLAGS: + raise VerifyScriptError( + "some of the flags cannot be handled by current code: {}" + .format(script_verify_flags_to_string(flags & UNHANDLED_SCRIPT_VERIFY_FLAGS))) + + stack: List[bytes] = [] + EvalScript(stack, scriptSig, txTo, inIdx, flags=flags) + if SCRIPT_VERIFY_P2SH in flags: + stackCopy = list(stack) + EvalScript(stack, scriptPubKey, txTo, inIdx, flags=flags) + if len(stack) == 0: + raise VerifyScriptError("scriptPubKey left an empty stack") + if not _CastToBool(stack[-1]): + raise VerifyScriptError("scriptPubKey returned false") + + hadWitness = False + if witness is None: + witness = CScriptWitness([]) + + if SCRIPT_VERIFY_WITNESS in flags and scriptPubKey.is_witness_scriptpubkey(): + hadWitness = True + + if scriptSig: + raise VerifyScriptError("scriptSig is not empty") + + VerifyWitnessProgramWithTaproot( + witness, + scriptPubKey.witness_version(), + scriptPubKey.witness_program(), + txTo, inIdx, flags=flags, amount=amount, + script_class=script_class, + spent_outputs=spent_outputs) + + # Bypass the cleanstack check at the end. The actual stack is obviously not clean + # for witness programs. + stack = stack[:1] + + # Additional validation for spend-to-script-hash transactions + if SCRIPT_VERIFY_P2SH in flags and scriptPubKey.is_p2sh(): + if not scriptSig.is_push_only(): + raise VerifyScriptError("P2SH scriptSig not is_push_only()") + + # restore stack + stack = stackCopy + + # stack cannot be empty here, because if it was the + # P2SH HASH <> EQUAL scriptPubKey would be evaluated with + # an empty stack and the EvalScript above would return false. + assert len(stack) + + pubKey2 = script_class(stack.pop()) + + EvalScript(stack, pubKey2, txTo, inIdx, flags=flags) + + if not len(stack): + raise VerifyScriptError("P2SH inner scriptPubKey left an empty stack") + + if not _CastToBool(stack[-1]): + raise VerifyScriptError("P2SH inner scriptPubKey returned false") + + # P2SH witness program + if SCRIPT_VERIFY_WITNESS in flags and pubKey2.is_witness_scriptpubkey(): + hadWitness = True + + if scriptSig != script_class([pubKey2]): + raise VerifyScriptError("scriptSig is not exactly a single push of the redeemScript") + + VerifyWitnessProgramWithTaproot( + witness, + pubKey2.witness_version(), + pubKey2.witness_program(), + txTo, inIdx, flags=flags, amount=amount, + script_class=script_class, + spent_outputs=spent_outputs) + + # Bypass the cleanstack check at the end. The actual stack is obviously not clean + # for witness programs. + stack = stack[:1] + + if SCRIPT_VERIFY_CLEANSTACK in flags: + if SCRIPT_VERIFY_P2SH not in flags: + raise ValueError( + 'SCRIPT_VERIFY_CLEANSTACK requires SCRIPT_VERIFY_P2SH') + + if len(stack) == 0: + raise VerifyScriptError("scriptPubKey left an empty stack") + elif len(stack) != 1: + raise VerifyScriptError("scriptPubKey left extra items on stack") + + if SCRIPT_VERIFY_WITNESS in flags: + # We can't check for correct unexpected witness data if P2SH was off, so require + # that WITNESS implies P2SH. Otherwise, going from WITNESS->P2SH+WITNESS would be + # possible, which is not a softfork. + if SCRIPT_VERIFY_P2SH not in flags: + raise ValueError( + "SCRIPT_VERIFY_WITNESS requires SCRIPT_VERIFY_P2SH") + + if not hadWitness and witness: + raise VerifyScriptError("Unexpected witness") + + +def VerifyWitnessProgramWithTaproot( + witness: CScriptWitness, + witversion: int, program: bytes, + txTo: 'bitcointx.core.CTransaction', + inIdx: int, + flags: Set[ScriptVerifyFlag_Type] = set(), + amount: int = 0, + script_class: Type[CScript] = CScript, + *, + spent_outputs: Optional[List[CTxOut]] = None +) -> None: + + if script_class is None: + raise ValueError("script class must be specified") + + sigversion = None + + if witversion == 0: + sigversion = SIGVERSION_WITNESS_V0 + stack = list(witness.stack) + if len(program) == 32: + # Version 0 segregated witness program: SHA256(CScript) inside the program, + # CScript + inputs in witness + if len(stack) == 0: + raise VerifyScriptError("witness is empty") + + scriptPubKey = script_class(stack.pop()) + hashScriptPubKey = hashlib.sha256(scriptPubKey).digest() + if hashScriptPubKey != program: + raise VerifyScriptError("witness program mismatch") + elif len(program) == 20: + # Special case for pay-to-pubkeyhash; signature + pubkey in witness + if len(stack) != 2: + raise VerifyScriptError("witness program mismatch") # 2 items in witness + + scriptPubKey = script_class([OP_DUP, OP_HASH160, program, + OP_EQUALVERIFY, OP_CHECKSIG]) + else: + raise VerifyScriptError("wrong length for witness program") + elif witversion == 1: + sigversion = SIGVERSION_TAPROOT + stack = list(witness.stack) + if len(program) == 32: + if len(stack) == 0: + raise VerifyScriptError("witness is empty") + if len(stack) != 1: + raise VerifyScriptError("only key path spend is supported") + assert spent_outputs + sig = stack[0] + pubkey = XOnlyPubKey(program) + sighash = SignatureHashSchnorr(txTo, inIdx, spent_outputs) + if pubkey.verify_schnorr(sighash, sig): + return + else: + raise VerifyScriptError("schnorr signature verify failed") + else: + raise VerifyScriptError("wrong length for witness program") + elif SCRIPT_VERIFY_DISCOURAGE_UPGRADABLE_WITNESS_PROGRAM in flags: + raise VerifyScriptError("upgradeable witness program is not accepted") + else: + # Higher version witness scripts return true for future softfork compatibility + return + + assert sigversion is not None + + for i, elt in enumerate(stack): + if isinstance(elt, int): + elt_len = len(script_class([elt])) + else: + elt_len = len(elt) + + # Disallow stack item size > MAX_SCRIPT_ELEMENT_SIZE in witness stack + if elt_len > MAX_SCRIPT_ELEMENT_SIZE: + raise VerifyScriptError( + "maximum push size exceeded by an item at position {} " + "on witness stack".format(i)) + + EvalScript(stack, scriptPubKey, txTo, inIdx, flags=flags, amount=amount, sigversion=sigversion) + + # Scripts inside witness implicitly require cleanstack behaviour + if len(stack) == 0: + raise VerifyScriptError("scriptPubKey left an empty stack") + elif len(stack) != 1: + raise VerifyScriptError("scriptPubKey left extra items on stack") + + if not _CastToBool(stack[-1]): + raise VerifyScriptError("scriptPubKey returned false") + + return diff --git a/src/jmbitcoin/secp256k1_transaction.py b/src/jmbitcoin/secp256k1_transaction.py index ff91e74..43ce4b7 100644 --- a/src/jmbitcoin/secp256k1_transaction.py +++ b/src/jmbitcoin/secp256k1_transaction.py @@ -12,15 +12,17 @@ from bitcointx.core import (CMutableTransaction, CTxInWitness, CMutableTxOut, CTxIn, CTxOut, ValidationError, CBitcoinTransaction) from bitcointx.core.script import * -from bitcointx.wallet import (P2WPKHCoinAddress, CCoinAddress, P2PKHCoinAddress, - CCoinAddressError) -from bitcointx.core.scripteval import (VerifyScript, SCRIPT_VERIFY_WITNESS, +from bitcointx.core.script import SignatureHashSchnorr +from bitcointx.wallet import (P2WPKHCoinAddress, P2TRCoinAddress, CCoinAddress, + P2PKHCoinAddress, CCoinAddressError, CCoinKey) +from bitcointx.core.scripteval import (SCRIPT_VERIFY_WITNESS, SCRIPT_VERIFY_P2SH, SCRIPT_VERIFY_STRICTENC, SIGVERSION_WITNESS_V0) from jmbase import bintohex, utxo_to_utxostr from jmbitcoin.secp256k1_main import * +from .scripteval import VerifyScriptWithTaproot as VerifyScript def human_readable_transaction(tx: CTransaction, jsonified: bool = True) -> str: @@ -137,7 +139,8 @@ def estimate_tx_size(ins: List[str], outs: List[str]) -> Union[int, Tuple[int]]: inmults = {"p2wsh": {"w": 1 + 72 + 43, "nw": 41}, "p2wpkh": {"w": 108, "nw": 41}, "p2sh-p2wpkh": {"w": 108, "nw": 64}, - "p2pkh": {"w": 0, "nw": 148}} + "p2pkh": {"w": 0, "nw": 148}, + "p2tr": {"w": 67, "nw": 41}} # Notes: in outputs, there is only 1 'scripthash' # type for either segwit/nonsegwit (hence "p2sh-p2wpkh" @@ -215,6 +218,13 @@ def pubkey_to_p2sh_p2wpkh_script(pub: bytes) -> CScript: raise Exception("Invalid pubkey") return pubkey_to_p2wpkh_script(pub).to_p2sh_scriptPubKey() +def pubkey_to_p2tr_script(pub: bytes) -> CScript: + """ + Given a pubkey in bytes (compressed), return a CScript + representing the corresponding pay-to-taproot scriptPubKey. + """ + return P2TRCoinAddress.from_pubkey(pub).to_scriptPubKey() + def redeem_script_to_p2wsh_script(redeem_script: Union[bytes, CScript]) -> CScript: """ Given redeem script of type CScript (or bytes) returns the corresponding segwit v0 scriptPubKey as @@ -246,12 +256,16 @@ def mk_burn_script(data: bytes) -> CScript: raise TypeError("data must be in bytes") return CScript([OP_RETURN, data]) -def sign(tx: CMutableTransaction, - i: int, - priv: bytes, - hashcode: SIGHASH_Type = SIGHASH_ALL, - amount: Optional[int] = None, - native: bool = False) -> Tuple[Optional[bytes], str]: +def sign( + tx: CMutableTransaction, + i: int, + priv: bytes, + hashcode: SIGHASH_Type = SIGHASH_ALL, + amount: Optional[int] = None, + native: bool = False, + *, + spent_outputs: Optional[List[CTxOut]] = None +) -> Tuple[Optional[bytes], str]: """ Given a transaction tx of type CMutableTransaction, an input index i, and a raw privkey in bytes, updates the CMutableTransaction to contain @@ -285,20 +299,44 @@ def sign(tx: CMutableTransaction, tx.vin[i].scriptSig = CScript([sig, pub]) # Verify the signature worked. try: - VerifyScript(tx.vin[i].scriptSig, - input_scriptPubKey, tx, i, flags=flags) + VerifyScript( + tx.vin[i].scriptSig, input_scriptPubKey, tx, i, flags=flags, + spent_outputs=spent_outputs) except Exception as e: return return_err(e) return sig, "signing succeeded" else: - # segwit case; we currently support p2wpkh native or under p2sh. + # segwit case; we currently support p2tr, p2wpkh native or under p2sh. # https://github.com/Simplexum/python-bitcointx/blob/648ad8f45ff853bf9923c6498bfa0648b3d7bcbd/bitcointx/core/scripteval.py#L1250-L1252 flags.add(SCRIPT_VERIFY_P2SH) flags.add(SCRIPT_VERIFY_WITNESS) - if native and native != "p2wpkh": + if native and native == "p2tr": + assert spent_outputs + sighash = SignatureHashSchnorr(tx, i, spent_outputs) + + try: + coin_key = CCoinKey.from_secret_bytes(priv[:32]) + sig = coin_key.sign_schnorr_tweaked(sighash) + except Exception as e: + return return_err(e) + witness = [sig] + ctxwitness = CTxInWitness(CScriptWitness(witness)) + tx.wit.vtxinwit[i] = ctxwitness + try: + input_scriptPubKey = pubkey_to_p2tr_script(pub) + VerifyScript( + tx.vin[i].scriptSig, input_scriptPubKey, tx, i, + flags=flags, amount=amount, + witness=tx.wit.vtxinwit[i].scriptWitness, + spent_outputs=spent_outputs) + except ValidationError as e: + return return_err(e) + + return sig, "signing succeeded" + elif native and native != "p2wpkh": scriptCode = native input_scriptPubKey = redeem_script_to_p2wsh_script(native) else: @@ -328,8 +366,10 @@ def sign(tx: CMutableTransaction, tx.wit.vtxinwit[i] = ctxwitness # Verify the signature worked. try: - VerifyScript(tx.vin[i].scriptSig, input_scriptPubKey, tx, i, - flags=flags, amount=amount, witness=tx.wit.vtxinwit[i].scriptWitness) + VerifyScript( + tx.vin[i].scriptSig, input_scriptPubKey, tx, i, flags=flags, + amount=amount, witness=tx.wit.vtxinwit[i].scriptWitness, + spent_outputs=spent_outputs) except ValidationError as e: return return_err(e) @@ -387,19 +427,23 @@ def make_shuffled_tx(ins: List[Tuple[bytes, int]], return mktx(ins, outs, version=version, locktime=locktime) def verify_tx_input(tx: CTransaction, - i: int, - scriptSig: CScript, - scriptPubKey: CScript, - amount: Optional[int] = None, - witness: Optional[CScriptWitness] = None) -> bool: + i: int, + scriptSig: CScript, + scriptPubKey: CScript, + amount: Optional[int] = None, + witness: Optional[CScriptWitness] = None, + *, + spent_outputs: Optional[List[CTxOut]] = None +) -> bool: flags = set([SCRIPT_VERIFY_STRICTENC]) if witness: # https://github.com/Simplexum/python-bitcointx/blob/648ad8f45ff853bf9923c6498bfa0648b3d7bcbd/bitcointx/core/scripteval.py#L1250-L1252 flags.add(SCRIPT_VERIFY_P2SH) flags.add(SCRIPT_VERIFY_WITNESS) try: - VerifyScript(scriptSig, scriptPubKey, tx, i, - flags=flags, amount=amount, witness=witness) + VerifyScript( + scriptSig, scriptPubKey, tx, i, flags=flags, amount=amount, + witness=witness, spent_outputs=spent_outputs) except ValidationError as e: return False return True diff --git a/src/jmclient/__init__.py b/src/jmclient/__init__.py index ab4c3ec..f9c9080 100644 --- a/src/jmclient/__init__.py +++ b/src/jmclient/__init__.py @@ -17,7 +17,7 @@ from .wallet import (Mnemonic, estimate_tx_fee, WalletError, BaseWallet, ImportW SegwitWallet, SegwitLegacyWallet, FidelityBondMixin, FidelityBondWatchonlyWallet, SegwitWalletFidelityBonds, UTXOManager, WALLET_IMPLEMENTATIONS, compute_tx_locktime, - UnknownAddressForLabel) + UnknownAddressForLabel, TaprootWallet) from .storage import (Argon2Hash, Storage, StorageError, RetryableStorageError, StoragePasswordError, VolatileStorage) from .cryptoengine import (BTCEngine, BTC_P2PKH, BTC_P2SH_P2WPKH, BTC_P2WPKH, EngineError, @@ -27,7 +27,7 @@ from .configure import (load_test_config, process_shutdown, load_program_config, jm_single, get_network, update_persist_config, validate_address, is_burn_destination, get_mchannels, get_blockchain_interface_instance, set_config, is_segwit_mode, - is_native_segwit_mode, JMPluginService, get_interest_rate, + is_taproot_mode, is_native_segwit_mode, JMPluginService, get_interest_rate, get_bondless_makers_allowance, check_and_start_tor) from .blockchaininterface import (BlockchainInterface, RegtestBitcoinCoreInterface, BitcoinCoreInterface) diff --git a/src/jmclient/blockchaininterface.py b/src/jmclient/blockchaininterface.py index 81c7194..d050738 100644 --- a/src/jmclient/blockchaininterface.py +++ b/src/jmclient/blockchaininterface.py @@ -503,6 +503,49 @@ class BitcoinCoreInterface(BlockchainInterface): self.import_addresses(addresses - imported_addresses, wallet_name) return import_needed + def import_descriptors(self, desc_list: Iterable[str], wallet_name: str, + restart_cb: Callable[[str], None] = None) -> None: + requests = [] + for desc in desc_list: + requests.append({ + "desc": desc, + "timestamp": "now", + "label": wallet_name, + "internal": False + }) + + result = self._rpc('importdescriptors', [requests]) + + num_failed = 0 + for row in result: + if row['success'] == False: + num_failed += 1 + # don't try/catch, assume failure always has error message + log.warn(row['error']['message']) + if num_failed > 0: + fatal_msg = ("Fatal sync error: import of {} address(es) failed for " + "some reason. To prevent coin or privacy loss, " + "Joinmarket will not load a wallet in this conflicted " + "state. Try using a new Bitcoin Core wallet to sync this " + "Joinmarket wallet, or use a new Joinmarket wallet." + "".format(num_failed)) + if restart_cb: + restart_cb(fatal_msg) + else: + jmprint(fatal_msg, "important") + sys.exit(EXIT_FAILURE) + + def import_descriptors_if_needed(self, descriptors: Set[str], wallet_name: str) -> bool: + if wallet_name in self._rpc('listlabels', []): + imported_descriptors = set(self._rpc('getaddressesbylabel', + [wallet_name]).keys()) + else: + imported_descriptors = set() + import_needed = not descriptors.issubset(imported_descriptors) + if import_needed: + self.import_descriptors(descriptors - imported_descriptors, wallet_name) + return import_needed + def get_deser_from_gettransaction(self, rpcretval: dict) -> Optional[btc.CMutableTransaction]: if not "hex" in rpcretval: log.info("Malformed gettransaction output") @@ -533,6 +576,22 @@ class BitcoinCoreInterface(BlockchainInterface): return None return res + def getrawtransaction(self, txid: bytes) -> Optional[dict]: + htxid = bintohex(txid) + try: + res = self._rpc("getrawtransaction", [htxid]) + except JsonRpcError as e: + log.warn("Failed getrawtransaction call; JsonRpcError: " + repr(e)) + return None + except Exception as e: + log.warn("Failed getrawtransaction call; unexpected error:") + log.warn(str(e)) + return None + if res is None: + # happens in case of rpc connection failure: + return None + return res + def pushtx(self, txbin: bytes) -> bool: """ Given a binary serialized valid bitcoin transaction, broadcasts it to the network. @@ -720,6 +779,8 @@ class RegtestBitcoinCoreMixin(): if self._rpc('setgenerate', [True, reqd_blocks]): raise Exception("Something went wrong") """ + if not self.destn_addr: + self.destn_addr = self._rpc("getnewaddress", []) # now we do a custom create transaction and push to the receiver txid = self._rpc('sendtoaddress', [receiving_addr, amt]) if not txid: @@ -736,6 +797,7 @@ class BitcoinCoreNoHistoryInterface(BitcoinCoreInterface, RegtestBitcoinCoreMixi self.import_addresses_call_count = 0 self.wallet_name = None self.scan_result = None + self.destn_addr = None def import_addresses_if_needed(self, addresses: Set[str], wallet_name: str) -> bool: self.import_addresses_call_count += 1 @@ -793,7 +855,8 @@ class BitcoinCoreNoHistoryInterface(BitcoinCoreInterface, RegtestBitcoinCoreMixi wallet.disable_new_scripts = True def tick_forward_chain(self, n: int) -> None: - self.destn_addr = self._rpc("getnewaddress", []) + if not self.destn_addr: + self.destn_addr = self._rpc("getnewaddress", []) super().tick_forward_chain(n) @@ -810,7 +873,7 @@ class RegtestBitcoinCoreInterface(BitcoinCoreInterface, RegtestBitcoinCoreMixin) self.absurd_fees = False self.simulating = False self.shutdown_signal = False - self.destn_addr = self._rpc("getnewaddress", []) + self.destn_addr = None def estimate_fee_per_kb(self, tx_fees: int) -> int: if not self.absurd_fees: @@ -830,6 +893,8 @@ class RegtestBitcoinCoreInterface(BitcoinCoreInterface, RegtestBitcoinCoreMixin) self.tick_forward_chain(1) def simulate_blocks(self) -> None: + if not self.destn_addr: + self.destn_addr = self._rpc("getnewaddress", []) self.tickchainloop = task.LoopingCall(self.tickchain) self.tickchainloop.start(self.tick_forward_chain_interval) self.simulating = True diff --git a/src/jmclient/configure.py b/src/jmclient/configure.py index bb55590..7ef0fb6 100644 --- a/src/jmclient/configure.py +++ b/src/jmclient/configure.py @@ -953,6 +953,12 @@ def update_persist_config(section: str, name: str, value: Any) -> bool: f.writelines([x.encode("utf-8") for x in newlines]) return True +def is_taproot_mode() -> bool: + c = jm_single().config + if not c.has_option('POLICY', 'taproot'): + return False + return c.get('POLICY', 'taproot') != 'false' + def is_segwit_mode() -> bool: return jm_single().config.get('POLICY', 'segwit') != 'false' diff --git a/src/jmclient/cryptoengine.py b/src/jmclient/cryptoengine.py index 065e3ee..d90f2a6 100644 --- a/src/jmclient/cryptoengine.py +++ b/src/jmclient/cryptoengine.py @@ -16,7 +16,8 @@ from .configure import get_network, jm_single # make existing wallets unsable. TYPE_P2PKH, TYPE_P2SH_P2WPKH, TYPE_P2WPKH, TYPE_P2SH_M_N, TYPE_TIMELOCK_P2WSH, \ TYPE_SEGWIT_WALLET_FIDELITY_BONDS, TYPE_WATCHONLY_FIDELITY_BONDS, \ - TYPE_WATCHONLY_TIMELOCK_P2WSH, TYPE_WATCHONLY_P2WPKH, TYPE_P2WSH, TYPE_P2TR = range(11) + TYPE_WATCHONLY_TIMELOCK_P2WSH, TYPE_WATCHONLY_P2WPKH, TYPE_P2WSH, \ + TYPE_P2TR, TYPE_P2TR_FROST = range(12) NET_MAINNET, NET_TESTNET, NET_SIGNET = range(3) NET_MAP = {'mainnet': NET_MAINNET, 'testnet': NET_TESTNET, 'signet': NET_SIGNET} @@ -458,6 +459,32 @@ class BTC_Watchonly_P2WPKH(BTC_P2WPKH): hashcode=btc.SIGHASH_ALL, **kwargs): raise RuntimeError("Cannot spend from watch-only wallets") + +class BTC_P2TR(BTCEngine): + + @classproperty + def VBYTE(cls): + return btc.BTC_P2TR_VBYTE[get_network()] + + @classmethod + def pubkey_to_script(cls, pubkey): + return btc.pubkey_to_p2tr_script(pubkey) + + @classmethod + def pubkey_to_script_code(cls, pubkey): + raise NotImplementedError() + + @classmethod + def sign_transaction(cls, tx, index, privkey, amount, + hashcode=btc.SIGHASH_ALL, **kwargs): + assert amount is not None + assert 'spent_outputs' in kwargs + spent_outputs = kwargs['spent_outputs'] + return btc.sign(tx, index, privkey, + hashcode=hashcode, amount=amount, native="p2tr", + spent_outputs=spent_outputs) + + ENGINES = { TYPE_P2PKH: BTC_P2PKH, TYPE_P2SH_P2WPKH: BTC_P2SH_P2WPKH, @@ -466,5 +493,5 @@ ENGINES = { TYPE_WATCHONLY_TIMELOCK_P2WSH: BTC_Watchonly_Timelocked_P2WSH, TYPE_WATCHONLY_P2WPKH: BTC_Watchonly_P2WPKH, TYPE_SEGWIT_WALLET_FIDELITY_BONDS: BTC_P2WPKH, - TYPE_P2TR: None # TODO + TYPE_P2TR: BTC_P2TR, } diff --git a/src/jmclient/descriptor.py b/src/jmclient/descriptor.py new file mode 100644 index 0000000..dad7111 --- /dev/null +++ b/src/jmclient/descriptor.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- + +INPUT_CHARSET = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ " +CHECKSUM_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" +GENERATOR = [0xf5dee51989, 0xa9fdca3312, 0x1bab10e32d, 0x3706b1677a, 0x644d626ffd] + +def descsum_polymod(symbols): + """Internal function that computes the descriptor checksum.""" + chk = 1 + for value in symbols: + top = chk >> 35 + chk = (chk & 0x7ffffffff) << 5 ^ value + for i in range(5): + chk ^= GENERATOR[i] if ((top >> i) & 1) else 0 + return chk + +def descsum_expand(s): + """Internal function that does the character to symbol expansion""" + groups = [] + symbols = [] + for c in s: + if not c in INPUT_CHARSET: + return None + v = INPUT_CHARSET.find(c) + symbols.append(v & 31) + groups.append(v >> 5) + if len(groups) == 3: + symbols.append(groups[0] * 9 + groups[1] * 3 + groups[2]) + groups = [] + if len(groups) == 1: + symbols.append(groups[0]) + elif len(groups) == 2: + symbols.append(groups[0] * 3 + groups[1]) + return symbols + +def descsum_check(s): + """Verify that the checksum is correct in a descriptor""" + if s[-9] != '#': + return False + if not all(x in CHECKSUM_CHARSET for x in s[-8:]): + return False + symbols = descsum_expand(s[:-9]) + [CHECKSUM_CHARSET.find(x) for x in s[-8:]] + return descsum_polymod(symbols) == 1 + +def descsum_create(s): + """Add a checksum to a descriptor without""" + symbols = descsum_expand(s) + [0, 0, 0, 0, 0, 0, 0, 0] + checksum = descsum_polymod(symbols) ^ 1 + return s + '#' + ''.join(CHECKSUM_CHARSET[(checksum >> (5 * (7 - i))) & 31] for i in range(8)) diff --git a/src/jmclient/maker.py b/src/jmclient/maker.py index 842e774..73d0e4a 100644 --- a/src/jmclient/maker.py +++ b/src/jmclient/maker.py @@ -171,6 +171,9 @@ class Maker(object): elif self.wallet_service.get_txtype() == 'p2wpkh': sig, pub = [a for a in iter(tx.wit.vtxinwit[index].scriptWitness)] sigmsg = btc.CScript([sig]) + btc.CScript(pub) + elif self.wallet_service.get_txtype() == 'p2tr': + sig = [a for a in iter(tx.wit.vtxinwit[index].scriptWitness)] + sigmsg = btc.CScript(sig) else: jlog.error("Taker has unknown wallet type") sys.exit(EXIT_FAILURE) diff --git a/src/jmclient/support.py b/src/jmclient/support.py index e8641a0..d5d088b 100644 --- a/src/jmclient/support.py +++ b/src/jmclient/support.py @@ -167,9 +167,9 @@ def select_one_utxo(unspent, value): def calc_cj_fee(ordertype, cjfee, cj_amount): - if ordertype in ['sw0absoffer', 'swabsoffer', 'absoffer']: + if ordertype in ['trabsoffer', 'sw0absoffer', 'swabsoffer', 'absoffer']: real_cjfee = int(cjfee) - elif ordertype in ['sw0reloffer', 'swreloffer', 'reloffer']: + elif ordertype in ['trreloffer', 'sw0reloffer', 'swreloffer', 'reloffer']: real_cjfee = int((Decimal(cjfee) * Decimal(cj_amount)).quantize(Decimal( 1))) else: @@ -338,9 +338,11 @@ def choose_sweep_orders(offers, sumtxfee_contribution = 0 for order in ordercombo: sumtxfee_contribution += order['txfee'] - if order['ordertype'] in ['sw0absoffer', 'swabsoffer', 'absoffer']: + if order['ordertype'] in ['trabsoffer', 'sw0absoffer', + 'swabsoffer', 'absoffer']: sumabsfee += int(order['cjfee']) - elif order['ordertype'] in ['sw0reloffer', 'swreloffer', 'reloffer']: + elif order['ordertype'] in ['trreloffer', 'sw0reloffer', + 'swreloffer', 'reloffer']: sumrelfee += Decimal(order['cjfee']) #this is unreachable since calc_cj_fee must already have been called else: #pragma: no cover diff --git a/src/jmclient/taker.py b/src/jmclient/taker.py index 6a58af3..998e83f 100644 --- a/src/jmclient/taker.py +++ b/src/jmclient/taker.py @@ -12,7 +12,8 @@ from jmbase import get_log, bintohex, hexbin from jmclient.support import (calc_cj_fee, fidelity_bond_weighted_order_choose, choose_orders, choose_sweep_orders) from jmclient.wallet import (estimate_tx_fee, compute_tx_locktime, - FidelityBondMixin, UnknownAddressForLabel) + FidelityBondMixin, UnknownAddressForLabel, + TaprootWallet) from jmclient.podle import generate_podle, get_podle_commitments from jmclient.wallet_service import WalletService from jmclient.fidelity_bond import FidelityBondProof @@ -284,6 +285,8 @@ class Taker(object): allowed_types = ["swreloffer", "swabsoffer"] elif self.wallet_service.get_txtype() == "p2wpkh": allowed_types = ["sw0reloffer", "sw0absoffer"] + elif self.wallet_service.get_txtype() == "p2tr": + allowed_types = ["trreloffer", "trabsoffer"] else: jlog.error("Unrecognized wallet type, taker cannot continue.") return False @@ -374,6 +377,8 @@ class Taker(object): allowed_types = ["swreloffer", "swabsoffer"] elif self.wallet_service.get_txtype() == "p2wpkh": allowed_types = ["sw0reloffer", "sw0absoffer"] + elif self.wallet_service.get_txtype() == "p2tr": + allowed_types = ["trreloffer", "trabsoffer"] else: jlog.error("Unrecognized wallet type, taker cannot continue.") return False @@ -679,8 +684,11 @@ class Taker(object): jlog.debug("Junk signature: " + str(sig_deserialized) + \ ", not attempting to verify") break - # The second case here is kept for backwards compatibility. - if len(sig_deserialized) == 2: + # The third case here is kept for backwards compatibility. + if len(sig_deserialized) == 1: + ver_sig = sig_deserialized[0] + ver_pub = None + elif len(sig_deserialized) == 2: ver_sig, ver_pub = sig_deserialized elif len(sig_deserialized) == 3: ver_sig, ver_pub, _ = sig_deserialized @@ -689,10 +697,17 @@ class Taker(object): break scriptPubKey = btc.CScript(utxo_data[i]['script']) - is_witness_input = scriptPubKey.is_p2sh() or scriptPubKey.is_witness_v0_keyhash() + is_witness_input = (scriptPubKey.is_p2sh() + or scriptPubKey.is_witness_v0_keyhash() + or scriptPubKey.is_witness_v1_taproot()) ver_amt = utxo_data[i]['value'] if is_witness_input else None - witness = btc.CScriptWitness( - [ver_sig, ver_pub]) if is_witness_input else None + if is_witness_input: + if ver_pub: + witness = btc.CScriptWitness([ver_sig, ver_pub]) + else: + witness = btc.CScriptWitness([ver_sig]) + else: + witness = None # don't attempt to parse `pub` as pubkey unless it's valid. if scriptPubKey.is_p2sh(): @@ -704,13 +719,20 @@ class Taker(object): if scriptPubKey.is_witness_v0_keyhash(): scriptSig = btc.CScript(b'') + elif scriptPubKey.is_witness_v1_taproot(): + scriptSig = btc.CScript(b'') elif scriptPubKey.is_p2sh(): scriptSig = btc.CScript([s]) else: scriptSig = btc.CScript([ver_sig, ver_pub]) + spent_outputs = None + wallet = self.wallet_service.wallet + if isinstance(wallet, TaprootWallet): + spent_outputs = wallet.get_spent_outputs(self.latest_tx) sig_good = btc.verify_tx_input(self.latest_tx, u[0], scriptSig, - scriptPubKey, amount=ver_amt, witness=witness) + scriptPubKey, amount=ver_amt, witness=witness, + spent_outputs=spent_outputs) if sig_good: jlog.debug('found good sig at index=%d' % (u[0])) diff --git a/src/jmclient/wallet.py b/src/jmclient/wallet.py index 5ac786f..072be3d 100644 --- a/src/jmclient/wallet.py +++ b/src/jmclient/wallet.py @@ -29,11 +29,12 @@ from .support import select_gradual, select_greedy, select_greediest, \ from .cryptoengine import TYPE_P2PKH, TYPE_P2SH_P2WPKH, TYPE_P2WSH,\ TYPE_P2WPKH, TYPE_TIMELOCK_P2WSH, TYPE_SEGWIT_WALLET_FIDELITY_BONDS,\ TYPE_WATCHONLY_FIDELITY_BONDS, TYPE_WATCHONLY_TIMELOCK_P2WSH, \ - TYPE_WATCHONLY_P2WPKH, TYPE_P2TR, ENGINES, detect_script_type, EngineError + TYPE_WATCHONLY_P2WPKH, TYPE_P2TR, TYPE_P2TR_FROST, ENGINES, \ + detect_script_type, EngineError from .support import get_random_bytes from . import mn_encode, mn_decode import jmbitcoin as btc -from jmbase import JM_WALLET_NAME_PREFIX, bintohex +from jmbase import JM_WALLET_NAME_PREFIX, bintohex, hextobin def _int_to_bytestr(i): @@ -513,6 +514,8 @@ class BaseWallet(object): return 'p2pkh' elif self.TYPE == TYPE_P2SH_P2WPKH: return 'p2sh-p2wpkh' + elif self.TYPE == TYPE_P2TR: + return 'p2tr' elif self.TYPE in (TYPE_P2WPKH, TYPE_SEGWIT_WALLET_FIDELITY_BONDS): return 'p2wpkh' @@ -540,6 +543,29 @@ class BaseWallet(object): # from detect_script_type are covered. assert False + def getrawtransaction(self, txid): + """ If the transaction for txid is an in-wallet + transaction, will return a CTransaction object for it; + if not, will return None. + """ + bci = jm_single().bc_interface + rawtx = bci.getrawtransaction(txid) + if not rawtx: + return None + return rawtx + + def get_spent_outputs(self, tx): + spent_outputs = [] + for txIn in tx.vin: + txid = txIn.prevout.hash[::-1] + n = txIn.prevout.n + rawtx = self.getrawtransaction(txid) + if rawtx: + prevtx = btc.CMutableTransaction.deserialize( + hextobin(rawtx)) + spent_outputs.append(prevtx.vout[n]) + return spent_outputs + def sign_tx(self, tx, scripts, **kwargs): """ Add signatures to transaction for inputs referenced by scripts. @@ -556,6 +582,11 @@ class BaseWallet(object): assert amount > 0 path = self.script_to_path(script) privkey, engine = self._get_key_from_path(path) + spent_outputs = None # need for SignatureHashSchnorr + if isinstance(self, TaprootWallet): + spent_outputs = self.get_spent_outputs(tx) + if spent_outputs: + kwargs['spent_outputs'] = spent_outputs sig, msg = engine.sign_transaction(tx, index, privkey, amount, **kwargs) if not sig: @@ -577,14 +608,37 @@ class BaseWallet(object): the wallet from other sources, or receiving payments or donations. JoinMarket will never generate these addresses for internal use. """ - return self.get_new_addr(mixdepth, self.ADDRESS_TYPE_EXTERNAL) + if isinstance(self, TaprootWallet): + pubkey = self.get_new_pubkey(mixdepth, self.ADDRESS_TYPE_EXTERNAL) + return self.pubkey_to_addr(pubkey) + else: + return self.get_new_addr(mixdepth, self.ADDRESS_TYPE_EXTERNAL) def get_internal_addr(self, mixdepth): """ Return an address for internal usage, as change addresses and when participating in transactions initiated by other parties. """ - return self.get_new_addr(mixdepth, self.ADDRESS_TYPE_INTERNAL) + if isinstance(self, TaprootWallet): + pubkey = self.get_new_pubkey(mixdepth, self.ADDRESS_TYPE_INTERNAL) + return self.pubkey_to_addr(pubkey) + else: + return self.get_new_addr(mixdepth, self.ADDRESS_TYPE_INTERNAL) + + def get_external_pubkey(self, mixdepth): + """ + Return an pubkey suitable for external distribution, including funding + the wallet from other sources, or receiving payments or donations. + JoinMarket will never generate these addresses for internal use. + """ + return self.get_new_pubkey(mixdepth, self.ADDRESS_TYPE_EXTERNAL) + + def get_internal_pubkey(self, mixdepth): + """ + Return an pubkey for internal usage, as change addresses and when + participating in transactions initiated by other parties. + """ + return self.get_new_pubkey(mixdepth, self.ADDRESS_TYPE_INTERNAL) def get_external_script(self, mixdepth): return self.get_new_script(mixdepth, self.ADDRESS_TYPE_EXTERNAL) @@ -652,6 +706,12 @@ class BaseWallet(object): return self.get_address_from_path(path, validate_cache=validate_cache) + def get_pubkey(self, mixdepth, address_type, index, + validate_cache: bool = False): + path = self.get_path(mixdepth, address_type, index) + return self._get_pubkey_from_path( + path, validate_cache=validate_cache)[0] + def get_address_from_path(self, path, validate_cache: bool = False): cache = self._get_cache_for_path(path) @@ -680,6 +740,14 @@ class BaseWallet(object): return self.script_to_addr(script, validate_cache=validate_cache) + def get_new_pubkey(self, mixdepth, address_type, + validate_cache: bool = True): + script = self.get_new_script( + mixdepth, address_type, validate_cache=validate_cache) + path = self.script_to_path(script) + return self._get_pubkey_from_path( + path, validate_cache=validate_cache)[0] + def get_new_script(self, mixdepth, address_type, validate_cache: bool = True): raise NotImplementedError() @@ -2811,6 +2879,10 @@ class BIP84Wallet(BIP32PurposedWallet): _PURPOSE = 2**31 + 84 _ENGINE = ENGINES[TYPE_P2WPKH] +class BIP86Wallet(BIP32PurposedWallet): + _PURPOSE = 2**31 + 86 + _ENGINE = ENGINES[TYPE_P2TR] + class SegwitLegacyWallet(ImportWalletMixin, BIP39WalletMixin, PSBTWalletMixin, SNICKERWalletMixin, BIP49Wallet): TYPE = TYPE_P2SH_P2WPKH @@ -2820,6 +2892,10 @@ class SegwitWallet(ImportWalletMixin, BIP39WalletMixin, PSBTWalletMixin, SNICKER class SegwitWalletFidelityBonds(FidelityBondMixin, SegwitWallet): TYPE = TYPE_SEGWIT_WALLET_FIDELITY_BONDS +class TaprootWallet(BIP39WalletMixin, BIP86Wallet): + # FIXME add other mixins if adapted + TYPE = TYPE_P2TR + class FidelityBondWatchonlyWallet(FidelityBondMixin, BIP84Wallet): TYPE = TYPE_WATCHONLY_FIDELITY_BONDS @@ -2876,10 +2952,19 @@ class FidelityBondWatchonlyWallet(FidelityBondMixin, BIP84Wallet): return pubkey, self._ENGINE +class FrostWallet(object): + + _PURPOSE = 2**31 + 86 + _ENGINE = ENGINES[TYPE_P2TR] + TYPE = TYPE_P2TR_FROST + + WALLET_IMPLEMENTATIONS = { LegacyWallet.TYPE: LegacyWallet, SegwitLegacyWallet.TYPE: SegwitLegacyWallet, SegwitWallet.TYPE: SegwitWallet, SegwitWalletFidelityBonds.TYPE: SegwitWalletFidelityBonds, - FidelityBondWatchonlyWallet.TYPE: FidelityBondWatchonlyWallet + FidelityBondWatchonlyWallet.TYPE: FidelityBondWatchonlyWallet, + TaprootWallet.TYPE: TaprootWallet, + FrostWallet.TYPE: FrostWallet, } diff --git a/src/jmclient/wallet_service.py b/src/jmclient/wallet_service.py index b68ae63..ae0445a 100644 --- a/src/jmclient/wallet_service.py +++ b/src/jmclient/wallet_service.py @@ -16,9 +16,10 @@ from jmclient.configure import jm_single, get_log from jmclient.output import fmt_tx_data from jmclient.blockchaininterface import (INF_HEIGHT, BitcoinCoreInterface, BitcoinCoreNoHistoryInterface) -from jmclient.wallet import FidelityBondMixin, BaseWallet +from jmclient.wallet import FidelityBondMixin, BaseWallet, TaprootWallet from jmbase import (stop_reactor, hextobin, utxo_to_utxostr, jmprint, EXIT_SUCCESS, EXIT_FAILURE) +from .descriptor import descsum_create """Wallet service @@ -626,8 +627,21 @@ class WalletService(Service): # we ensure that all addresses that will be displayed (see wallet_utils.py, # function wallet_display()) are imported by importing gap limit beyond current # index: - self.bci.import_addresses(self.collect_addresses_gap(), self.get_wallet_name(), - self.restart_callback) + if isinstance(self.wallet, TaprootWallet): + pubkeys = self.collect_pubkeys_gap() + desc_scripts = [f'tr({bytes(P)[1:].hex()})' for P in pubkeys] + descriptors = set() + for desc in desc_scripts: + descriptors.add(f'{descsum_create(desc)}') + self.bci.import_descriptors( + descriptors, + self.get_wallet_name(), + self.restart_callback) + else: + self.bci.import_addresses( + self.collect_addresses_gap(), + self.get_wallet_name(), + self.restart_callback) if isinstance(self.wallet, FidelityBondMixin): mixdepth = FidelityBondMixin.FIDELITY_BOND_MIXDEPTH @@ -754,10 +768,19 @@ class WalletService(Service): """ jlog.debug("requesting detailed wallet history") wallet_name = self.get_wallet_name() - addresses, saved_indices = self.collect_addresses_init() - import_needed = self.bci.import_addresses_if_needed(addresses, - wallet_name) + if isinstance(self.wallet, TaprootWallet): + pubkeys, saved_indices = self.collect_pubkeys_init() + desc_scripts = [f'tr({bytes(P)[1:].hex()})' for P in pubkeys] + descriptors= set() + for desc in desc_scripts: + descriptors.add(f'{descsum_create(desc)}') + import_needed = self.bci.import_descriptors_if_needed( + descriptors, wallet_name) + else: + addresses, saved_indices = self.collect_addresses_init() + import_needed = self.bci.import_addresses_if_needed( + addresses, wallet_name) if import_needed: self.display_rescan_message_and_system_exit(self.restart_callback) return @@ -963,10 +986,22 @@ class WalletService(Service): if self.bci is not None and hasattr(self.bci, 'import_addresses'): self.bci.import_addresses([addr], self.wallet.get_wallet_name()) + def import_pubkey(self, pubkey): + if self.bci is not None and hasattr(self.bci, 'import_descriptors'): + desc_script = f'tr({bytes(pubkey)[1:].hex()})' + descriptor = descsum_create(desc_script) + self.bci.import_descriptors( + [descriptor], self.wallet.get_wallet_name()) + def get_internal_addr(self, mixdepth): - addr = self.wallet.get_internal_addr(mixdepth) - self.import_addr(addr) - return addr + if isinstance(self.wallet, TaprootWallet): + pubkey = self.wallet.get_internal_pubkey(mixdepth) + self.import_pubkey(pubkey) + return self.wallet.pubkey_to_addr(pubkey) + else: + addr = self.wallet.get_internal_addr(mixdepth) + self.import_addr(addr) + return addr def collect_addresses_init(self) -> Tuple[Set[str], Dict[int, List[int]]]: """ Collects the "current" set of addresses, @@ -1002,6 +1037,32 @@ class WalletService(Service): addresses.add(self.get_addr(md, address_type, timenumber)) return addresses, saved_indices + def collect_pubkeys_init(self) -> Tuple[Set[str], Dict[int, List[int]]]: + """ Collects the "current" set of pubkeys, + as defined by the indices recorded in the wallet's + index cache (persisted in the wallet file usually). + Note that it collects up to the current indices plus + the gap limit. + """ + pubkeys = set() + saved_indices = dict() + + for md in range(self.max_mixdepth + 1): + saved_indices[md] = [0, 0] + for address_type in (BaseWallet.ADDRESS_TYPE_EXTERNAL, + BaseWallet.ADDRESS_TYPE_INTERNAL): + next_unused = self.get_next_unused_index(md, address_type) + for index in range(next_unused): + pubkeys.add(self.get_pubkey(md, address_type, index)) + for index in range(self.gap_limit): + pubkeys.add(self.get_new_pubkey( + md, address_type, validate_cache=False)) + # reset the indices to the value we had before the + # new address calls: + self.set_next_index(md, address_type, next_unused) + saved_indices[md][address_type] = next_unused + return pubkeys, saved_indices + def collect_addresses_gap(self, gap_limit=None): gap_limit = gap_limit or self.gap_limit addresses = set() @@ -1015,10 +1076,28 @@ class WalletService(Service): self.set_next_index(md, address_type, old_next) return addresses + def collect_pubkeys_gap(self, gap_limit=None): + gap_limit = gap_limit or self.gap_limit + pubkeys = set() + for md in range(self.max_mixdepth + 1): + for address_type in (BaseWallet.ADDRESS_TYPE_INTERNAL, + BaseWallet.ADDRESS_TYPE_EXTERNAL): + old_next = self.get_next_unused_index(md, address_type) + for index in range(gap_limit): + pubkeys.add(self.get_new_pubkey( + md, address_type, validate_cache=False)) + self.set_next_index(md, address_type, old_next) + return pubkeys + def get_external_addr(self, mixdepth): - addr = self.wallet.get_external_addr(mixdepth) - self.import_addr(addr) - return addr + if isinstance(self.wallet, TaprootWallet): + pubkey = self.wallet.get_external_pubkey(mixdepth) + self.import_pubkey(pubkey) + return self.wallet.pubkey_to_addr(pubkey) + else: + addr = self.wallet.get_external_addr(mixdepth) + self.import_addr(addr) + return addr def __getattr__(self, attr): # any method not present here is passed diff --git a/src/jmclient/wallet_utils.py b/src/jmclient/wallet_utils.py index 67ec02c..ed8f421 100644 --- a/src/jmclient/wallet_utils.py +++ b/src/jmclient/wallet_utils.py @@ -12,9 +12,10 @@ from itertools import islice, chain from typing import Callable, Optional, Tuple, Union from jmclient import (get_network, WALLET_IMPLEMENTATIONS, Storage, podle, jm_single, WalletError, BaseWallet, VolatileStorage, - StoragePasswordError, is_segwit_mode, SegwitLegacyWallet, LegacyWallet, - SegwitWallet, FidelityBondMixin, FidelityBondWatchonlyWallet, - is_native_segwit_mode, load_program_config, add_base_options, check_regtest) + StoragePasswordError, is_taproot_mode, is_segwit_mode, SegwitLegacyWallet, + LegacyWallet, SegwitWallet, FidelityBondMixin, FidelityBondWatchonlyWallet, + TaprootWallet, is_native_segwit_mode, load_program_config, + add_base_options, check_regtest, JMClientProtocolFactory, start_reactor) from jmclient.blockchaininterface import (BitcoinCoreInterface, BitcoinCoreNoHistoryInterface) from jmclient.wallet_service import WalletService @@ -24,9 +25,10 @@ from jmbase.support import (get_password, jmprint, EXIT_FAILURE, cli_prompt_user_yesno) from .cryptoengine import TYPE_P2PKH, TYPE_P2SH_P2WPKH, TYPE_P2WPKH, \ - TYPE_SEGWIT_WALLET_FIDELITY_BONDS + TYPE_SEGWIT_WALLET_FIDELITY_BONDS, TYPE_P2TR from .output import fmt_utxo import jmbitcoin as btc +from .descriptor import descsum_create # used for creating new wallets @@ -557,7 +559,12 @@ def wallet_display(wallet_service, showprivkey, displayall=False, path = wallet_service.get_path(m, address_type, k) addr = wallet_service.get_address_from_path(path) if k >= unused_index: - gap_addrs.append(addr) + if isinstance(wallet_service.wallet, TaprootWallet): + P = wallet_service.get_pubkey(m, address_type, k) + desc = f'tr({bytes(P)[1:].hex()})' + gap_addrs.append(f'{descsum_create(desc)}') + else: + gap_addrs.append(addr) label = wallet_service.get_address_label(addr) balance, status = get_addr_status( path, utxos[m], utxos_enabled[m], k >= unused_index, address_type) @@ -577,8 +584,12 @@ def wallet_display(wallet_service, showprivkey, displayall=False, # displayed for user deposit. # It also does not apply to fidelity bond addresses which are created manually. if address_type == BaseWallet.ADDRESS_TYPE_EXTERNAL and wallet_service.bci is not None: - wallet_service.bci.import_addresses(gap_addrs, - wallet_service.get_wallet_name()) + if isinstance(wallet_service.wallet, TaprootWallet): + wallet_service.bci.import_descriptors( + gap_addrs, wallet_service.get_wallet_name()) + else: + wallet_service.bci.import_addresses( + gap_addrs, wallet_service.get_wallet_name()) wallet_service.set_next_index(m, address_type, unused_index) path = wallet_service.get_path_repr(wallet_service.get_path(m, address_type)) branchlist.append(WalletViewBranch(path, m, address_type, entrylist, @@ -755,7 +766,8 @@ def wallet_generate_recover_bip39(method: str, password = enter_wallet_password_callback() if not password: - return False + password = None + # return False wallet_name = enter_wallet_file_name_callback() if wallet_name == "cancelled": @@ -765,7 +777,10 @@ def wallet_generate_recover_bip39(method: str, if not wallet_name: wallet_name = default_wallet_name wallet_path = os.path.join(walletspath, wallet_name) - support_fidelity_bonds = enter_do_support_fidelity_bonds() + if is_taproot_mode(): + support_fidelity_bonds = False + else: + support_fidelity_bonds = enter_do_support_fidelity_bonds() wallet_cls = get_wallet_cls(get_configured_wallet_type(support_fidelity_bonds)) wallet = create_wallet(wallet_path, password, mixdepth, wallet_cls, entropy=entropy, @@ -779,7 +794,13 @@ def wallet_generate_recover_bip39(method: str, def wallet_generate_recover(method, walletspath, default_wallet_name='wallet.jmdat', mixdepth=DEFAULT_MIXDEPTH): - if is_segwit_mode(): + if is_taproot_mode(): + return wallet_generate_recover_bip39(method, walletspath, + default_wallet_name, cli_display_user_words, cli_user_mnemonic_entry, + cli_get_wallet_passphrase_check, cli_get_wallet_file_name, + cli_do_use_mnemonic_extension, cli_get_mnemonic_extension, + cli_do_support_fidelity_bonds, mixdepth=mixdepth) + elif is_segwit_mode(): #Here using default callbacks for scripts (not used in Qt) return wallet_generate_recover_bip39(method, walletspath, default_wallet_name, cli_display_user_words, cli_user_mnemonic_entry, @@ -1429,7 +1450,9 @@ def wallet_createwatchonly(wallet_root_path, master_pub_key): def get_configured_wallet_type(support_fidelity_bonds): configured_type = TYPE_P2PKH - if is_segwit_mode(): + if is_taproot_mode(): + return TYPE_P2TR + elif is_segwit_mode(): if is_native_segwit_mode(): configured_type = TYPE_P2WPKH else: diff --git a/src/jmclient/yieldgenerator.py b/src/jmclient/yieldgenerator.py index 8be3307..b7e1c21 100644 --- a/src/jmclient/yieldgenerator.py +++ b/src/jmclient/yieldgenerator.py @@ -99,13 +99,15 @@ class YieldGeneratorBasic(YieldGenerator): max_mix = max(mix_balance, key=mix_balance.get) f = '0' - if self.ordertype in ('reloffer', 'swreloffer', 'sw0reloffer'): + if self.ordertype in ('reloffer', 'swreloffer', + 'sw0reloffer', 'trreloffer'): f = self.cjfee_r #minimum size bumped if necessary such that you always profit #least 50% of the miner fee self.minsize = max(int(1.5 * self.txfee_contribution / float(self.cjfee_r)), self.minsize) - elif self.ordertype in ('absoffer', 'swabsoffer', 'sw0absoffer'): + elif self.ordertype in ('absoffer', 'swabsoffer', + 'sw0absoffer', 'trreloffer'): f = str(self.txfee_contribution + self.cjfee_a) order = {'oid': 0, 'ordertype': self.ordertype, @@ -455,7 +457,9 @@ def ygmain(ygclass, nickserv_password='', gaplimit=6): wallet_service.startService() txtype = wallet_service.get_txtype() - if txtype == "p2wpkh": + if txtype == "p2tr": + prefix = "tr" + elif txtype == "p2wpkh": prefix = "sw0" elif txtype == "p2sh-p2wpkh": prefix = "sw" diff --git a/src/jmdaemon/orderbookwatch.py b/src/jmdaemon/orderbookwatch.py index ba62087..10ed969 100644 --- a/src/jmdaemon/orderbookwatch.py +++ b/src/jmdaemon/orderbookwatch.py @@ -96,8 +96,9 @@ class OrderbookWatch(object): "from {}").format log.debug(fmt(minsize, maxsize, counterparty)) return - if ordertype in ['sw0absoffer', 'swabsoffer', 'absoffer']\ - and not isinstance(cjfee, Integral): + if (ordertype in ['trabsoffer', 'sw0absoffer', + 'swabsoffer', 'absoffer'] + and not isinstance(cjfee, Integral)): try: cjfee = int(cjfee) except ValueError: diff --git a/src/jmdaemon/protocol.py b/src/jmdaemon/protocol.py index eefe72d..aa55d89 100644 --- a/src/jmdaemon/protocol.py +++ b/src/jmdaemon/protocol.py @@ -16,6 +16,10 @@ offertypes = {"reloffer": [(int, "oid"), (int, "minsize"), (int, "maxsize"), "sw0reloffer": [(int, "oid"), (int, "minsize"), (int, "maxsize"), (int, "txfee"), (float, "cjfee")], "sw0absoffer": [(int, "oid"), (int, "minsize"), (int, "maxsize"), + (int, "txfee"), (int, "cjfee")], + "trreloffer": [(int, "oid"), (int, "minsize"), (int, "maxsize"), + (int, "txfee"), (float, "cjfee")], + "trabsoffer": [(int, "oid"), (int, "minsize"), (int, "maxsize"), (int, "txfee"), (int, "cjfee")]} offername_list = list(offertypes.keys()) diff --git a/test/jmclient/test_wallet.py b/test/jmclient/test_wallet.py index 5dffa71..1be2109 100644 --- a/test/jmclient/test_wallet.py +++ b/test/jmclient/test_wallet.py @@ -14,7 +14,7 @@ from jmclient import load_test_config, jm_single, BaseWallet, \ SegwitWallet, WalletService, SegwitWalletFidelityBonds,\ create_wallet, open_test_wallet_maybe, open_wallet, \ FidelityBondMixin, FidelityBondWatchonlyWallet,\ - wallet_gettimelockaddress, UnknownAddressForLabel + wallet_gettimelockaddress, UnknownAddressForLabel, TaprootWallet from test_blockchaininterface import sync_test_wallet from freezegun import freeze_time @@ -414,6 +414,26 @@ def test_signing_simple(setup_wallet, wallet_cls, type_check): txout = jm_single().bc_interface.pushtx(tx.serialize()) assert txout + +def test_signing_simple_p2tr(setup_wallet): + jm_single().config.set('BLOCKCHAIN', 'network', 'testnet') + storage = VolatileStorage() + TaprootWallet.initialize(storage, get_network(), entropy=b"\xaa"*16) + wallet = TaprootWallet(storage) + utxo = fund_wallet_addr(wallet, wallet.get_internal_addr(0)) + # The dummy output is constructed as an unspendable p2sh: + tx = btc.mktx([utxo], + [{"address": str(btc.CCoinAddress.from_scriptPubKey( + btc.CScript(b"\x00").to_p2sh_scriptPubKey())), + "value": 10**8 - 9000}]) + script = wallet.get_script(0, BaseWallet.ADDRESS_TYPE_INTERNAL, 0) + success, msg = wallet.sign_tx(tx, {0: (script, 10**8)}) + assert success, msg + assert_segwit(tx) + txout = jm_single().bc_interface.pushtx(tx.serialize()) + assert txout + + # note that address validation is tested separately; # this test functions only to make sure that given a valid # taproot address, we can actually spend to it