Skip to content

Commit 4b4775d

Browse files
committed
fix(checksig-census): match Core push-only stack semantics
1 parent a102b9c commit 4b4775d

2 files changed

Lines changed: 145 additions & 1 deletion

File tree

tools/checksig-census/context.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -559,7 +559,9 @@ def parse_script(script: bytes) -> tuple[ScriptElement, ...]:
559559
opcode = script[offset]
560560
offset += 1
561561
pushed: bytes | None = None
562-
if 1 <= opcode <= 75:
562+
if opcode == 0:
563+
pushed = b""
564+
elif 1 <= opcode <= 75:
563565
if offset + opcode > len(script):
564566
raise ContextError(
565567
f"script push opcode {opcode} truncated at byte {offset}"
@@ -584,6 +586,19 @@ def parse_script(script: bytes) -> tuple[ScriptElement, ...]:
584586
raise ContextError("script OP_PUSHDATA2 payload truncated")
585587
pushed = script[offset : offset + length]
586588
offset += length
589+
elif opcode == 78:
590+
if offset + 4 > len(script):
591+
raise ContextError("script OP_PUSHDATA4 length bytes missing")
592+
length = struct.unpack_from("<I", script, offset)[0]
593+
offset += 4
594+
if offset + length > len(script):
595+
raise ContextError("script OP_PUSHDATA4 payload truncated")
596+
pushed = script[offset : offset + length]
597+
offset += length
598+
elif opcode == 79:
599+
pushed = b"\x81"
600+
elif 81 <= opcode <= 96:
601+
pushed = bytes([opcode - 80])
587602
elements.append(ScriptElement(opcode=opcode, pushed=pushed))
588603
return tuple(elements)
589604

tools/checksig-census/test_validation_contracts.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
classify_input,
7878
iter_context_inputs,
7979
iter_legacy_context_inputs,
80+
parse_script,
8081
read_bounded_context_rows,
8182
)
8283

@@ -1667,6 +1668,29 @@ def test_brsctx1_accepts_valid_file() -> None:
16671668
# ── Tests: classify_input spend-context classification ───────────────────────
16681669

16691670

1671+
def test_classify_input_block_177609_op_0_p2sh() -> None:
1672+
"""The exact block-177609 OP_0 multisig spend classifies as P2SH."""
1673+
evidence = _ctx_input(
1674+
bytes.fromhex(
1675+
"1cc1ecdf5c05765df3d1f59fba24cd01c45464c329b0f0a25aa9883adfcf7f29"
1676+
)[::-1],
1677+
0,
1678+
verify_flags=VERIFY_P2SH,
1679+
prevout=bytes.fromhex("a9145c02c49641699863f909bf4bf3be8398d2e383f187"),
1680+
script_sig=bytes.fromhex(
1681+
"00483045022100beb926da7428fa009ac770576342ebd1960939e73584a5d0"
1682+
"f3229b58c41e906f022017c0d143077906afccf30caf21f5ece0bb30e3f7"
1683+
"08fd4a17f9d9ef9fe7cdc983014751210307ac6296168948c3f64ce22f51"
1684+
"f6e5424f936c846f1d01223b3d9864f4d955662103ac6ad514715bec8d5d"
1685+
"e1873b9bc873bb71773b51338b4d115f9938b6a029b7d152ae"
1686+
),
1687+
)
1688+
1689+
classified = classify_input(evidence)
1690+
1691+
assert classified.spend_context == SpendContext.P2SH
1692+
1693+
16701694
def test_classify_input_bare_p2pkh() -> None:
16711695
"""Bare P2PKH with no flags classifies as BARE."""
16721696
txid_le = b"\x10" * 32
@@ -1838,6 +1862,101 @@ def test_classify_input_taproot_bad_control_block() -> None:
18381862
)
18391863

18401864

