mirror of
https://github.com/bitcoin/bips.git
synced 2026-06-29 17:39:57 +00:00
409 lines
19 KiB
Python
Executable File
409 lines
19 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# Copyright (c) 2026 The Bitcoin Knots developers
|
|
# Distributed under the MIT software license, see the accompanying
|
|
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
|
|
"""Generate and verify BIP-110 (REDUCED_DATA) consensus test vectors.
|
|
|
|
For each BIP-110 rule this test constructs a transaction (or output) that
|
|
exercises the rule's boundary, submits it inside a block to a regtest node
|
|
that is enforcing REDUCED_DATA, and asserts the node accepts/rejects it as
|
|
the BIP specifies. Verified cases are written to a portable JSON file so the
|
|
exact same vectors can be replayed against any other implementation.
|
|
|
|
Coverage:
|
|
Rule 1: output scriptPubKey size (<=34, or OP_RETURN <=83) [per-output]
|
|
Rule 2: push / script-argument witness item size (<=256)
|
|
Rule 3: spending undefined witness / Tapleaf versions
|
|
Rule 4: Taproot annex
|
|
Rule 5: Taproot control block size (<=257)
|
|
Rule 6: OP_SUCCESS* in tapscript
|
|
Rule 7: OP_IF / OP_NOTIF in tapscript
|
|
Grandfathering: inputs spending pre-activation UTXOs are exempt from the
|
|
per-input script rules (2-7); the per-output rule (1) still
|
|
applies to outputs created while active.
|
|
|
|
This is a Bitcoin Core / Knots functional test; it needs the test framework and
|
|
a built bitcoind, so it is not run from the bips repository. To regenerate and
|
|
verify test-vectors.json against the reference implementation:
|
|
|
|
1. Check out the reference implementation
|
|
(https://github.com/dathonohm/bitcoin, branch uasf-modified-bip9) and copy
|
|
this file into its test/functional/ directory.
|
|
2. Configure and build (this also symlinks the test into the build tree):
|
|
cmake -B build && cmake --build build -j"$(nproc)"
|
|
3. Run it from the build tree (which auto-discovers the test config):
|
|
TEST_VECTOR_OUT=/path/to/test-vectors.json python3 build/test/functional/test-vectors.py
|
|
|
|
The generator is deterministic, so a successful run reproduces test-vectors.json
|
|
byte-for-byte while re-verifying every case against the node.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
from io import BytesIO
|
|
|
|
from test_framework.blocktools import (
|
|
COINBASE_MATURITY,
|
|
create_block,
|
|
create_coinbase,
|
|
add_witness_commitment,
|
|
)
|
|
from test_framework.messages import (
|
|
COutPoint,
|
|
CTransaction,
|
|
CTxIn,
|
|
CTxInWitness,
|
|
CTxOut,
|
|
)
|
|
from test_framework.script import (
|
|
CScript,
|
|
OP_1,
|
|
OP_2,
|
|
OP_DROP,
|
|
OP_ENDIF,
|
|
OP_IF,
|
|
OP_RETURN,
|
|
OP_TRUE,
|
|
taproot_construct,
|
|
)
|
|
from test_framework.script_util import script_to_p2wsh_script
|
|
from test_framework.key import generate_privkey, compute_xonly_pubkey
|
|
from test_framework.test_framework import BitcoinTestFramework
|
|
from test_framework.util import assert_equal
|
|
|
|
from test_framework.wallet import MiniWallet, MiniWalletMode
|
|
|
|
REDUCED_DATA_BIT = 4
|
|
VERSIONBITS_TOP_BITS = 0x20000000
|
|
ACTIVATION_HEIGHT = 432 # with min_activation_height=288 (period 3 start)
|
|
|
|
FEE = 1000
|
|
FUND_AMOUNT = 100000
|
|
|
|
|
|
def block_hash(block):
|
|
"""Block hash hex, portable across test-framework versions."""
|
|
h = getattr(block, 'hash_hex', None)
|
|
return h if h is not None else block.hash
|
|
|
|
|
|
def normalize_reject(res):
|
|
"""Make the rejection reason implementation-agnostic.
|
|
|
|
A REDUCED_DATA script-verification failure is reported as
|
|
"mandatory-script-verify-flag-failed (...)" by Bitcoin Knots and as
|
|
"block-script-verify-flag-failed (...)" by the Bitcoin Core port; the
|
|
parenthetical detail is identical. Canonicalize the differing prefix so the
|
|
vectors reproduce identically against either implementation.
|
|
"""
|
|
if res is None:
|
|
return None
|
|
for prefix in ("mandatory-script-verify-flag-failed",
|
|
"block-script-verify-flag-failed"):
|
|
if res.startswith(prefix):
|
|
return "script-verify-flag-failed" + res[len(prefix):]
|
|
return res
|
|
|
|
|
|
def cb(version, info, leaf):
|
|
"""Control block for a taproot leaf."""
|
|
return bytes([leaf.version + info.negflag]) + info.internal_pubkey + leaf.merklebranch
|
|
|
|
|
|
def deepen(item, depth):
|
|
"""Wrap a taproot tree item under `depth` fictitious partner branches so the
|
|
target leaf ends up at the given Merkle depth (control block = 33 + 32*depth)."""
|
|
node = item
|
|
for i in range(depth):
|
|
partner = bytes([0x80 + i]) * 32
|
|
node = [node, (lambda h, p=partner: p)]
|
|
return node
|
|
|
|
|
|
class ReducedDataTestVectors(BitcoinTestFramework):
|
|
def set_test_params(self):
|
|
self.num_nodes = 1
|
|
self.setup_clean_chain = True
|
|
# start=0, timeout=never, min_activation_height=288, max disabled, active_duration permanent
|
|
self.extra_args = [[
|
|
'-vbparams=reduced_data:0:999999999999:288:2147483647:2147483647',
|
|
'-acceptnonstdtxn=1',
|
|
]]
|
|
|
|
# ---- block helpers -------------------------------------------------------
|
|
|
|
def tip_block(self, txs, signal=False):
|
|
node = self.nodes[0]
|
|
tip = node.getbestblockhash()
|
|
height = node.getblockcount() + 1
|
|
block_time = node.getblockheader(tip)['time'] + 1
|
|
block = create_block(int(tip, 16), create_coinbase(height), ntime=block_time, txlist=txs)
|
|
if signal:
|
|
block.nVersion = VERSIONBITS_TOP_BITS | (1 << REDUCED_DATA_BIT)
|
|
add_witness_commitment(block)
|
|
block.solve()
|
|
return block
|
|
|
|
def mine(self, count, signal=False):
|
|
node = self.nodes[0]
|
|
for _ in range(count):
|
|
block = self.tip_block([], signal=signal)
|
|
res = node.submitblock(block.serialize().hex())
|
|
assert res is None, f"submitblock failed: {res}"
|
|
assert_equal(node.getbestblockhash(), block_hash(block))
|
|
|
|
def rd_status(self):
|
|
return self.nodes[0].getdeploymentinfo()['deployments']['reduced_data']['bip9']
|
|
|
|
def activate(self):
|
|
# Mine until ACTIVE, signaling on every block while STARTED so the
|
|
# threshold is met. Activation height is captured dynamically.
|
|
node = self.nodes[0]
|
|
while True:
|
|
info = self.rd_status()
|
|
if info['status'] == 'active':
|
|
break
|
|
self.mine(1, signal=(info['status'] == 'started'))
|
|
self.activation_height = self.rd_status()['since']
|
|
assert node.getblockcount() >= self.activation_height
|
|
self.log.info(f"REDUCED_DATA active since height {self.activation_height}")
|
|
|
|
# ---- funding -------------------------------------------------------------
|
|
|
|
def fund(self, spk):
|
|
"""Create and confirm an output paying `spk`; return (COutPoint, amount, height)."""
|
|
node = self.nodes[0]
|
|
sent = self.wallet.send_to(from_node=node, scriptPubKey=spk, amount=FUND_AMOUNT)
|
|
self.generate(self.wallet, 1)
|
|
txid_int = int(sent['txid'], 16)
|
|
return COutPoint(txid_int, sent['sent_vout']), FUND_AMOUNT, node.getblockcount()
|
|
|
|
def spend(self, outpoint, amount, witness_stack, outputs=None, scriptsig=b''):
|
|
tx = CTransaction()
|
|
tx.vin = [CTxIn(outpoint, scriptsig)]
|
|
if outputs is None:
|
|
outputs = [CTxOut(amount - FEE, CScript([OP_TRUE]))]
|
|
tx.vout = outputs
|
|
tx.wit.vtxinwit.append(CTxInWitness())
|
|
tx.wit.vtxinwit[0].scriptWitness.stack = witness_stack
|
|
return tx
|
|
|
|
# ---- vector recording ----------------------------------------------------
|
|
|
|
def check(self, rule, name, description, spent, tx, expect_valid, spent_age):
|
|
"""Submit `tx` in a block, assert accept/reject, record the vector.
|
|
|
|
The transaction is validated through the node using its real funding
|
|
outpoint, then the recorded copy has its input(s) rewritten to a
|
|
deterministic synthetic outpoint so the vector is self-contained and
|
|
reproducible. None of these spends carry signatures committing to the
|
|
prevout, so the rewrite does not change script validity.
|
|
"""
|
|
node = self.nodes[0]
|
|
block = self.tip_block([tx], signal=False)
|
|
res = node.submitblock(block.serialize().hex())
|
|
accepted = res is None
|
|
if expect_valid:
|
|
assert accepted, f"[{name}] expected VALID, node rejected: {res}"
|
|
assert_equal(node.getbestblockhash(), block_hash(block))
|
|
else:
|
|
assert not accepted, f"[{name}] expected INVALID, node accepted"
|
|
self.log.info(f" rule {rule} [{name}] -> {'valid' if accepted else 'invalid'} "
|
|
f"({'reject: ' + str(res) if res else 'accepted'})")
|
|
|
|
# Rewrite to synthetic, deterministic outpoints for the recorded vector.
|
|
spent_outputs = []
|
|
for j, (spk, amount) in enumerate(spent):
|
|
self._outpoint_counter += 1
|
|
synth = self._outpoint_counter
|
|
tx.vin[j].prevout = COutPoint(synth, 0)
|
|
spent_outputs.append({
|
|
"prevout": {"txid": "%064x" % synth, "vout": 0},
|
|
"scriptPubKey": spk.hex(),
|
|
"amount": amount,
|
|
})
|
|
self.vectors.append({
|
|
"rule": rule,
|
|
"name": name,
|
|
"description": description,
|
|
"reduced_data_active": True,
|
|
"spent_utxo": spent_age,
|
|
"spent_outputs": spent_outputs,
|
|
"tx": tx.serialize().hex(),
|
|
"expected": "valid" if expect_valid else "invalid",
|
|
"reject_reason": normalize_reject(res),
|
|
})
|
|
return accepted
|
|
|
|
# ---- the test ------------------------------------------------------------
|
|
|
|
def run_test(self):
|
|
node = self.nodes[0]
|
|
self.vectors = []
|
|
self._outpoint_counter = 0x1100000000000000000000000000000000000000000000000000000000000000
|
|
self.wallet = MiniWallet(node, mode=MiniWalletMode.RAW_OP_TRUE)
|
|
|
|
self.generate(self.wallet, COINBASE_MATURITY)
|
|
|
|
# Fixed internal key for taproot constructions (deterministic vectors)
|
|
sec = bytes.fromhex("0000000000000000000000000000000000000000000000000000000000000003")
|
|
pub = compute_xonly_pubkey(sec)[0]
|
|
|
|
# P2WSH(OP_TRUE) anyone-can-spend, used for output-size (rule 1) cases
|
|
wsh_true = CScript([OP_TRUE])
|
|
wsh_true_spk = script_to_p2wsh_script(wsh_true)
|
|
# P2WSH(OP_DROP OP_TRUE) lets us inject an arbitrary witness data element (rule 2)
|
|
wsh_drop = CScript([OP_DROP, OP_TRUE])
|
|
wsh_drop_spk = script_to_p2wsh_script(wsh_drop)
|
|
|
|
# Fund "old" UTXOs BEFORE activation so they are grandfathered (exempt
|
|
# from the per-input script rules). Done first, at a low height.
|
|
self.log.info("Funding pre-activation (grandfathered) UTXOs...")
|
|
old_drop_o, old_drop_a, old_drop_h = self.fund(wsh_drop_spk)
|
|
old_true_o, old_true_a, old_true_h = self.fund(wsh_true_spk)
|
|
|
|
self.log.info("Activating REDUCED_DATA...")
|
|
self.activate()
|
|
assert old_drop_h < self.activation_height
|
|
assert old_true_h < self.activation_height
|
|
self.generate(self.wallet, 20) # spendable coins for funding "new" UTXOs
|
|
|
|
self.log.info("=== Rule 1: output scriptPubKey size (per-output) ===")
|
|
# boundary-valid: P2WSH (34 bytes) and 83-byte OP_RETURN
|
|
op = self.fund(wsh_true_spk)[0:2]; o, a = op
|
|
self.check(1, "spk_p2wsh_34_valid", "Create a 34-byte P2WSH output (boundary)",
|
|
[(wsh_true_spk, a)], self.spend(o, a, [bytes(wsh_true)],
|
|
outputs=[CTxOut(a - FEE, wsh_true_spk)]), True, "post-activation")
|
|
o, a = self.fund(wsh_true_spk)[0:2]
|
|
opret83 = bytes([OP_RETURN, 0x4c, 0x50]) + b'\x00' * 80
|
|
self.check(1, "opreturn_83_valid", "Create an 83-byte OP_RETURN output (boundary)",
|
|
[(wsh_true_spk, a)], self.spend(o, a, [bytes(wsh_true)],
|
|
outputs=[CTxOut(a - FEE, CScript(opret83))]), True, "post-activation")
|
|
# invalid: 35-byte non-OP_RETURN spk, and 84-byte OP_RETURN
|
|
o, a = self.fund(wsh_true_spk)[0:2]
|
|
spk35 = CScript(bytes([OP_1]) + b'\x00' * 34)
|
|
self.check(1, "spk_35_nonopreturn_invalid", "Create a 35-byte non-OP_RETURN output (>34)",
|
|
[(wsh_true_spk, a)], self.spend(o, a, [bytes(wsh_true)],
|
|
outputs=[CTxOut(a - FEE, spk35)]), False, "post-activation")
|
|
o, a = self.fund(wsh_true_spk)[0:2]
|
|
opret84 = bytes([OP_RETURN, 0x4c, 0x51]) + b'\x00' * 81
|
|
self.check(1, "opreturn_84_invalid", "Create an 84-byte OP_RETURN output (>83)",
|
|
[(wsh_true_spk, a)], self.spend(o, a, [bytes(wsh_true)],
|
|
outputs=[CTxOut(a - FEE, CScript(opret84))]), False, "post-activation")
|
|
|
|
self.log.info("=== Rule 2: script-argument witness item size ===")
|
|
o, a = self.fund(wsh_drop_spk)[0:2]
|
|
self.check(2, "witness_item_256_valid", "Spend with a 256-byte witness item (boundary)",
|
|
[(wsh_drop_spk, a)], self.spend(o, a, [b'\x42' * 256, bytes(wsh_drop)]),
|
|
True, "post-activation")
|
|
o, a = self.fund(wsh_drop_spk)[0:2]
|
|
self.check(2, "witness_item_257_invalid", "Spend with a 257-byte witness item (>256)",
|
|
[(wsh_drop_spk, a)], self.spend(o, a, [b'\x42' * 257, bytes(wsh_drop)]),
|
|
False, "post-activation")
|
|
|
|
self.log.info("=== Rule 3: undefined witness / Tapleaf versions ===")
|
|
# Undefined witness version v2 (OP_2 <32 bytes>): spending is invalid.
|
|
wv2_spk = CScript([OP_2, b'\x42' * 32])
|
|
o, a = self.fund(wv2_spk)[0:2]
|
|
self.check(3, "spend_witness_v2_invalid", "Spend an undefined witness v2 output",
|
|
[(wv2_spk, a)], self.spend(o, a, [b'']), False, "post-activation")
|
|
# Undefined Tapleaf version (0xc2): spending that leaf is invalid.
|
|
leafv = (b'\x51', 0xc2) # (code=OP_1, version=0xc2)
|
|
infov = taproot_construct(pub, [("u", leafv[0], leafv[1])])
|
|
o, a = self.fund(infov.scriptPubKey)[0:2]
|
|
lv = infov.leaves["u"]
|
|
self.check(3, "spend_tapleaf_v0xc2_invalid", "Spend an undefined (0xc2) Tapleaf version",
|
|
[(bytes(infov.scriptPubKey), a)],
|
|
self.spend(o, a, [lv.script, cb(0xc2, infov, lv)]), False, "post-activation")
|
|
|
|
self.log.info("=== Rule 4: Taproot annex ===")
|
|
info1 = taproot_construct(pub, [("ok", CScript([OP_1]))])
|
|
o, a = self.fund(info1.scriptPubKey)[0:2]
|
|
leaf = info1.leaves["ok"]
|
|
annex = bytes([0x50]) + b'\x00' * 10
|
|
self.check(4, "taproot_annex_invalid", "Spend a taproot script path with an annex",
|
|
[(bytes(info1.scriptPubKey), a)],
|
|
self.spend(o, a, [leaf.script, cb(0xc0, info1, leaf), annex]),
|
|
False, "post-activation")
|
|
|
|
self.log.info("=== Rule 5: Taproot control block size ===")
|
|
info7 = taproot_construct(pub, [deepen(("d7", CScript([OP_1])), 7)])
|
|
o, a = self.fund(info7.scriptPubKey)[0:2]
|
|
l7 = info7.leaves["d7"]
|
|
assert_equal(len(cb(0xc0, info7, l7)), 257)
|
|
self.check(5, "control_block_257_valid", "Spend a leaf at depth 7 (257-byte control block, boundary)",
|
|
[(bytes(info7.scriptPubKey), a)],
|
|
self.spend(o, a, [l7.script, cb(0xc0, info7, l7)]), True, "post-activation")
|
|
info8 = taproot_construct(pub, [deepen(("d8", CScript([OP_1])), 8)])
|
|
o, a = self.fund(info8.scriptPubKey)[0:2]
|
|
l8 = info8.leaves["d8"]
|
|
assert_equal(len(cb(0xc0, info8, l8)), 289)
|
|
self.check(5, "control_block_289_invalid", "Spend a leaf at depth 8 (289-byte control block, >257)",
|
|
[(bytes(info8.scriptPubKey), a)],
|
|
self.spend(o, a, [l8.script, cb(0xc0, info8, l8)]), False, "post-activation")
|
|
|
|
self.log.info("=== Rule 6: OP_SUCCESS* in tapscript ===")
|
|
info_os = taproot_construct(pub, [("os", bytes([0xfe]))]) # OP_SUCCESS254
|
|
o, a = self.fund(info_os.scriptPubKey)[0:2]
|
|
los = info_os.leaves["os"]
|
|
self.check(6, "op_success_invalid", "Spend a tapscript containing OP_SUCCESS254 (0xfe)",
|
|
[(bytes(info_os.scriptPubKey), a)],
|
|
self.spend(o, a, [los.script, cb(0xc0, info_os, los)]), False, "post-activation")
|
|
|
|
self.log.info("=== Rule 7: OP_IF / OP_NOTIF in tapscript ===")
|
|
if_leaf = CScript([OP_1, OP_IF, OP_1, OP_ENDIF])
|
|
info_if = taproot_construct(pub, [("if", if_leaf)])
|
|
o, a = self.fund(info_if.scriptPubKey)[0:2]
|
|
lif = info_if.leaves["if"]
|
|
self.check(7, "tapscript_op_if_invalid", "Spend a tapscript executing OP_IF",
|
|
[(bytes(info_if.scriptPubKey), a)],
|
|
self.spend(o, a, [lif.script, cb(0xc0, info_if, lif)]), False, "post-activation")
|
|
# contrast: OP_IF in a witness v0 (non-tapscript) script is unaffected
|
|
wsh_if = CScript([OP_1, OP_IF, OP_1, OP_ENDIF])
|
|
wsh_if_spk = script_to_p2wsh_script(wsh_if)
|
|
o, a = self.fund(wsh_if_spk)[0:2]
|
|
self.check(7, "witness_v0_op_if_valid", "OP_IF in a witness v0 script is valid (rule is tapscript-only)",
|
|
[(wsh_if_spk, a)], self.spend(o, a, [bytes(wsh_if)]), True, "post-activation")
|
|
|
|
self.log.info("=== Grandfathering: pre-activation UTXOs ===")
|
|
# An input spending a pre-activation UTXO is exempt from the per-input
|
|
# script rules: a 257-byte witness item is accepted here (cf. rule 2).
|
|
self.check(2, "grandfathered_witness_item_257_valid",
|
|
"Spend a PRE-activation UTXO with a 257-byte witness item (grandfathered, exempt)",
|
|
[(wsh_drop_spk, old_drop_a)],
|
|
self.spend(old_drop_o, old_drop_a, [b'\x42' * 257, bytes(wsh_drop)]),
|
|
True, "pre-activation")
|
|
# But the per-output rule 1 still applies even when spending an old UTXO:
|
|
# creating an oversized output is rejected regardless of input age.
|
|
self.check(1, "grandfathered_input_oversized_output_invalid",
|
|
"Spend a PRE-activation UTXO but create a 35-byte output (rule 1 not grandfathered)",
|
|
[(wsh_true_spk, old_true_a)],
|
|
self.spend(old_true_o, old_true_a, [bytes(wsh_true)],
|
|
outputs=[CTxOut(old_true_a - FEE, CScript(bytes([OP_1]) + b'\x00' * 34))]),
|
|
False, "pre-activation")
|
|
|
|
self.write_vectors()
|
|
|
|
def write_vectors(self):
|
|
out = os.environ.get("TEST_VECTOR_OUT")
|
|
if not out:
|
|
self.log.info("TEST_VECTOR_OUT not set; not writing vector file")
|
|
return
|
|
doc = {
|
|
"comment": ("BIP-110 (REDUCED_DATA) consensus test vectors, generated and "
|
|
"verified against the reference implementation. Each vector gives the "
|
|
"spent output(s), the spending/creating transaction, and whether a block "
|
|
"containing it is valid while REDUCED_DATA is active. 'rule' refers to the "
|
|
"numbered rules in the BIP Specification. Hex values are byte arrays. "
|
|
"'reject_reason' is informational and normalized to be implementation-agnostic."),
|
|
"vectors": self.vectors,
|
|
}
|
|
with open(out, "w") as f:
|
|
json.dump(doc, f, indent=2)
|
|
f.write("\n")
|
|
self.log.info(f"Wrote {len(self.vectors)} vectors to {out}")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
ReducedDataTestVectors(__file__).main()
|