diff --git a/.github/workflows/sanitycheck.yml b/.github/workflows/sanitycheck.yml index d5fff70..bdc08aa 100644 --- a/.github/workflows/sanitycheck.yml +++ b/.github/workflows/sanitycheck.yml @@ -27,8 +27,10 @@ jobs: uses: actions/cache/restore@v6 with: path: .cache/prevouts - key: prevouts-v1-${{ hashFiles('blocks/*.bin') }} - restore-keys: prevouts-v1- + key: prevouts-v2-${{ hashFiles('blocks/*.bin') }} + restore-keys: | + prevouts-v2- + prevouts-v1- - name: validate data and test validator run: | python -m venv .venv diff --git a/README.md b/README.md index a09962a..264c8d8 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Prefer immutable evidence URLs. For header rules, observations and full block files are optional. Body failures require a complete `.bin` that demonstrates the named failure. For sigops, CI fetches the referenced previous transactions from public APIs, verifies their transaction IDs, and calculates the cost using their output scripts. +For `already_confirmed_in_parent`, CI checks that a named non-coinbase transaction also appears in the canonical parent, using a txid list authenticated against the parent's header merkle root. The [schema](docs/schema.md#evidence-enforced-by-ci) specifies each rule's evidence contract; observation labels cannot substitute for these checks. Replaying a `.bin` with `bitcoin-cli submitblock` reproduces context-free failures such as 74638's `bad-txns-vout-toolarge`; connect-level failures such as `bad-blk-sigops` need the historical chain context. @@ -54,11 +55,11 @@ It checks JSONL structure, types, ordering, uniqueness, header hash, PoW and dec It enforces the rule/reject-string mapping, required context and rule-specific predicates. Validation reports the first error in each record, with its file and line number, then continues to the next record. Available block files must parse completely and match their transaction merkle roots and applicable witness commitments. -CI checks output-value overflow, forward transaction spends and excessive sigop cost directly. +CI checks output-value overflow, forward transaction spends, excessive sigop cost and transaction reuse from the canonical parent directly. -Sigops and missing-parent checks use a verified cache in `.cache/prevouts/`, restored between GitHub Actions runs. +Sigops, missing-parent and parent-transaction reuse checks use a verified cache in `.cache/prevouts/`, restored between GitHub Actions runs. Missing entries are fetched from public Esplora-compatible APIs when `--fetch-prevouts` is supplied. -API failures, missing evidence and corrupt cached transactions fail validation. +API failures, missing evidence and corrupt cache entries fail validation. After filling the cache, omit the flag for an offline run; `--prevouts-dir` selects another cache and `--api-url` selects an API base. The [schema](docs/schema.md#sigops-evidence-and-public-apis) explains the authentication and counting checks. diff --git a/blocks/507514-000000000000000000571c2b98a090c15774cb7400bd4a50b160d488d14055d0.bin b/blocks/507514-000000000000000000571c2b98a090c15774cb7400bd4a50b160d488d14055d0.bin new file mode 100644 index 0000000..d5692e2 Binary files /dev/null and b/blocks/507514-000000000000000000571c2b98a090c15774cb7400bd4a50b160d488d14055d0.bin differ diff --git a/blocks/509557-00000000000000000027894f0969c2f79a6cdb1231750e048effbd17c88da431.bin b/blocks/509557-00000000000000000027894f0969c2f79a6cdb1231750e048effbd17c88da431.bin new file mode 100644 index 0000000..736a61c Binary files /dev/null and b/blocks/509557-00000000000000000027894f0969c2f79a6cdb1231750e048effbd17c88da431.bin differ diff --git a/blocks/515319-00000000000000000014c1ee89b61a84e3e30dd9b2c78c9916d323a2775bc613.bin b/blocks/515319-00000000000000000014c1ee89b61a84e3e30dd9b2c78c9916d323a2775bc613.bin new file mode 100644 index 0000000..73fcacc Binary files /dev/null and b/blocks/515319-00000000000000000014c1ee89b61a84e3e30dd9b2c78c9916d323a2775bc613.bin differ diff --git a/blocks/534339-0000000000000000001a04286794b25ff10dfdb1bb601b17280dfc1ef933a0ba.bin b/blocks/534339-0000000000000000001a04286794b25ff10dfdb1bb601b17280dfc1ef933a0ba.bin new file mode 100644 index 0000000..b09afbc Binary files /dev/null and b/blocks/534339-0000000000000000001a04286794b25ff10dfdb1bb601b17280dfc1ef933a0ba.bin differ diff --git a/ci/block_evidence.py b/ci/block_evidence.py index 2708afd..5bb4da5 100644 --- a/ci/block_evidence.py +++ b/ci/block_evidence.py @@ -16,7 +16,7 @@ from collections.abc import Mapping, Sequence from typing import TypeVar -from bitcoin.core import CBlock, CoreMainParams, CTransaction, Hash as sha256d, b2lx +from bitcoin.core import CBlock, CoreMainParams, CTransaction, Hash as sha256d, b2lx, lx from bitcoin.core.script import ( CScript, CScriptInvalidError, CScriptOp, OP_1, OP_16, OP_CHECKSIG, OP_CHECKSIGVERIFY, OP_CHECKMULTISIG, OP_CHECKMULTISIGVERIFY, @@ -105,6 +105,16 @@ def confirmed_at_or_after(confirmation: tuple[int, str], height: int, block_hash return confirmed_height >= height and confirmed_hash != block_hash +def reuses_parent_transaction(block: CBlock, parent_txids: Sequence[str], txid: str) -> bool: + """Require the named transaction in both blocks, excluding both coinbases. + + The caller authenticates the ordered parent list and its canonical height. + """ + target = lx(txid) + return (txid in parent_txids[1:] + and any(not tx.is_coinbase() and tx.GetTxid() == target for tx in block.vtx[1:])) + + def establishes_rule(block: CBlock, rule: str) -> bool: """Recognize only failures provable from these committed transactions. diff --git a/ci/prevouts.py b/ci/prevouts.py index ef991b9..3e9dc63 100644 --- a/ci/prevouts.py +++ b/ci/prevouts.py @@ -1,4 +1,4 @@ -"""Fetch previous transactions, parent confirmations and canonical block hashes. +"""Fetch transactions, confirmations, canonical hashes and authenticated parent txid lists. `{txid}.bin` is a stripped transaction, checked against its txid. `{txid}.status.json` is the Esplora status reply for that transaction, and @@ -6,6 +6,8 @@ are decoded and checked like downloads. Failed downloads and corrupt files fail the run, and a reply is stored only after it decodes as evidence, so an unconfirmed status is never cached. +Parent headers are cached as hex in `{blockhash}.header`; their ordered +`{blockhash}.txids.json` lists must reproduce the hash-verified header's merkle root. """ from collections.abc import Callable, Sequence @@ -19,13 +21,14 @@ from typing import TypeVar from urllib.request import Request, urlopen -from bitcoin.core import CTransaction, b2lx +from bitcoin.core import CBlock, CBlockHeader, CTransaction, b2lx, lx from block_evidence import omitted_prevouts, read_transaction DEFAULT_APIS = ("https://mempool.space/api", "https://blockstream.info/api") PREVOUTS_DIR = Path(".cache/prevouts") BLOCK_HASH = re.compile(r"[0-9a-fA-F]{64}") +PARENT_TXIDS_LIMIT = 2 * 1024 * 1024 T = TypeVar("T") @@ -155,9 +158,13 @@ def load_confirmation(txid: str, cache_dir: Path | str = PREVOUTS_DIR, fetch: bo fetch, lambda: _fetch(what, apis, f"tx/{txid}/status", 65_536)) +def _ascii_text(data: bytes) -> str: + return data.decode("ascii", errors="replace").strip() + + def decode_block_hash(data: bytes, height: int) -> str: """Read a block-height reply as a lowercase block hash.""" - text = data.decode("ascii", errors="replace").strip() + text = _ascii_text(data) if not BLOCK_HASH.fullmatch(text): raise ValueError(f"malformed block hash for height {height}") return text.lower() @@ -169,3 +176,45 @@ def load_canonical_hash(height: int, cache_dir: Path | str = PREVOUTS_DIR, fetch what = f"block hash for height {height}" return _cached(what, Path(cache_dir) / f"height-{height}.hash", lambda data: decode_block_hash(data, height), fetch, lambda: _fetch(what, apis, f"block-height/{height}", 256)) + + +def decode_parent_header(data: bytes, block_hash: str) -> CBlockHeader: + """Decode an Esplora hex header and bind it to the requested parent hash.""" + if len(data) > 256: + raise ValueError("oversized parent header") + raw = bytes.fromhex(_ascii_text(data)) + if len(raw) != 80: + raise ValueError("parent header must encode 80 bytes") + header = CBlockHeader.deserialize(raw) + if b2lx(header.GetHash()) != block_hash: + raise ValueError("parent header identity mismatch") + return header + + +def decode_parent_txids(data: bytes, header: CBlockHeader) -> list[str]: + """Authenticate a complete, ordered txid list against the parent merkle root.""" + if len(data) > PARENT_TXIDS_LIMIT: + raise ValueError("oversized parent txid list") + txids = json.loads(data) + if not isinstance(txids, list) or not txids or any( + not isinstance(txid, str) or not BLOCK_HASH.fullmatch(txid) for txid in txids): + raise ValueError("parent txid list must be a nonempty array of 64-hex strings") + txids = [txid.lower() for txid in txids] + # Repeated leaves can preserve a merkle root under Bitcoin's odd-leaf padding. + if len(set(txids)) != len(txids): + raise ValueError("duplicate transaction IDs in parent evidence") + if CBlock.build_merkle_tree_from_txids([lx(txid) for txid in txids])[-1] != header.hashMerkleRoot: + raise ValueError("parent transaction merkle root mismatch") + return txids + + +def load_parent_txids(block_hash: str, cache_dir: Path | str = PREVOUTS_DIR, fetch: bool = False, + apis: Sequence[str] = DEFAULT_APIS) -> list[str]: + """Load a hash-verified parent header and its merkle-authenticated txid list.""" + cache = Path(cache_dir) + header = _cached(f"parent header {block_hash}", cache / f"{block_hash}.header", + lambda data: decode_parent_header(data, block_hash), fetch, + lambda: _fetch(f"parent header {block_hash}", apis, f"block/{block_hash}/header", 256)) + return _cached(f"parent txids {block_hash}", cache / f"{block_hash}.txids.json", + lambda data: decode_parent_txids(data, header), fetch, + lambda: _fetch(f"parent txids {block_hash}", apis, f"block/{block_hash}/txids", PARENT_TXIDS_LIMIT)) diff --git a/ci/sanity-check.py b/ci/sanity-check.py index dc36b4f..0894709 100644 --- a/ci/sanity-check.py +++ b/ci/sanity-check.py @@ -23,10 +23,10 @@ from block_evidence import ( MAX_BLOCK_SIGOPS_COST, confirmed_at_or_after, establishes_rule, omitted_prevouts, - read_block, sigop_cost, verify_witness_commitment, + read_block, reuses_parent_transaction, sigop_cost, verify_witness_commitment, ) from prevouts import ( - DEFAULT_APIS, PREVOUTS_DIR, load_canonical_hash, load_confirmation, load_previous, load_transaction, + DEFAULT_APIS, PREVOUTS_DIR, load_canonical_hash, load_confirmation, load_parent_txids, load_previous, load_transaction, ) DATA_PATH = Path("data/invalid-blocks.jsonl") @@ -34,7 +34,7 @@ REQUIRED = {"height", "hash", "header", "prev_hash", "nTime", "core_reject_reason", "rule"} CONTEXT_FIELDS = { "expected_nbits", "parent_mtp", "coinbase_height", "coinbase_scriptsig_hex", - "pool", "pool_basis", "parent_kind", "missing_prevout", + "pool", "pool_basis", "parent_kind", "missing_prevout", "parent_txid", } OUTPOINT = re.compile(r"[0-9a-f]{64}:(?:0|[1-9][0-9]*)") OBSERVATION_REQUIRED = {"channel", "source", "provenance"} @@ -47,13 +47,16 @@ # Evidence paths: local = header/context only; body = complete block file; # sigops = body plus previous transactions; missing_parent = body plus API -# evidence for the recorded outpoint. Rule names and reject strings must +# evidence for the recorded outpoint; parent_txid_reuse = body plus an +# authenticated canonical parent txid list. Rule names and reject strings must # match docs/schema.md. RULES = { "bad-txns-vout-toolarge": ("bad-txns-vout-toolarge", (), "body"), "bad-blk-sigops": ("bad-blk-sigops", (), "sigops"), "bad-txns-inputs-missingorspent": ("bad-txns-inputs-missingorspent", (), "body"), "missing_unconfirmed_parent": ("bad-txns-inputs-missingorspent", ("missing_prevout",), "missing_parent"), + "already_confirmed_in_parent": ("bad-txns-inputs-missingorspent", + ("parent_txid", "parent_kind", "coinbase_height", "coinbase_scriptsig_hex"), "parent_txid_reuse"), "bip34_v2_coinbase_height_mismatch": ( "bad-cb-height", ("coinbase_height", "coinbase_scriptsig_hex"), "local"), "bip34_coinbase_height_mismatch": ( @@ -170,6 +173,8 @@ def check_context(record: dict[str, Any]) -> None: raise ValueError(f"pool_basis must be one of {sorted(POOL_BASES)}") elif "pool_basis" in details: raise ValueError("pool_basis requires pool") + if "parent_txid" in details: + hex_value(details, "parent_txid", 32) if "missing_prevout" in details and not ( isinstance(details["missing_prevout"], str) and OUTPOINT.fullmatch(details["missing_prevout"])): raise ValueError("missing_prevout must be txid:vout in lowercase hex") @@ -254,6 +259,8 @@ def check_local_evidence(record: dict[str, Any], header: CBlockHeader) -> None: height = record["height"] version = header.nVersion script = bytes.fromhex(context["coinbase_scriptsig_hex"]) if "coinbase_scriptsig_hex" in context else None + if rule == "already_confirmed_in_parent" and context["coinbase_height"] != height: + raise ValueError("already_confirmed_in_parent requires coinbase_height equal to height") if script is not None and "coinbase_height" in context: if script_height(script) != context["coinbase_height"]: raise ValueError("coinbase_height does not match the scriptSig prefix") @@ -283,6 +290,18 @@ def check_local_evidence(record: dict[str, Any], header: CBlockHeader) -> None: raise ValueError("coinbase scriptSig must exceed 100 bytes") +def require_canonical_parent(record: dict[str, Any], prevouts_dir: Path | str, fetch_prevouts: bool, + apis: Sequence[str]) -> str: + """Require prev_hash to be the block the API reports at the previous height.""" + if record.get("context", {}).get("parent_kind", "canonical") != "canonical": + raise ValueError("parent_kind=canonical is required when the previous block is checked against the canonical chain") + previous_height = record["height"] - 1 + canonical = load_canonical_hash(previous_height, prevouts_dir, fetch_prevouts, apis) + if canonical != record["prev_hash"]: + raise ValueError(f"prev_hash is not the canonical block at height {previous_height}") + return canonical + + def check_failure_evidence(record: dict[str, Any], block: CBlock | None, prevouts_dir: Path | str = PREVOUTS_DIR, fetch_prevouts: bool = False, apis: Sequence[str] = DEFAULT_APIS) -> None: """Require a checked failure; observations cannot substitute for bytes.""" @@ -293,14 +312,16 @@ def check_failure_evidence(record: dict[str, Any], block: CBlock | None, prevout raise ValueError(f"{record['rule']} requires a complete block body") if mode == "body" and not establishes_rule(block, record["rule"]): raise ValueError(f"committed body does not demonstrate {record['rule']}") + if mode == "parent_txid_reuse": + require_canonical_parent(record, prevouts_dir, fetch_prevouts, apis) + parent_txids = load_parent_txids(record["prev_hash"], prevouts_dir, fetch_prevouts, apis) + if not reuses_parent_transaction(block, parent_txids, record["context"]["parent_txid"]): + raise ValueError("parent_txid is not a non-coinbase transaction in both the body and its parent") if mode == "missing_parent": txid, vout = record["context"]["missing_prevout"].split(":") if (lx(txid), int(vout)) not in omitted_prevouts(block.vtx): raise ValueError("missing_prevout is not spent by the body from outside the block") - previous_height = record["height"] - 1 - canonical = load_canonical_hash(previous_height, prevouts_dir, fetch_prevouts, apis) - if canonical != record["prev_hash"]: - raise ValueError(f"prev_hash is not the canonical block at height {previous_height}") + canonical = require_canonical_parent(record, prevouts_dir, fetch_prevouts, apis) parent = load_transaction(txid, prevouts_dir, fetch_prevouts, apis) if int(vout) >= len(parent.vout): raise ValueError(f"missing_prevout output {vout} does not exist in the parent transaction") @@ -309,7 +330,7 @@ def check_failure_evidence(record: dict[str, Any], block: CBlock | None, prevout raise ValueError("parent transaction is not confirmed at this height or later in another block") if fetch_prevouts: print(f"{record['height']}: parent {txid} currently confirmed at {confirmation[0]} " - f"in {confirmation[1]}; canonical block at {previous_height} is {canonical}", flush=True) + f"in {confirmation[1]}; canonical parent is {canonical}", flush=True) if mode == "sigops": # This checker uses BIP16 + BIP141 counting, not pre-SegWit rules. if record["height"] < 481824: @@ -397,7 +418,7 @@ def check_dataset(path: Path | str = DATA_PATH, blocks_dir: Path | str = BLOCKS_ def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--fetch-prevouts", action="store_true", - help="fetch missing sigops prevouts, parent confirmations and canonical block hashes from public APIs") + help="fetch missing transactions, confirmations, canonical hashes and parent txid evidence from public APIs") parser.add_argument("--prevouts-dir", type=Path, default=PREVOUTS_DIR, help="verified transaction cache directory") parser.add_argument("--api-url", action="append", help="Esplora API base URL; repeat for fallback providers") args = parser.parse_args() diff --git a/ci/test_block_evidence.py b/ci/test_block_evidence.py index c6eef08..3bc7592 100644 --- a/ci/test_block_evidence.py +++ b/ci/test_block_evidence.py @@ -2,12 +2,12 @@ import unittest -from bitcoin.core import CBlock, COutPoint, CTransaction, CTxIn, CTxOut, CTxWitness, CTxInWitness +from bitcoin.core import CBlock, COutPoint, CTransaction, CTxIn, CTxOut, CTxWitness, CTxInWitness, b2lx from bitcoin.core.script import CScript, CScriptWitness, OP_TRUE from block_evidence import ( MAX_MONEY, confirmed_at_or_after, establishes_rule, omitted_prevouts, read_block, sha256d, - sigop_count, witness_sigops, + reuses_parent_transaction, sigop_count, witness_sigops, ) @@ -82,6 +82,25 @@ def test_invalid_block_serialization(self): with self.subTest(case=case), self.assertRaises(ValueError): read_block(wire) + def test_parent_transaction_reuse_excludes_coinbases_and_absent_transactions(self): + """The named witness must be a non-coinbase transaction present in both blocks.""" + coinbase = transaction() + reused = transaction(prev_hash=b"\x11" * 32, vout=0) + candidate = read_block(block(coinbase, reused)) + txid = b2lx(candidate.vtx[1].GetTxid()) + coinbase_id = b2lx(candidate.vtx[0].GetTxid()) + cases = ( + ("intersection", candidate, ["ab" * 32, txid], txid, True), + ("disjoint", candidate, ["ab" * 32, "cd" * 32], txid, False), + ("body coinbase", candidate, ["ab" * 32, coinbase_id], coinbase_id, False), + ("parent coinbase", candidate, [txid, "ab" * 32], txid, False), + ("absent from body", candidate, ["ab" * 32, "cd" * 32], "cd" * 32, False), + ("coinbase only", read_block(block(coinbase)), ["ab" * 32, txid], txid, False), + ) + for name, body, parent, witness, expected in cases: + with self.subTest(case=name): + self.assertEqual(reuses_parent_transaction(body, parent, witness), expected) + class SigopChecks(unittest.TestCase): def test_legacy_multisig_accurate_count_and_pushed_bytes(self): diff --git a/ci/test_prevouts.py b/ci/test_prevouts.py index a0fd2ba..80cc36e 100644 --- a/ci/test_prevouts.py +++ b/ci/test_prevouts.py @@ -8,8 +8,12 @@ from unittest.mock import patch from urllib.error import URLError +from bitcoin.core import CBlock, CBlockHeader, b2lx from block_evidence import read_transaction, sha256d -from prevouts import decode_previous, fetch_previous, load_canonical_hash, load_confirmation, load_previous +from prevouts import ( + PARENT_TXIDS_LIMIT, decode_parent_header, decode_parent_txids, decode_previous, + fetch_previous, load_canonical_hash, load_confirmation, load_parent_txids, load_previous, +) from test_block_evidence import transaction @@ -88,6 +92,54 @@ def test_malformed_block_hash_is_not_cached(self, sleep): with patch("prevouts.urlopen", return_value=BytesIO(b"AB" * 32)): self.assertEqual(load_canonical_hash(5, self.cache, fetch=True), "ab" * 32) + @patch("prevouts.time.sleep") + def test_parent_txid_cache_authenticates_header_and_merkle_root(self, sleep): + """Download authentic parent evidence, reuse it offline and reject corrupt or absent lists.""" + txids = [b2lx(self.coinbase.GetTxid()), self.txid] + header = CBlockHeader(hashMerkleRoot=CBlock.build_merkle_tree_from_txids( + [self.coinbase.GetTxid(), self.spending.vin[0].prevout.hash])[-1]) + block_hash = b2lx(header.GetHash()) + with patch("prevouts.urlopen", side_effect=[ + BytesIO(header.serialize().hex().encode()), BytesIO(json.dumps(txids).encode())]): + self.assertEqual(load_parent_txids(block_hash, self.cache, True), txids) + with patch("prevouts.urlopen", side_effect=AssertionError("network on cache hit")): + self.assertEqual(load_parent_txids(block_hash, self.cache), txids) + list_path = self.cache / f"{block_hash}.txids.json" + list_path.write_text(json.dumps(list(reversed(txids)))) + with self.assertRaisesRegex(ValueError, "merkle root mismatch"): + load_parent_txids(block_hash, self.cache) + list_path.unlink() + with self.assertRaisesRegex(ValueError, "missing cached parent txids"): + load_parent_txids(block_hash, self.cache) + with patch("prevouts.urlopen", side_effect=URLError("down")): + with self.assertRaisesRegex(ValueError, "could not download parent txids"): + load_parent_txids(block_hash, self.cache, True, ("https://one.example/api",)) + self.assertFalse(list_path.exists()) + with patch("prevouts.urlopen", return_value=BytesIO(json.dumps(txids[::-1]).encode())): + with self.assertRaisesRegex(ValueError, "merkle root mismatch"): + load_parent_txids(block_hash, self.cache, True) + self.assertFalse(list_path.exists()) + + def test_parent_evidence_encoding_and_identity(self): + """Wrong byte order, duplicate leaves and malformed evidence cannot authenticate a parent.""" + header = CBlockHeader(hashMerkleRoot=self.coinbase.GetTxid()) + txid = b2lx(self.coinbase.GetTxid()) + self.assertEqual(decode_parent_txids(json.dumps([txid]).encode(), header), [txid]) + cases = { + "not an array": b'{}', "empty": b'[]', "bad txid": b'["not-a-txid"]', + "duplicate leaves": json.dumps([txid, txid]).encode(), + "wrong byte order": json.dumps([self.coinbase.GetTxid().hex()]).encode(), + "oversized": b' ' * (PARENT_TXIDS_LIMIT + 1), + } + for case, raw in cases.items(): + with self.subTest(case=case), self.assertRaises(ValueError): + decode_parent_txids(raw, header) + for case, raw, identity in ( + ("truncated", header.serialize()[:-1].hex().encode(), b2lx(header.GetHash())), + ("wrong hash", header.serialize().hex().encode(), "00" * 32)): + with self.subTest(case=case), self.assertRaises(ValueError): + decode_parent_header(raw, identity) + if __name__ == "__main__": unittest.main() diff --git a/ci/test_sanity_check.py b/ci/test_sanity_check.py index 068a0d6..cc11794 100644 --- a/ci/test_sanity_check.py +++ b/ci/test_sanity_check.py @@ -183,6 +183,30 @@ def test_body_evidence_is_required(self): self.record.pop("observations", None) self.assertTrue(any("complete block body" in p for p in self.validate())) + def test_parent_reuse_requires_canonical_parent_and_matching_witness(self): + """Reject disjoint evidence, noncanonical parents, malformed witnesses and missing cache.""" + self.record = self.for_rule("already_confirmed_in_parent") + self.copy_body(self.record) + parent = self.record["prev_hash"] + witness = self.record["context"]["parent_txid"] + cases = ( + ("disjoint parent", parent, ["ab" * 32, "cd" * 32], "non-coinbase transaction"), + ("wrong canonical parent", "ab" * 32, ["cd" * 32, witness], "not the canonical block"), + ) + for name, canonical, txids, error in cases: + with self.subTest(case=name), patch.object(CHECK, "load_canonical_hash", return_value=canonical), \ + patch.object(CHECK, "load_parent_txids", return_value=txids): + self.assertTrue(any(error in p for p in self.validate())) + for name, value, error in ( + ("parent_txid", "AB" * 32, "lowercase hex"), + ("parent_kind", "stale", "parent_kind=canonical")): + with self.subTest(field=name), patch.dict(self.record["context"], {name: value}): + self.assertTrue(any(error in p for p in self.validate())) + cache = self.root / "cache" + cache.mkdir() + (cache / f"height-{self.record['height'] - 1}.hash").write_text(parent) + self.assertTrue(any("missing cached parent header" in p for p in self.validate(prevouts_dir=cache))) + def test_body_matches_claimed_evidence(self): """Bind the named failure and supplied coinbase scriptSig to the available body.""" self.record = self.for_rule("bad-txns-vout-toolarge") diff --git a/data/invalid-blocks.jsonl b/data/invalid-blocks.jsonl index 4fed4e6..04fdbc7 100644 --- a/data/invalid-blocks.jsonl +++ b/data/invalid-blocks.jsonl @@ -34,6 +34,10 @@ {"height":422059,"hash":"00000000000000000254ed1e8143f0bcd3c3564db07e7c35631e999d53e81fa7","header":"0000002015b9a5a957588d43fdcbc9c8c8fb5b2d378b74bc54fc1e040000000000000000bb4bf58844f6bec746791e35d4f99e476ec7a2ab9a70b6172087ab8e4b1c0681d27c945769260518385ece24","prev_hash":"0000000000000000041efc54bc748b372d5bfbc8c8c9cbfd438d5857a9a5b915","nTime":1469349074,"core_reject_reason":"bad-cb-height","rule":"bip34_coinbase_height_mismatch","context":{"expected_nbits":"18052669","coinbase_height":422060,"coinbase_scriptsig_hex":"03ac7006162f5669614254432f48656c6c6f2c20576f726c64212f2cfabe6d6d66a8e99ddd64fdd53d2fcba9e0da13d660eb4f3af15c1cc9f4afd712b09729fb01000000000000000cd114e24c6b5af1af8c290400","pool":"ViaBTC","pool_basis":"tag","parent_kind":"stale"},"observations":[{"channel":"merge_mining","source":"merge-mining-research","child_chain":"namecoin","child_height":296773,"provenance":"https://github.com/deadmanoz/merge-mining-research/blob/f543b1f23c57be6840c7a2a44a59306b5d0180f7/data/error-blocks/error_block_observations.csv#L71","child_block_hash":"66a8e99ddd64fdd53d2fcba9e0da13d660eb4f3af15c1cc9f4afd712b09729fb","child_block_time":1469348069,"child_header":"04010100498a5a053ae0b56d2397b69808899b1676cb8cbccca1242ee61249b42242bec388fe0e2260ff96162a9e53c8565ee4d286bd7bb4a045b194cf9a0f7f06bd9f3ae57894572fe20d1800000000"},{"channel":"merge_mining","source":"mergedmonitor","child_chain":"namecoin","provenance":"https://github.com/NStifter/mergedmonitor/blob/54344d4e355f73eb94bef8d391e8fb6e4a9323a6/fork-analysis/mergedmonitor/mergedmonitor.json"},{"channel":"p2p","source":"kit-mon1","first_seen":1469348631,"provenance":"https://github.com/NStifter/mergedmonitor/blob/54344d4e355f73eb94bef8d391e8fb6e4a9323a6/fork-analysis/kit/mon1.json.tar.gz"},{"channel":"p2p","source":"kit-mon2","first_seen":1469348631,"provenance":"https://github.com/NStifter/mergedmonitor/blob/54344d4e355f73eb94bef8d391e8fb6e4a9323a6/fork-analysis/kit/mon2.json.tar.gz"}]} {"height":474294,"hash":"00000000000000000182acdf5657c93a0769dc6f9004047496b2e15efc6a4232","header":"020000203a4b08e0a87bb99712b929dcc7ea45f14c07230c98b4800000000000000000001cd58a6def6a066eb763bff9ff237a098a7a3c19445d12f9d0f392774efae01ead595c59308d0118453bb5d9","prev_hash":"00000000000000000080b4980c23074cf145eac7dc29b91297b97ba8e0084b3a","nTime":1499224493,"core_reject_reason":"bad-txns-inputs-missingorspent","rule":"missing_unconfirmed_parent","context":{"coinbase_height":474294,"coinbase_scriptsig_hex":"03b63c072cfabe6d6dd2ebb1599afbefb038c64430a42ed0057716d5a6f1aa2cd842756f3e1743548d01000000000000002f4e59412f","pool":"1Hash","pool_basis":"reported","parent_kind":"canonical","missing_prevout":"b11a78c6c61af1cb37586f639050d74b95c2b0fd525623b6cb6a4bb4fba46a0e:1"},"observations":[{"channel":"merge_mining","source":"merge-mining-research","child_chain":"namecoin","child_height":349887,"child_block_hash":"d2ebb1599afbefb038c64430a42ed0057716d5a6f1aa2cd842756f3e1743548d","child_block_time":1499224108,"provenance":"https://github.com/deadmanoz/merge-mining-research/blob/f543b1f23c57be6840c7a2a44a59306b5d0180f7/data/child-identity/namecoin_child_identity.csv#L1388"},{"channel":"scrape","source":"chainquery.com","provenance":"https://github.com/NStifter/mergedmonitor/blob/54344d4e355f73eb94bef8d391e8fb6e4a9323a6/fork-analysis/chainquery.com/orphans_chainquery.com.json"}]} {"height":477115,"hash":"0000000000000000013ee4a86822d37a061732e04ee5f41fb77168f193363d1b","header":"12000020257adfb6e7e0b3cd645a9458c6fbc4362eff71878cae3301000000000000000047e7d6f16255b474b896c6bf5c7336c1b570b8de4f2367d126bda61e8007c6d41d217459dc5d0118b5a222ce","prev_hash":"00000000000000000133ae8c8771ff2e36c4fbc658945a64cdb3e0e7b6df7a25","nTime":1500782877,"core_reject_reason":"bad-txns-inputs-missingorspent","rule":"bad-txns-inputs-missingorspent","context":{"coinbase_height":477115,"coinbase_scriptsig_hex":"03bb47072cfabe6d6dab936343b3137ca4569b6c9ef7af36bce731965d82b1b9a221da07786260a5df01000000000000002f4e59412f","parent_kind":"canonical"},"observations":[{"channel":"merge_mining","source":"merge-mining-research","child_chain":"namecoin","child_height":352422,"child_block_hash":"ab936343b3137ca4569b6c9ef7af36bce731965d82b1b9a221da07786260a5df","child_block_time":1500782788,"provenance":"https://github.com/deadmanoz/merge-mining-research/blob/f543b1f23c57be6840c7a2a44a59306b5d0180f7/data/child-identity/namecoin_child_identity.csv#L1390"},{"channel":"scrape","source":"chainquery.com","provenance":"https://github.com/NStifter/mergedmonitor/blob/54344d4e355f73eb94bef8d391e8fb6e4a9323a6/fork-analysis/chainquery.com/orphans_chainquery.com.json"}]} +{"height":507514,"hash":"000000000000000000571c2b98a090c15774cb7400bd4a50b160d488d14055d0","header":"00000020f50ab622900d742ad3fd4dda7e717f7f82266b2ec6da440000000000000000003b5d21477f270ca02877ed92755e978abc7048715c354147269a7b686c310298266d765a46216c17342c1fb0","prev_hash":"00000000000000000044dac62e6b26827f7f717eda4dfdd32a740d9022b60af5","nTime":1517710630,"core_reject_reason":"bad-txns-inputs-missingorspent","rule":"already_confirmed_in_parent","context":{"coinbase_height":507514,"coinbase_scriptsig_hex":"037abe07194d696e656420627920416e74506f6f6c31365d205a766d265fbb02000096020100","pool":"AntPool","pool_basis":"tag","parent_kind":"canonical","parent_txid":"89c67989abbf845fe7e777bbe7329316578155e2fa8a11b07aa867a07d1fb119"},"observations":[{"channel":"scrape","source":"chainquery.com","provenance":"https://github.com/NStifter/mergedmonitor/blob/54344d4e355f73eb94bef8d391e8fb6e4a9323a6/fork-analysis/chainquery.com/orphans_chainquery.com.json"}]} +{"height":509557,"hash":"00000000000000000027894f0969c2f79a6cdb1231750e048effbd17c88da431","header":"0000002099d3c503e14f7fedf3526c5b9de89de8abc41f2014493c000000000000000000bcb9518dacd6d9eff61f526e7732725d4d0a3d39fbae3f464ed0d7c50843664a7ba1875af8e961171a2b1cde","prev_hash":"0000000000000000003c4914201fc4abe89de89d5b6c52f3ed7f4fe103c5d399","nTime":1518838139,"core_reject_reason":"bad-txns-inputs-missingorspent","rule":"already_confirmed_in_parent","context":{"coinbase_height":509557,"coinbase_scriptsig_hex":"0375c6071a4d696e656420627920416e74506f6f6c323943205a87a17b03c6a11300004a870200","pool":"AntPool","pool_basis":"tag","parent_kind":"canonical","parent_txid":"3b7bef682f80fb732b27bded7f96fcf1f974ac3c6ef102cb99894261c1e88921"},"observations":[{"channel":"scrape","source":"chainquery.com","provenance":"https://github.com/NStifter/mergedmonitor/blob/54344d4e355f73eb94bef8d391e8fb6e4a9323a6/fork-analysis/chainquery.com/orphans_chainquery.com.json"}]} +{"height":515319,"hash":"00000000000000000014c1ee89b61a84e3e30dd9b2c78c9916d323a2775bc613","header":"000000205d5392d5c6263d4d73ab195b6a0b617892d2fc72552016000000000000000000a84e072a9b6743b4a9b283accd99b4cd1634841b3aa5724af92e97b684e0db49d49fb95a494a51178f179062","prev_hash":"00000000000000000016205572fcd29278610b6a5b19ab734d3d26c6d592535d","nTime":1522114516,"core_reject_reason":"bad-txns-inputs-missingorspent","rule":"already_confirmed_in_parent","context":{"coinbase_height":515319,"coinbase_scriptsig_hex":"03f7dc07174d696e656420627920416e74506f6f6c6738205ab99fd4fabe6d6dfe891a8b5a014d08c366fa3f35d46ad6e117235cfe9c41b3a03b2f3dc9c399170400000000000000d320000072010100","pool":"AntPool","pool_basis":"tag","parent_kind":"canonical","parent_txid":"7bb574454e472d3e9af6f1087890c6e9b2a7ee38c6f953a517a6e11c54a7247b"},"observations":[{"channel":"scrape","source":"chainquery.com","provenance":"https://github.com/NStifter/mergedmonitor/blob/54344d4e355f73eb94bef8d391e8fb6e4a9323a6/fork-analysis/chainquery.com/orphans_chainquery.com.json"}]} +{"height":534339,"hash":"0000000000000000001a04286794b25ff10dfdb1bb601b17280dfc1ef933a0ba","header":"00000020c90f41f74716a62f11965396e59f8f92aaa8d74314de260000000000000000000ee9f4b00e1a50f0c4865c112e5b9e37fafabd645f6239233d1f4b5ffb1f2f09146d5e5b7b4f2f17ddb18152","prev_hash":"00000000000000000026de1443d7a8aa928f9fe5965396112fa61647f7410fc9","nTime":1532914964,"core_reject_reason":"bad-txns-inputs-missingorspent","rule":"already_confirmed_in_parent","context":{"coinbase_height":534339,"coinbase_scriptsig_hex":"03432708184d696e656420627920416e74506f6f6c383116205b5e6d14880c00008be40200","pool":"AntPool","pool_basis":"tag","parent_kind":"canonical","parent_txid":"b849a66b56aa98f997e93d05455563c591bc8130ba3ac3eafc7e9cfead0fa167"},"observations":[{"channel":"scrape","source":"chainquery.com","provenance":"https://github.com/NStifter/mergedmonitor/blob/54344d4e355f73eb94bef8d391e8fb6e4a9323a6/fork-analysis/chainquery.com/orphans_chainquery.com.json"}]} {"height":543804,"hash":"0000000000000000000c958fe3563e7e3ecda8e35e6d6ecce4d5693cec1f1918","header":"000000a0e1f0be09a3ee109bc463656178dfcc9b5b3f4bdf857b12000000000000000000b61fd2f54e77727b941724900da0695433083c49a4c6a3374ce652f15e3ec92a5623b15b1f5a2717661d5ac5","prev_hash":"000000000000000000127b85df4b3f5b9bccdf78616563c49b10eea309bef0e1","nTime":1538335574,"core_reject_reason":"bad-version","rule":"bip65_block_version_below_4","context":{"expected_nbits":"17275a1f","coinbase_height":543804,"coinbase_scriptsig_hex":"033c4c08045623b15b622f4254432e434f4d2ffabe6d6dee6689d8fa713e1a935e1b03dd664008ac0a74b753d03c527f6d25800e82299a0100000000000000fc00cfe8cc4d000000000000","pool":"BTC.COM","pool_basis":"tag","parent_kind":"canonical"},"observations":[{"channel":"merge_mining","source":"merge-mining-research","child_chain":"namecoin","child_height":419550,"provenance":"https://github.com/deadmanoz/merge-mining-research/blob/f543b1f23c57be6840c7a2a44a59306b5d0180f7/data/error-blocks/error_block_observations.csv#L72","child_block_hash":"ee6689d8fa713e1a935e1b03dd664008ac0a74b753d03c527f6d25800e82299a","child_block_time":1538335419},{"channel":"merge_mining","source":"merge-mining-research","child_chain":"rsk","child_height":789982,"provenance":"https://github.com/deadmanoz/merge-mining-research/blob/f543b1f23c57be6840c7a2a44a59306b5d0180f7/data/error-blocks/error_block_observations.csv#L79","child_block_hash":"7e26d8a3de73a67b4bbe35bfd0763556504ef02ee5eb84e15c1c99024a6a0077","child_block_time":1538335566}]} {"height":544024,"hash":"0000000000000000001ac59b46b44a9e525bc03a5cd2e138a8ae684445fa6ed8","header":"000000e083bcaf0ad9fb201f6f888ee014d8c1e8c317213cb5ba1e000000000000000000fc3d782759e6ade1a1b211bff767cd07d7f33dc0c21f82c8d62359bc3aa2b31ac0fab25b1f5a271719a0fc34","prev_hash":"0000000000000000001ebab53c2117c3e8c1d814e08e886f1f20fbd90aafbc83","nTime":1538456256,"core_reject_reason":"bad-version","rule":"bip65_block_version_below_4","context":{"expected_nbits":"17275a1f","coinbase_height":544024,"coinbase_scriptsig_hex":"03184d0804c9fab25b612f4254432e434f4d2ffabe6d6dcea0fda9be365f0d78b66072ae5cf48ed7cb6338df96d4a951f49bcc9641a6710100000000000000fb0136c12081000000000000","pool":"BTC.COM","pool_basis":"tag","parent_kind":"canonical"},"observations":[{"channel":"merge_mining","source":"merge-mining-research","child_chain":"namecoin","child_height":419759,"provenance":"https://github.com/deadmanoz/merge-mining-research/blob/f543b1f23c57be6840c7a2a44a59306b5d0180f7/data/error-blocks/error_block_observations.csv#L74","child_block_hash":"cea0fda9be365f0d78b66072ae5cf48ed7cb6338df96d4a951f49bcc9641a671","child_block_time":1538455676},{"channel":"merge_mining","source":"merge-mining-research","child_chain":"rsk","child_height":793596,"provenance":"https://github.com/deadmanoz/merge-mining-research/blob/f543b1f23c57be6840c7a2a44a59306b5d0180f7/data/error-blocks/error_block_observations.csv#L81","child_block_hash":"9820cd4d8c71156858576c8fb33c1d26acf4a35f6759be6992d20aa34380854c","child_block_time":1538456260}]} {"height":544024,"hash":"0000000000000000001ffb5988298bfc9f528f9a47e6062ebe8a01177af25d21","header":"000000a083bcaf0ad9fb201f6f888ee014d8c1e8c317213cb5ba1e0000000000000000002ff9d73a3acc053757fc4f126dd404601687113d789ab1706af2ff45e44324d4a8eeb25b1f5a2717a2dcff2c","prev_hash":"0000000000000000001ebab53c2117c3e8c1d814e08e886f1f20fbd90aafbc83","nTime":1538453160,"core_reject_reason":"bad-version","rule":"bip65_block_version_below_4","context":{"expected_nbits":"17275a1f","coinbase_height":544024,"coinbase_scriptsig_hex":"03184d0804a9eeb25b622f4254432e434f4d2ffabe6d6d00b4b5e6ade353899dc765c9ea4101e089f70877b7d314d29fc9b7d0654fcf020100000000000000fb006d12d345000000000000","pool":"BTC.COM","pool_basis":"tag","parent_kind":"canonical"},"observations":[{"channel":"merge_mining","source":"merge-mining-research","child_chain":"namecoin","child_height":419757,"provenance":"https://github.com/deadmanoz/merge-mining-research/blob/f543b1f23c57be6840c7a2a44a59306b5d0180f7/data/error-blocks/error_block_observations.csv#L73","child_block_hash":"00b4b5e6ade353899dc765c9ea4101e089f70877b7d314d29fc9b7d0654fcf02","child_block_time":1538452868},{"channel":"merge_mining","source":"merge-mining-research","child_chain":"rsk","child_height":793505,"provenance":"https://github.com/deadmanoz/merge-mining-research/blob/f543b1f23c57be6840c7a2a44a59306b5d0180f7/data/error-blocks/error_block_observations.csv#L80","child_block_hash":"10c113540401f22983c36bfa6f83165c5b0986589a0653cd4ba41cec337dfac0","child_block_time":1538453154}]} diff --git a/docs/notes.md b/docs/notes.md index 4869727..107fea2 100644 --- a/docs/notes.md +++ b/docs/notes.md @@ -14,6 +14,33 @@ A node that already stores them returns `duplicate`, and only a node that attemp ## Incident notes +### 507514, 509557, 515319 and 534339 - AntPool parent-transaction reuse (2018) + +Each block includes non-coinbase transactions already confirmed in the canonical parent named by its header, so their inputs were already spent and `ConnectBlock` fails with `bad-txns-inputs-missingorspent`. +The dataset calls this `already_confirmed_in_parent`, separately from forward spends and missing unconfirmed parent transactions. + +| Height | Date (UTC) | Transactions reused from parent | Non-coinbase transactions in candidate | +| --- | --- | ---: | ---: | +| [507514](../blocks/507514-000000000000000000571c2b98a090c15774cb7400bd4a50b160d488d14055d0.bin) | 2018-02-04 | 338 | 737 | +| [509557](../blocks/509557-00000000000000000027894f0969c2f79a6cdb1231750e048effbd17c88da431.bin) | 2018-02-17 | 1253 | 2322 | +| [515319](../blocks/515319-00000000000000000014c1ee89b61a84e3e30dd9b2c78c9916d323a2775bc613.bin) | 2018-03-27 | 79 | 79 | +| [534339](../blocks/534339-0000000000000000001a04286794b25ff10dfdb1bb601b17280dfc1ef933a0ba.bin) | 2018-07-30 | 218 | 218 | + +Block 515319 copies all 79 of its parent's non-coinbase transactions in order; 534339 carries a subset in a different order; the other two mix parent transactions with new ones. +All four coinbases carry `Mined by AntPool`, and the pattern suggests a template whose previous-block hash was refreshed while its transaction list was kept, an inference from the bodies rather than a recovered record. +It is the sibling of the 584802 incident, where the transactions were dropped while their fees stayed in the coinbase. + +The pinned chainquery archive holds the hashes in [orphans_chainquery.com.json](https://github.com/NStifter/mergedmonitor/blob/54344d4e355f73eb94bef8d391e8fb6e4a9323a6/fork-analysis/chainquery.com/orphans_chainquery.com.json) and labels their tips `invalid` in [tips.json](https://github.com/NStifter/mergedmonitor/blob/54344d4e355f73eb94bef8d391e8fb6e4a9323a6/fork-analysis/chainquery.com/tips.json); only the former is recorded as a scrape observation, the labels are background. +The bodies reached stale-blocks in [abee96d](https://github.com/bitcoin-data/stale-blocks/commit/abee96d) without a recorded acquisition path, so they establish no further observation. + +CI checks the named txid in each body against the parent's ordered txid list, authenticated by the parent header's merkle root, with a canonical-height lookup binding the parent to height minus one. +All four pass python-bitcoinlib's context-free `CheckBlock` and witness-commitment checks, which do not replay historical `ConnectBlock`. +On 17 September 2026 a Bitcoin Core v31.1.0 node, rewound to each parent with `invalidateblock`, returned `bad-txns-inputs-missingorspent` from `submitblock` for all four. +Core skips BIP30 checks below height 1983702 on the known mainnet chain after BIP34 (see the [guard in validation.cpp](https://github.com/bitcoin/bitcoin/blob/bf8402c8803f085a50df96cb7956033cd252e9ab/src/validation.cpp#L2412)), so the named failure is the reuse of spent inputs, not `bad-txns-BIP30`. + +A 2026-09-15 sweep of all 1086 stale-blocks bodies at [be1e859](https://github.com/bitcoin-data/stale-blocks/commit/be1e8597615c3372aab9ca437a9cd554822b6870) found no other non-coinbase intersection among the 1073 bodies extending canonical parents; 13 extended noncanonical parents and were out of scope. +A negative result does not establish that a block satisfies every consensus rule. + ### 74638 - value overflow (2010) `bad-txns-vout-toolarge` is the 2010 overflow incident ([CVE-2010-5139](https://en.bitcoin.it/wiki/Value_overflow_incident)). diff --git a/docs/schema.md b/docs/schema.md index cac862f..b47be7c 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -6,7 +6,7 @@ Height is `prev + 1`, not a unique key: different blocks at the same height rema A block may enter the dataset only if its header meets its encoded PoW target and its named consensus failure has the evidence required below. Header/context rules use the supplied header and context. -Body rules require a complete block; sigops and missing-parent rules additionally require evidence fetched from public APIs or verified cache entries. +Body rules require a complete block; sigops, missing-parent and parent-transaction reuse rules additionally require evidence fetched from public APIs or verified cache entries. Observations document acquisition and incident history, but an explorer label or reported reject string cannot substitute for the evidence check. JSONL keeps each block's identity, optional context and repeated observations together. @@ -41,12 +41,13 @@ Context is shared across observations; adding another witness does not duplicate | --- | --- | --- | | `expected_nbits` | string | Canonical compact target as eight hex characters. Required for `nbits_retarget_not_applied`. | | `parent_mtp` | integer | Parent median-time-past, the median of eleven block timestamps, in Unix seconds. Required for `time_below_mtp`. | -| `coinbase_height` | integer | Height decoded from the BIP34 scriptSig prefix. Required for a BIP34 height mismatch. | -| `coinbase_scriptsig_hex` | string | Coinbase input scriptSig. Required for BIP34 failures and `coinbase_scriptsig_length_above_100`. | +| `coinbase_height` | integer | Height decoded from the BIP34 scriptSig prefix. Required for a BIP34 height mismatch and `already_confirmed_in_parent`. | +| `coinbase_scriptsig_hex` | string | Coinbase input scriptSig. Required for BIP34 failures, `coinbase_scriptsig_length_above_100` and `already_confirmed_in_parent`. | | `pool` | string | Pool the block is attributed to, when known. Requires `pool_basis`. | | `pool_basis` | string | How the pool was identified: `tag` when the pool name appears as a tag in the coinbase scriptSig, `address` when the coinbase payout address is listed for the pool in [mining-pools](https://github.com/bitcoin-data/mining-pools), or `reported` when only a contemporaneous report names the pool. Required whenever `pool` is present and not allowed otherwise. Descriptive: CI checks the value, not the attribution. | -| `parent_kind` | string | Chain status of the previous block: `canonical`, `stale`, or `invalid`. `invalid` means the previous block is in this dataset. Descriptive and unverified: CI checks the spelling, not the chain. | +| `parent_kind` | string | Chain status of the previous block: `canonical`, `stale`, or `invalid`. `invalid` means the previous block is in this dataset. Descriptive, except that the rules which look up the canonical block at the previous height (`missing_unconfirmed_parent`, `already_confirmed_in_parent`) reject any other value. | | `missing_prevout` | string | Outpoint as `txid:vout`, spent by a non-coinbase input whose transaction is not in the block. Required for `missing_unconfirmed_parent`. | +| `parent_txid` | string | Lowercase 64-hex txid of a non-coinbase transaction in both the candidate and its canonical parent. Required for `already_confirmed_in_parent`. | ## Optional `observations` array @@ -96,6 +97,7 @@ Core functions live in [bitcoin/bitcoin](https://github.com/bitcoin/bitcoin): | `bad-blk-sigops` | `bad-blk-sigops` | `ConnectBlock` | | `bad-txns-inputs-missingorspent` | `bad-txns-inputs-missingorspent` | `ConnectBlock` via `CheckTxInputs` | | `missing_unconfirmed_parent` | `bad-txns-inputs-missingorspent` | `ConnectBlock` via `CheckTxInputs` | +| `already_confirmed_in_parent` | `bad-txns-inputs-missingorspent` | `ConnectBlock` via `CheckTxInputs` | | `bip34_v2_coinbase_height_mismatch` | `bad-cb-height` | `ContextualCheckBlock` | | `bip34_coinbase_height_mismatch` | `bad-cb-height` | `ContextualCheckBlock` | | `bip34_coinbase_height_missing` | `bad-cb-height` | `ContextualCheckBlock` | @@ -123,6 +125,7 @@ A provenance URL cannot bypass these requirements. | `bad-txns-vout-toolarge` | A complete block whose transactions contain an output above 21000000 BTC. | | `bad-txns-inputs-missingorspent` | A complete block containing a spend of an existing output of a later transaction in that block. A spend of a parent transaction absent from the block uses `missing_unconfirmed_parent`. | | `missing_unconfirmed_parent` | A complete block extending the block a public API reports at the previous height. The `missing_prevout` outpoint must be spent by an input in the block and created by a transaction not in the block, and the API must currently report that transaction confirmed in another block at this height or later. Unconfirmed or absent status is not evidence. | +| `already_confirmed_in_parent` | A complete block whose named `parent_txid` is a non-coinbase transaction in both that body and its canonical parent. The parent's ordered txid list must reproduce the merkle root of a hash-verified parent header, and the canonical hash at height minus one must equal `prev_hash`. Require `parent_kind=canonical` and body-derived coinbase fields with `coinbase_height` equal to the record height. | | `bad-blk-sigops` | A complete block at mainnet height 481824 or later, authenticated previous transactions for every external input, and calculated BIP16/BIP141 sigop cost above 80000. | | `bip34_v2_coinbase_height_mismatch` | Both coinbase context fields, decoded height matching the scriptSig, and a scriptSig that lacks the exact expected BIP34 prefix. Header version must be at least 2 and height below 227931. Applicability of the historical rolling-version threshold still requires review. | | `bip34_coinbase_height_mismatch` | Both coinbase context fields, decoded height matching the scriptSig, and a scriptSig that lacks the exact expected BIP34 prefix, at height 227931 or later. A non-minimal encoding of the right number also fails the prefix check. | @@ -142,7 +145,7 @@ At or after mainnet SegWit activation, witness data must match the coinbase witn The validator also checks JSONL structure, field types, decoded Bitcoin header identity, compact target and PoW, ordering, uniqueness and observation fields. Locally checked predicates establish consistency with the supplied context. -Review must still establish the block height, parent MTP, expected difficulty, historical activation state, the chain status claimed in `parent_kind`, and the connection between an extracted coinbase script and its header when no complete body is available. +Review must still establish the block height, parent MTP, expected difficulty, historical activation state, chain status where no canonical lookup is required, and the connection between an extracted coinbase script and its header when no complete body is available. The retarget check does not reconstruct the previous difficulty or prove that it was reused. CI does not execute scripts, validate child-chain commitments, reconstruct historical chain state or fetch observation provenance. It verifies the named failures, not every consensus rule or the exact first rejection a historical node would return. @@ -199,3 +202,27 @@ Offline validation needs all three files in `.cache/prevouts/`. This check does not reconstruct a UTXO set or replay `ConnectBlock`, and it trusts the configured APIs for the confirmation and the canonical hash. Cached confirmation and block-hash entries are snapshots from their first fetch; delete them to re-check. + +## Transactions already confirmed in the parent + +`already_confirmed_in_parent` identifies reuse of a non-coinbase transaction from the canonical previous block. +That transaction has already consumed its inputs, so including it again fails the input availability check. +This differs from an in-block forward spend and from a missing unconfirmed parent transaction; all three share `bad-txns-inputs-missingorspent` as their reject-string family. +The body must contain the named `parent_txid`, and neither the body's coinbase nor the parent's coinbase may serve as the witness. + +`ci/prevouts.py` fetches `/block/{prev_hash}/header` as hex and requires exactly 80 decoded bytes whose header hash equals `prev_hash`. +It fetches `/block/{prev_hash}/txids` as a nonempty JSON array of 64-hex transaction IDs, rejecting duplicates. +Converting the ordered IDs from display byte order and rebuilding their merkle tree must reproduce the header's merkle root. +The first list entry is the canonical parent's coinbase and is excluded from membership checks. +This binds the list contents to the parent without downloading its full body. +The `/block-height/{height-1}` response must also equal `prev_hash`; the canonical-chain assertion is trusted API context, not proved by a header alone. + +The cache stores the header's hex response as `{prev_hash}.header`, the ordered list as `{prev_hash}.txids.json`, and the canonical hash as `height-{height-1}.hash`. +Header downloads are limited to 256 bytes and txid-list downloads to 2 MiB. +Both cached and downloaded evidence undergo the same checks; missing or corrupt evidence fails validation. +`--fetch-prevouts` permits downloads, and a warmed cache supports offline checks. +CI uses a `prevouts-v2-` cache prefix with older `prevouts-v1-` archives retained as a restore fallback, since the new files do not replace the existing evidence formats. +The cache retention and scheduled-workflow limits described above also apply to these files. + +The checker considers only the immediate canonical parent, not older ancestors or the UTXO set, and does not execute scripts or replay historical `ConnectBlock`. +Canonical hashes remain snapshots from their first fetch, as in the missing-parent check.