1865+
def test_classify_input_p2sh_op_reserved_scriptsig() -> None:
1866+
"""P2SH with OP_RESERVED in scriptSig must raise ContextError."""
1867+
txid_le = b"\x1c" * 32
1868+
redeem = _multisig_redeem_script()
1869+
bad_script_sig = _push(redeem) + bytes([0x50]) # extra OP_RESERVED byte
1870+
evidence = _ctx_input(
1871+
txid_le,
1872+
0,
1873+
verify_flags=VERIFY_P2SH,
1874+
prevout=_p2sh_prevout(),
1875+
script_sig=bad_script_sig,
1876+
)
1877+
_raises(
1878+
ContextError, lambda: classify_input(evidence), "OP_RESERVED P2SH scriptSig"
1879+
)
1880+
1881+
1882+
# ── Tests: parse_script Core stack semantics ─────────────────────────────────
1883+
1884+
1885+
def test_parse_script_op_0_pushes_empty() -> None:
1886+
"""OP_0 must push an empty byte vector, matching Core."""
1887+
elements = parse_script(bytes([0x00]))
1888+
assert len(elements) == 1
1889+
assert elements[0].opcode == 0x00
1890+
assert elements[0].pushed == b""
1891+
1892+
1893+
def test_parse_script_op_1negate_pushes_negative_one() -> None:
1894+
"""OP_1NEGATE must push the single-byte ScriptNum 0x81."""
1895+
elements = parse_script(bytes([0x4F]))
1896+
assert len(elements) == 1
1897+
assert elements[0].opcode == 0x4F
1898+
assert elements[0].pushed == b"\x81"
1899+
1900+
1901+
def test_parse_script_small_integers_pushes_core_scriptnum() -> None:
1902+
"""OP_1..OP_16 must push the single bytes 0x01..0x10."""
1903+
script = bytes(range(0x51, 0x61))
1904+
elements = parse_script(script)
1905+
assert len(elements) == 16
1906+
for i, element in enumerate(elements):
1907+
assert element.opcode == 0x51 + i
1908+
assert element.pushed == bytes([i + 1])
1909+
1910+
1911+
def test_parse_script_pushdata4_success() -> None:
1912+
"""OP_PUSHDATA4 must read a 4-byte little-endian length and payload."""
1913+
payload = b"payload"
1914+
length_le = struct.pack("<I", len(payload))
1915+
script = bytes([0x4E]) + length_le + payload + bytes([0x00])
1916+
elements = parse_script(script)
1917+
assert len(elements) == 2
1918+
assert elements[0].opcode == 0x4E
1919+
assert elements[0].pushed == payload
1920+
assert elements[1].opcode == 0x00
1921+
assert elements[1].pushed == b""
1922+
1923+
1924+
def test_parse_script_pushdata4_truncated_length() -> None:
1925+
"""OP_PUSHDATA4 with fewer than 4 length bytes must fail closed."""
1926+
_raises_with(
1927+
ContextError,
1928+
lambda: parse_script(bytes([0x4E, 0x01])),
1929+
"OP_PUSHDATA4 truncated length",
1930+
"OP_PUSHDATA4 length bytes missing",
1931+
)
1932+
1933+
1934+
def test_parse_script_pushdata4_truncated_payload() -> None:
1935+
"""OP_PUSHDATA4 with a declared payload beyond remaining bytes must fail closed."""
1936+
_raises_with(
1937+
ContextError,
1938+
lambda: parse_script(bytes([0x4E, 0x05, 0x00, 0x00, 0x00])),
1939+
"OP_PUSHDATA4 truncated payload",
1940+
"OP_PUSHDATA4 payload truncated",
1941+
)
1942+
1943+
1944+
def test_parse_script_op_reserved_pushes_none() -> None:
1945+
"""OP_RESERVED is not a data push and must leave pushed as None."""
1946+
elements = parse_script(bytes([0x50]))
1947+
assert len(elements) == 1
1948+
assert elements[0].opcode == 0x50
1949+
assert elements[0].pushed is None
1950+
1951+
1952+
def test_parse_script_op_drop_pushes_none() -> None:
1953+
"""OP_DROP is not a data push and must leave pushed as None."""
1954+
elements = parse_script(bytes([0x75]))
1955+
assert len(elements) == 1
1956+
assert elements[0].opcode == 0x75
1957+
assert elements[0].pushed is None
1958+
1959+
18411960
# ── Tests: classify-corpus txid reversal mutation ────────────────────────────
18421961

18431962

@@ -6992,6 +7111,7 @@ def main() -> int:
69927111
test_brsctx1_rejects_declared_count_mismatch,
69937112
test_brsctx1_rejects_trailing_bytes,
69947113
test_brsctx1_accepts_valid_file,
7114+
test_classify_input_block_177609_op_0_p2sh,
69957115
test_classify_input_bare_p2pkh,
69967116
test_classify_input_p2sh_without_flag,
69977117
test_classify_input_p2sh_with_flag,
@@ -7008,6 +7128,15 @@ def main() -> int:
70087128
test_classify_input_native_v0_with_scriptsig,
70097129
test_classify_input_taproot_bad_key_path_sig,
70107130
test_classify_input_taproot_bad_control_block,
7131+
test_classify_input_p2sh_op_reserved_scriptsig,
7132+
test_parse_script_op_0_pushes_empty,
7133+
test_parse_script_op_1negate_pushes_negative_one,
7134+
test_parse_script_small_integers_pushes_core_scriptnum,
7135+
test_parse_script_pushdata4_success,
7136+
test_parse_script_pushdata4_truncated_length,
7137+
test_parse_script_pushdata4_truncated_payload,
7138+
test_parse_script_op_reserved_pushes_none,
7139+
test_parse_script_op_drop_pushes_none,
70117140
test_classify_corpus_txid_reversal_mutation,
70127141
test_classify_corpus_all_spend_contexts,
70137142
test_classify_corpus_c150_passes,

0 commit comments

Comments
 (0)