From b0b77eb378fc0373bbcdf61e2328237eb7d8e192 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 | 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 706b296c54942..d4fae1367468c 100644 --- a/contrib/signet/README.md +++ b/contrib/signet/README.md @@ -81,3 +81,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 c8a92ec611c17..d045fbaabcc9a 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 67fb5c9f94f34..651f0cdb6d670 100755 --- a/test/functional/tool_signet_miner.py +++ b/test/functional/tool_signet_miner.py @@ -10,14 +10,26 @@ import time 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): @@ -26,26 +38,32 @@ def add_options(self, parser): 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") subprocess.run([ @@ -59,7 +77,29 @@ def run_test(self): 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__":