From a35e88f81f00ef2ac91b0547990d721d6fbdfbf1 Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Fri, 6 Sep 2024 09:53:57 +0200 Subject: [PATCH 1/3] signet: split decode_psbt miner helper --- contrib/signet/miner | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/contrib/signet/miner b/contrib/signet/miner index e020c4589c1..a0c3e4acbe7 100755 --- a/contrib/signet/miner +++ b/contrib/signet/miner @@ -66,17 +66,21 @@ def signet_txs(block, challenge): return spend, to_spend -def decode_psbt(b64psbt): +def decode_challenge_psbt(b64psbt): psbt = PSBT.from_base64(b64psbt) assert len(psbt.tx.vin) == 1 assert len(psbt.tx.vout) == 1 assert PSBT_SIGNET_BLOCK in psbt.g.map + return psbt +def get_block_from_psbt(psbt): + return from_binary(CBlock, psbt.g.map[PSBT_SIGNET_BLOCK]) + +def get_solution_from_psbt(psbt): scriptSig = psbt.i[0].map.get(PSBT_IN_FINAL_SCRIPTSIG, b"") scriptWitness = psbt.i[0].map.get(PSBT_IN_FINAL_SCRIPTWITNESS, b"\x00") - - return from_binary(CBlock, psbt.g.map[PSBT_SIGNET_BLOCK]), ser_string(scriptSig) + scriptWitness + 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) @@ -179,7 +183,9 @@ def do_genpsbt(args): print(psbt) def do_solvepsbt(args): - block, signet_solution = decode_psbt(sys.stdin.read()) + psbt = decode_challenge_psbt(sys.stdin.read()) + block = get_block_from_psbt(psbt) + signet_solution = get_solution_from_psbt(psbt) block = finish_block(block, signet_solution, args.grind_cmd) print(block.serialize().hex()) @@ -329,7 +335,9 @@ class Generate: logging.debug("Generated PSBT: %s" % (psbt,)) sys.stderr.write("PSBT signing failed\n") return None - block, signet_solution = decode_psbt(psbt_signed["psbt"]) + psbt = decode_challenge_psbt(psbt_signed["psbt"]) + block = get_block_from_psbt(psbt) + signet_solution = get_solution_from_psbt(psbt) return finish_block(block, signet_solution, grind_cmd) def do_generate(args): From c768e1e7f6843d2d779d80b37be60fdcf7b462e3 Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Fri, 13 Sep 2024 10:00:38 +0200 Subject: [PATCH 2/3] signet: miner skips PSBT step for OP_TRUE --- contrib/signet/README.md | 1 + contrib/signet/miner | 73 ++++++++++++++++++++-------- test/functional/tool_signet_miner.py | 58 ++++++++++++++++++---- 3 files changed, 102 insertions(+), 30 deletions(-) diff --git a/contrib/signet/README.md b/contrib/signet/README.md index cd0cae6d509..f759b8b710e 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` the walletprocesspsbt step can be skipped. diff --git a/contrib/signet/miner b/contrib/signet/miner index a0c3e4acbe7..a51f57ba98a 100755 --- a/contrib/signet/miner +++ b/contrib/signet/miner @@ -77,15 +77,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 +102,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)) @@ -128,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() @@ -178,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()) @@ -228,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 + """ + spk = bytes.fromhex(spkhex) + if len(spk) == 1 and spk[0] == 0x51: + # OP_TRUE + 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 @@ -328,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/tool_signet_miner.py b/test/functional/tool_signet_miner.py index 11b6af4e9dd..7f47edb4be1 100755 --- a/test/functional/tool_signet_miner.py +++ b/test/functional/tool_signet_miner.py @@ -12,14 +12,26 @@ import time from test_framework.blocktools import DIFF_1_N_BITS 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 from test_framework.wallet_util import bytes_to_wif CHALLENGE_PRIVATE_KEY = (42).to_bytes(32, 'big') +SIGNET_COMMITMENT = 'ecc7daa2' +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].hex() == SIGNET_COMMITMENT: + return el[4:].hex() + return None class SignetMinerTest(BitcoinTestFramework): def add_options(self, parser): @@ -28,26 +40,32 @@ class SignetMinerTest(BitcoinTestFramework): def set_test_params(self): self.chain = "signet" self.setup_clean_chain = True - self.num_nodes = 1 + self.num_nodes = 3 # 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=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 - node.importprivkey(bytes_to_wif(CHALLENGE_PRIVATE_KEY)) + 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,29 @@ 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 + node.importprivkey(bytes_to_wif(CHALLENGE_PRIVATE_KEY)) + 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 (push sha256 hash)") + self.mine_block(node) + assert get_signet_commitment(get_segwit_commitment(node)) is None + if __name__ == "__main__": From 0b5d7515e06f5b015eea3e7e69992e85cc5aee95 Mon Sep 17 00:00:00 2001 From: Sjors Provoost Date: Mon, 24 Mar 2025 09:23:01 +0100 Subject: [PATCH 3/3] test: signet tool genpsbt and solvepsbt commands Co-authored-by: Anthony Towns --- test/functional/tool_signet_miner.py | 40 ++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/test/functional/tool_signet_miner.py b/test/functional/tool_signet_miner.py index 7f47edb4be1..b872fbdd73d 100755 --- a/test/functional/tool_signet_miner.py +++ b/test/functional/tool_signet_miner.py @@ -4,6 +4,7 @@ # file COPYING or http://www.opensource.org/licenses/mit-license.php. """Test signet miner tool""" +import json import os.path import shlex import subprocess @@ -83,6 +84,38 @@ class SignetMinerTest(BitcoinTestFramework): ], check=True, stderr=subprocess.STDOUT) assert_equal(node.getblockcount(), n_blocks + 1) + # generate block using the signet miner tool genpsbt and solvepsbt commands + def mine_block_manual(self, node, sign): + 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}"] + util_argv = node.binaries.util_argv() + ["grind"] + base_cmd = [ + sys.executable, + signet_miner_path, + f'--cli={shlex.join(rpc_argv)}', + ] + + template = node.getblocktemplate(dict(rules=["signet","segwit"])) + genpsbt = subprocess.run(base_cmd + [ + 'genpsbt', + f'--address={node.getnewaddress()}', + '--poolnum=98', + ], check=True, input=json.dumps(template).encode('utf8'), capture_output=True) + psbt = genpsbt.stdout.decode('utf8').strip() + if sign: + self.log.debug("Sign the PSBT") + res = node.walletprocesspsbt(psbt=psbt, sign=True, sighashtype='ALL') + assert res['complete'] + psbt = res['psbt'] + solvepsbt = subprocess.run(base_cmd + [ + 'solvepsbt', + f'--grind-cmd={shlex.join(util_argv)}', + ], check=True, input=psbt.encode('utf8'), capture_output=True) + node.submitblock(solvepsbt.stdout.decode('utf8').strip()) + assert_equal(node.getblockcount(), n_blocks + 1) + def run_test(self): self.log.info("Signet node with single signature challenge") node = self.nodes[0] @@ -92,6 +125,10 @@ class SignetMinerTest(BitcoinTestFramework): # MUST include signet commitment assert get_signet_commitment(get_segwit_commitment(node)) + self.log.info("Mine manually using genpsbt and solvepsbt") + self.mine_block_manual(node, True) + 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) @@ -104,6 +141,9 @@ class SignetMinerTest(BitcoinTestFramework): self.mine_block(node) assert get_signet_commitment(get_segwit_commitment(node)) is None + self.log.info("Manual mining with a trivial challenge doesn't require a PSBT") + self.mine_block_manual(node, False) + assert get_signet_commitment(get_segwit_commitment(node)) is None if __name__ == "__main__":