From 0a99d99fe4cb8432945d37c8e5d221715c538e4f Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Fri, 13 Sep 2024 10:00:38 +0200 Subject: [PATCH] signet: miner skips PSBT step for OP_TRUE --- contrib/signet/README.md | 1 + contrib/signet/miner | 76 ++++++++++++++------ test/functional/test_framework/blocktools.py | 3 + test/functional/tool_signet_miner.py | 65 ++++++++++++++--- 4 files changed, 112 insertions(+), 33 deletions(-) diff --git a/contrib/signet/README.md b/contrib/signet/README.md index cd0cae6d509..49947985719 100644 --- a/contrib/signet/README.md +++ b/contrib/signet/README.md @@ -80,3 +80,4 @@ These steps can instead be done explicitly: This is intended to allow you to replace part of the pipeline for further experimentation (eg, to sign the block with a hardware wallet). +For custom signets with a trivial challenge such as `OP_TRUE` and `OP_2` the walletprocesspsbt step can be skipped. diff --git a/contrib/signet/miner b/contrib/signet/miner index 3dcc679470a..b9a01080f14 100755 --- a/contrib/signet/miner +++ b/contrib/signet/miner @@ -18,7 +18,7 @@ PATH_BASE_CONTRIB_SIGNET = os.path.abspath(os.path.dirname(os.path.realpath(__fi PATH_BASE_TEST_FUNCTIONAL = os.path.abspath(os.path.join(PATH_BASE_CONTRIB_SIGNET, "..", "..", "test", "functional")) sys.path.insert(0, PATH_BASE_TEST_FUNCTIONAL) -from test_framework.blocktools import get_witness_script, script_BIP34_coinbase_height # noqa: E402 +from test_framework.blocktools import get_witness_script, script_BIP34_coinbase_height, SIGNET_HEADER # noqa: E402 from test_framework.messages import CBlock, CBlockHeader, COutPoint, CTransaction, CTxIn, CTxInWitness, CTxOut, from_binary, from_hex, ser_string, ser_uint256, tx_from_hex, MAX_SEQUENCE_NONFINAL # noqa: E402 from test_framework.psbt import PSBT, PSBTMap, PSBT_GLOBAL_UNSIGNED_TX, PSBT_IN_FINAL_SCRIPTSIG, PSBT_IN_FINAL_SCRIPTWITNESS, PSBT_IN_NON_WITNESS_UTXO, PSBT_IN_SIGHASH_TYPE # noqa: E402 from test_framework.script import CScript, CScriptOp # noqa: E402 @@ -28,7 +28,6 @@ logging.basicConfig( level=logging.INFO, datefmt='%Y-%m-%d %H:%M:%S') -SIGNET_HEADER = b"\xec\xc7\xda\xa2" PSBT_SIGNET_BLOCK = b"\xfc\x06signetb" # proprietary PSBT global field holding the block being signed RE_MULTIMINER = re.compile(r"^(\d+)(-(\d+))?/(\d+)$") @@ -77,15 +76,20 @@ def decode_challenge_psbt(b64psbt): def get_block_from_psbt(psbt): return from_binary(CBlock, psbt.g.map[PSBT_SIGNET_BLOCK]) -def get_solution_from_psbt(psbt): +def get_solution_from_psbt(psbt, emptyok=False): scriptSig = psbt.i[0].map.get(PSBT_IN_FINAL_SCRIPTSIG, b"") scriptWitness = psbt.i[0].map.get(PSBT_IN_FINAL_SCRIPTWITNESS, b"\x00") + if emptyok and len(scriptSig) == 0 and scriptWitness == b"\x00": + return None return ser_string(scriptSig) + scriptWitness def finish_block(block, signet_solution, grind_cmd): - block.vtx[0].vout[-1].scriptPubKey += CScriptOp.encode_op_pushdata(SIGNET_HEADER + signet_solution) - block.vtx[0].rehash() - block.hashMerkleRoot = block.calc_merkle_root() + if signet_solution is None: + pass # Don't need to add a signet commitment if there's no signet signature needed + else: + block.vtx[0].vout[-1].scriptPubKey += CScriptOp.encode_op_pushdata(SIGNET_HEADER + signet_solution) + block.vtx[0].rehash() + block.hashMerkleRoot = block.calc_merkle_root() if grind_cmd is None: block.solve() else: @@ -97,10 +101,7 @@ def finish_block(block, signet_solution, grind_cmd): block.rehash() return block -def generate_psbt(tmpl, reward_spk, *, blocktime=None, poolid=None): - signet_spk = tmpl["signet_challenge"] - signet_spk_bin = bytes.fromhex(signet_spk) - +def new_block(tmpl, reward_spk, *, blocktime=None, poolid=None): scriptSig = script_BIP34_coinbase_height(tmpl["height"]) if poolid is not None: scriptSig = CScript(b"" + scriptSig + CScriptOp.encode_op_pushdata(poolid)) @@ -129,8 +130,14 @@ def generate_psbt(tmpl, reward_spk, *, blocktime=None, poolid=None): block.vtx[0].wit.vtxinwit = [cbwit] block.vtx[0].vout.append(CTxOut(0, bytes(get_witness_script(witroot, witnonce)))) - signme, spendme = signet_txs(block, signet_spk_bin) + block.vtx[0].rehash() + block.hashMerkleRoot = block.calc_merkle_root() + return block + +def generate_psbt(block, signet_spk): + signet_spk_bin = bytes.fromhex(signet_spk) + signme, spendme = signet_txs(block, signet_spk_bin) psbt = PSBT() psbt.g = PSBTMap( {PSBT_GLOBAL_UNSIGNED_TX: signme.serialize(), PSBT_SIGNET_BLOCK: block.serialize() @@ -179,14 +186,16 @@ def get_reward_addr_spk(args, height): def do_genpsbt(args): poolid = get_poolid(args) tmpl = json.load(sys.stdin) + signet_spk = tmpl["signet_challenge"] _, reward_spk = get_reward_addr_spk(args, tmpl["height"]) - psbt = generate_psbt(tmpl, reward_spk, poolid=poolid) + block = new_block(tmpl, reward_spk, poolid=poolid) + psbt = generate_psbt(block, signet_spk) print(psbt) def do_solvepsbt(args): psbt = decode_challenge_psbt(sys.stdin.read()) block = get_block_from_psbt(psbt) - signet_solution = get_solution_from_psbt(psbt) + signet_solution = get_solution_from_psbt(psbt, emptyok=True) block = finish_block(block, signet_solution, args.grind_cmd) print(block.serialize().hex()) @@ -229,6 +238,21 @@ def seconds_to_hms(s): out = "-" + out return out +def trivial_challenge(spkhex): + """ + BIP325 allows omitting the signet commitment when scriptSig and + scriptWitness are both empty. This is the case for trivial + challenges such as OP_TRUE or a single data push. + """ + spk = bytes.fromhex(spkhex) + if len(spk) == 1 and 0x51 <= spk[0] <= 0x60: + # OP_TRUE/OP_1...OP_16 + return True + elif 2 <= len(spk) <= 76 and spk[0] + 1 == len(spk): + # Single fixed push of 1-75 bytes + return True + return False + class Generate: INTERVAL = 600.0*2016/2015 # 10 minutes, adjusted for the off-by-one bug @@ -329,16 +353,22 @@ class Generate: return tmpl def mine(self, bcli, grind_cmd, tmpl, reward_spk): - psbt = generate_psbt(tmpl, reward_spk, blocktime=self.mine_time, poolid=self.poolid) - input_stream = os.linesep.join([psbt, "true", "ALL"]).encode('utf8') - psbt_signed = json.loads(bcli("-stdin", "walletprocesspsbt", input=input_stream)) - if not psbt_signed.get("complete",False): - logging.debug("Generated PSBT: %s" % (psbt,)) - sys.stderr.write("PSBT signing failed\n") - return None - psbt = decode_challenge_psbt(psbt_signed["psbt"]) - block = get_block_from_psbt(psbt) - signet_solution = get_solution_from_psbt(psbt) + block = new_block(tmpl, reward_spk, blocktime=self.mine_time, poolid=self.poolid) + + signet_spk = tmpl["signet_challenge"] + if trivial_challenge(signet_spk): + signet_solution = None + else: + psbt = generate_psbt(block, signet_spk) + input_stream = os.linesep.join([psbt, "true", "ALL"]).encode('utf8') + psbt_signed = json.loads(bcli("-stdin", "walletprocesspsbt", input=input_stream)) + if not psbt_signed.get("complete",False): + logging.debug("Generated PSBT: %s" % (psbt,)) + sys.stderr.write("PSBT signing failed\n") + return None + psbt = decode_challenge_psbt(psbt_signed["psbt"]) + signet_solution = get_solution_from_psbt(psbt) + return finish_block(block, signet_solution, grind_cmd) def do_generate(args): diff --git a/test/functional/test_framework/blocktools.py b/test/functional/test_framework/blocktools.py index c36fdb5c76e..08f354652f0 100644 --- a/test/functional/test_framework/blocktools.py +++ b/test/functional/test_framework/blocktools.py @@ -80,6 +80,9 @@ DIFF_4_N_BITS = 0x1c3fffc0 DIFF_4_TARGET = int(DIFF_1_TARGET / 4) assert_equal(uint256_from_compact(DIFF_4_N_BITS), DIFF_4_TARGET) +# From BIP325 +SIGNET_HEADER = b"\xec\xc7\xda\xa2" + def nbits_str(nbits): return f"{nbits:08x}" diff --git a/test/functional/tool_signet_miner.py b/test/functional/tool_signet_miner.py index d2c0f6e3abc..0da66a02e88 100755 --- a/test/functional/tool_signet_miner.py +++ b/test/functional/tool_signet_miner.py @@ -10,9 +10,9 @@ import subprocess import sys import time -from test_framework.blocktools import DIFF_1_N_BITS +from test_framework.blocktools import DIFF_1_N_BITS, SIGNET_HEADER from test_framework.key import ECKey -from test_framework.script_util import key_to_p2wpkh_script +from test_framework.script_util import CScript, key_to_p2wpkh_script from test_framework.test_framework import BitcoinTestFramework from test_framework.util import ( assert_equal, @@ -23,31 +23,49 @@ from test_framework.wallet_util import bytes_to_wif CHALLENGE_PRIVATE_KEY = (42).to_bytes(32, 'big') +def get_segwit_commitment(node): + coinbase = node.getblock(node.getbestblockhash(), 2)['tx'][0] + commitment = coinbase['vout'][1]['scriptPubKey']['hex'] + assert_equal(commitment[0:12], '6a24aa21a9ed') + return commitment + +def get_signet_commitment(segwit_commitment): + for el in CScript.fromhex(segwit_commitment): + if isinstance(el, bytes) and el[0:4] == SIGNET_HEADER: + return el[4:].hex() + return None class SignetMinerTest(BitcoinTestFramework): def set_test_params(self): self.chain = "signet" self.setup_clean_chain = True - self.num_nodes = 1 + self.num_nodes = 4 # generate and specify signet challenge (simple p2wpkh script) privkey = ECKey() privkey.set(CHALLENGE_PRIVATE_KEY, True) pubkey = privkey.get_pubkey().get_bytes() challenge = key_to_p2wpkh_script(pubkey) - self.extra_args = [[f'-signetchallenge={challenge.hex()}']] + + self.extra_args = [ + [f'-signetchallenge={challenge.hex()}'], + ["-signetchallenge=51"], # OP_TRUE + ["-signetchallenge=60"], # OP_16 + ["-signetchallenge=202cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"], # sha256("hello") + ] def skip_test_if_missing_module(self): self.skip_if_no_cli() self.skip_if_no_wallet() self.skip_if_no_bitcoin_util() - def run_test(self): - node = self.nodes[0] - # import private key needed for signing block - wallet_importprivkey(node, bytes_to_wif(CHALLENGE_PRIVATE_KEY), "now") + def setup_network(self): + self.setup_nodes() + # Nodes with different signet networks are not connected - # generate block with signet miner tool + # generate block with signet miner tool + def mine_block(self, node): + n_blocks = node.getblockcount() base_dir = self.config["environment"]["SRCDIR"] signet_miner_path = os.path.join(base_dir, "contrib", "signet", "miner") rpc_argv = node.binaries.rpc_argv() + [f"-datadir={node.cli.datadir}"] @@ -63,7 +81,34 @@ class SignetMinerTest(BitcoinTestFramework): f'--set-block-time={int(time.time())}', '--poolnum=99', ], check=True, stderr=subprocess.STDOUT) - assert_equal(node.getblockcount(), 1) + assert_equal(node.getblockcount(), n_blocks + 1) + + def run_test(self): + self.log.info("Signet node with single signature challenge") + node = self.nodes[0] + # import private key needed for signing block + wallet_importprivkey(node, bytes_to_wif(CHALLENGE_PRIVATE_KEY), 0) + self.mine_block(node) + # MUST include signet commitment + assert get_signet_commitment(get_segwit_commitment(node)) + + node = self.nodes[1] + self.log.info("Signet node with trivial challenge (OP_TRUE)") + self.mine_block(node) + # MAY omit signet commitment (BIP 325). Do so for better compatibility + # with signet unaware mining software and hardware. + assert get_signet_commitment(get_segwit_commitment(node)) is None + + node = self.nodes[2] + self.log.info("Signet node with trivial challenge (OP_16)") + self.mine_block(node) + assert get_signet_commitment(get_segwit_commitment(node)) is None + + node = self.nodes[3] + self.log.info("Signet node with trivial challenge (push sha256 hash)") + self.mine_block(node) + assert get_signet_commitment(get_segwit_commitment(node)) is None + if __name__ == "__main__":