From 28bc778332206fcc3160b4ccc7fed661be910e24 Mon Sep 17 00:00:00 2001 From: Mudwood Labs Date: Thu, 3 Sep 2026 22:21:00 -0700 Subject: [PATCH] vendor consensus.h and uint256.h, and check what they define MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pyrxd's Python carries citations into Radiant Core for the rules it re-implements. A citation is only evidence if the cited file is here, and two of the load-bearing ones were not: * `interpreter.cpp` enforces MAX_SCRIPT_STACK_MEMORY_USAGE (128,000,000) and MAX_SCRIPT_OPCODE_COST (1,000,000,000) and declares neither. Their VALUES were uncheckable by anything in the repo. * `transaction_preimage.py` claims an output's refs are hashed ascending by little-endian uint288 value and cites `src/primitives/transaction.h`. That header holds the `std::set`; it does not define the order. The order is `base_blob::Compare` (walks m_data from WIDTH-1 down, so byte[35] is most significant) and `operator<` (`Compare(...) < 0`) in `src/uint256.h`. The claim IS correct — now shown against the C++ rather than asserted. Sorting the raw bytes instead made dMint contract-output signing fail about half the time. Both files are vendored at the pin already in MANIFEST.json (v3.1.2, 45e0aa40) via scripts/refresh_radiant_core_vendor.py, and `--check` reports them identical at v3.1.1, the tag the regtest image's binary is built from, so the source/image gap the README documents still holds. New in the oracle: `consensus_limit` (a separate parser — consensus.h uses `inline constexpr` with expression initialisers over ONE_MEGABYTE, which is 1,000,000 and not 2^20), `script_budgets_enforced_by_interpreter` (derived from EvalScript's own comparisons, so a budget added upstream fails the pin rather than being missed), and the uint288 comparator extractors feeding `uint288_sorted`, which the differential runs against the production `_get_push_refs` on a ref pair whose little- and big-endian orders DISAGREE. Also fixes the digest check itself. It was parametrised over `["script.h", "script.cpp"]` while eight files were vendored — structural about the digest, hand-kept about the set, and so vacuous for everything added after it was written. It now derives its parameters from the manifest (10 files, up from 2), with both other directions closed: a file on disk with no manifest entry, and a manifest entry the refresh script would never re-fetch. Six plants, each restored and confirmed with `git status --porcelain`: 1. one byte changed in consensus.h -> only the [consensus.h] digest case fails (9 others pass), so the new file really is covered 2. same in uint256.h -> only [uint256.h] fails 3. Compare's loop flipped to ascending in uint256.h -> 4 uint288 tests fail, including the differential through production `_get_push_refs` 4. uint64_t(128) -> uint64_t(129) in consensus.h -> the budget pin fails 5. one interpreter.cpp comparison retargeted at MAX_COINBASE_SCRIPTSIG_SIZE -> "no enforced budget is unpinned" fails on a name it was not built from 6. a stray .h dropped in the vendor dir -> the coverage test fails Full offline suite: 11005 passed, 197 skipped, 4 xfailed. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/refresh_radiant_core_vendor.py | 9 + src/pyrxd/transaction/transaction_preimage.py | 16 +- tests/consensus_oracle.py | 240 +++++++++++++++++- tests/test_consensus_opcode_parity.py | 124 ++++++++- tests/test_preimage_differential.py | 93 ++++++- tests/vendor/radiant_core/MANIFEST.json | 10 +- tests/vendor/radiant_core/README.md | 22 +- tests/vendor/radiant_core/consensus.h | 96 +++++++ tests/vendor/radiant_core/uint256.h | 212 ++++++++++++++++ 9 files changed, 806 insertions(+), 16 deletions(-) create mode 100644 tests/vendor/radiant_core/consensus.h create mode 100644 tests/vendor/radiant_core/uint256.h diff --git a/scripts/refresh_radiant_core_vendor.py b/scripts/refresh_radiant_core_vendor.py index aa4b8df6..c8ede921 100644 --- a/scripts/refresh_radiant_core_vendor.py +++ b/scripts/refresh_radiant_core_vendor.py @@ -72,6 +72,15 @@ # which is the only authority on "consensus vs policy". Also the file where # `fRequireStandard = false` is hardcoded, so the policy layer is absent here. "validation.cpp": "src/validation.cpp", + # MAX_SCRIPT_STACK_MEMORY_USAGE and MAX_SCRIPT_OPCODE_COST — the per-script + # resource budgets `interpreter.cpp` enforces (it references both but declares + # neither), so without this file their VALUES are uncheckable. + "consensus.h": "src/consensus/consensus.h", + # base_blob::Compare / operator< — the comparator behind `std::set`, + # and therefore the ref ordering inside hashOutputHashes. `transaction.h` holds + # the sets but not the ordering: sorting refs the wrong way made dMint signing + # fail ~50% of the time, and nothing vendored could adjudicate the rule. + "uint256.h": "src/uint256.h", } diff --git a/src/pyrxd/transaction/transaction_preimage.py b/src/pyrxd/transaction/transaction_preimage.py index b9216a25..9071be8e 100644 --- a/src/pyrxd/transaction/transaction_preimage.py +++ b/src/pyrxd/transaction/transaction_preimage.py @@ -39,15 +39,25 @@ def _get_push_refs(script_bytes: bytes) -> list: consensus collects an output's refs into a ``std::set`` and hashes them in iteration order — i.e. ascending by the *uint288 numeric value* of each 36-byte ref, which is **little-endian** (byte[35] is the - most-significant). See Radiant-Core ``src/primitives/transaction.h`` + most-significant). Two different files, and the distinction matters: + Radiant-Core ``src/primitives/transaction.h`` (``getRefHashDataSummary`` / ``writeOutputDataSummaryVector`` / - ``GetHashOutputHashes``). We therefore sort by ``ref[::-1]`` (the + ``GetHashOutputHashes``) holds the ``std::set`` and hashes it in + iteration order, but the ORDER itself is ``base_blob::Compare`` and + ``operator<`` in ``src/uint256.h:47-63`` and ``:71-73`` — ``Compare`` + walks ``m_data`` from ``WIDTH - 1`` down, so the last byte is the most + significant, and ``operator<`` is ``Compare(...) < 0``, which is what + ``std::less`` and therefore ``std::set`` sort by. ``uint288`` is + ``base_blob<288>``, i.e. 36 bytes (``:178``). We therefore sort by + ``ref[::-1]`` (the fully-reversed bytes), NOT by the raw byte order (which would be big-endian / lexicographic and diverges for any output with 2+ refs — the bug that made dMint contract-output signing fail ~50% of the time on a real node; single-ref outputs are unaffected since order is moot). Validated end-to-end against radiant-core regtest by - ``tests/test_dmint_v1_regtest_e2e.py`` (2-ref contract output). + ``tests/test_dmint_v1_regtest_e2e.py`` (2-ref contract output), and + against the vendored ``uint256.h`` itself by + ``tests/test_preimage_differential.py::TestUint288OrderIsTheOneRadiantUses``. Raises ``ValidationError`` if a pushref opcode is followed by fewer than 36 bytes (truncated script). Earlier versions silently produced diff --git a/tests/consensus_oracle.py b/tests/consensus_oracle.py index ac844757..8814c43d 100644 --- a/tests/consensus_oracle.py +++ b/tests/consensus_oracle.py @@ -7,8 +7,10 @@ constants (``primitives_transaction.h``) and how CSV consumes them (``interpreter.cpp``), the DER signature-encoding rules (``sigencoding.cpp``), and which verification flags are consensus rather than policy -(``script_flags.h``, ``policy.h``, ``validation.cpp``). See the README beside -those files for the full table. +(``script_flags.h``, ``policy.h``, ``validation.cpp``), the per-script resource +budgets ``interpreter.cpp`` enforces but does not declare (``consensus.h``), and +the ``uint288`` comparator that fixes the sighash ref order (``uint256.h``). See +the README beside those files for the full table. The point is that **no consensus fact in here is typed by a human.** Every value is recovered from the C++ that defines it. A hand-maintained Python table of the @@ -29,6 +31,7 @@ import hashlib import json import re +from collections.abc import Iterable from functools import lru_cache from pathlib import Path @@ -618,3 +621,236 @@ def low_s_gate_flag_name() -> str: if not match: raise OracleParseError("could not locate the low-S gate in sigencoding.cpp") return match.group(1) + + +# --------------------------------------------------------------------------- +# Fact 7 — per-script resource budgets (consensus/consensus.h) +# --------------------------------------------------------------------------- + +#: ``inline constexpr NAME = ;`` — the shape every scalar in +#: ``consensus.h`` is declared with. Deliberately a SEPARATE pattern from +#: ``_SCALAR_LIMIT``: ``script.h`` writes ``static const int NAME = ;`` and +#: these are ``inline constexpr`` with an *expression* initialiser, so widening +#: the existing regex to cover both would have made it accept shapes in either +#: file that nobody has read. +_CONSENSUS_SCALAR = r"inline\s+constexpr\s+(?:unsigned\s+)?(?:uint64_t|int)\s+{name}\s*=\s*([^;]+);" + +#: The same declaration shape, name-capturing, for enumerating the header. +_CONSENSUS_SCALAR_ANY = re.compile(r"inline\s+constexpr\s+(?:unsigned\s+)?(?:uint64_t|int)\s+([A-Z][A-Z0-9_]*)\s*=") + + +@lru_cache(maxsize=1) +def _one_megabyte() -> int: + """``ONE_MEGABYTE`` — the unit every size budget in ``consensus.h`` is a multiple of. + + Radiant's is 1,000,000, not 1,048,576. Reading it from the header rather + than assuming a power of two is the point: at 128 units the two readings + differ by 6.3 MB. + """ + src = _strip_comments(vendored_source("consensus.h")) + match = re.search(_CONSENSUS_SCALAR.format(name="ONE_MEGABYTE"), src) + if not match: + raise OracleParseError("could not locate ONE_MEGABYTE in vendored consensus/consensus.h") + text = match.group(1).strip() + if not text.isdigit(): + raise OracleParseError(f"ONE_MEGABYTE is no longer a bare integer literal in consensus.h: {text!r}") + return int(text) + + +def _eval_megabyte_expression(raw: str) -> int: + """Evaluate the tiny expression grammar ``consensus.h``'s scalars use. + + Three shapes appear: a bare literal (``1000000``), a multiple of the unit + (``12 * ONE_MEGABYTE``) and the same with an explicit cast + (``uint64_t(128) * ONE_MEGABYTE``). Anything else raises rather than + guessing — a mis-evaluated budget is a plausible number that pins nothing. + """ + text = re.sub(r"\buint64_t\s*\(\s*(\d+)\s*\)", r"\1", raw.strip()) + if text.isdigit(): + return int(text) + match = re.fullmatch(r"(\d+)\s*\*\s*ONE_MEGABYTE", text) + if match: + return int(match.group(1)) * _one_megabyte() + raise OracleParseError(f"unparsable initialiser in vendored consensus.h: {raw!r}") + + +@lru_cache(maxsize=16) +def consensus_limit(name: str) -> int: + """A scalar budget from ``consensus/consensus.h``, by its C++ name. + + The two that bound a script are ``MAX_SCRIPT_STACK_MEMORY_USAGE`` (peak + bytes held across main stack + altstack) and ``MAX_SCRIPT_OPCODE_COST`` + (cumulative bytes processed by the hashing/bytewise opcodes). + ``interpreter.cpp`` compares against both and declares neither, so until + this header was vendored their values were citable and checkable by nobody. + """ + src = _strip_comments(vendored_source("consensus.h")) + match = re.search(_CONSENSUS_SCALAR.format(name=re.escape(name)), src) + if not match: + raise OracleParseError( + f"{name} is no longer declared as `inline constexpr {name} = ;` in the " + "vendored consensus/consensus.h; re-derive it in tests/consensus_oracle.py." + ) + return _eval_megabyte_expression(match.group(1)) + + +@lru_cache(maxsize=1) +def consensus_scalar_names() -> frozenset[str]: + """Every scalar ``consensus/consensus.h`` declares.""" + names = frozenset(_CONSENSUS_SCALAR_ANY.findall(_strip_comments(vendored_source("consensus.h")))) + if "ONE_MEGABYTE" not in names: + raise OracleParseError(f"implausible consensus.h scalar table parsed ({sorted(names)})") + return names + + +@lru_cache(maxsize=1) +def script_budgets_enforced_by_interpreter() -> frozenset[str]: + """``consensus.h`` budgets that ``interpreter.cpp`` actually compares against. + + Recovered from the comparisons themselves rather than from a list of names, + so a budget added upstream and wired into ``EvalScript`` shows up here with + nobody editing anything — and the test requiring every enforced budget to be + pinned starts failing, which is the intended alarm. + + Intersected with what ``consensus.h`` declares, so limits belonging to other + headers (``MAX_SCRIPT_ELEMENT_SIZE`` is ``script.h``'s) cannot leak in. + """ + compared = frozenset(re.findall(r">\s*(MAX_[A-Z0-9_]+)", _strip_comments(vendored_source("interpreter.cpp")))) + names = compared & consensus_scalar_names() + if not names: + raise OracleParseError( + "no consensus.h budget appears in a comparison in the vendored interpreter.cpp — the " + "extractor found nothing, which would make the budget differential vacuous." + ) + return names + + +# --------------------------------------------------------------------------- +# Fact 8 — the uint288 ordering behind the sighash ref sort (uint256.h) +# --------------------------------------------------------------------------- +# +# Radiant collects an output's refs into a ``std::set`` and hashes them +# in ITERATION order (``primitives_transaction.h``, ``getRefHashDataSummary``). +# That header holds the set; it does not define the order. The order lives in +# ``base_blob::Compare`` and ``operator<``, which ``std::less`` — and so +# ``std::set`` — sorts by. Getting it wrong is not cosmetic: sorting the 36 raw +# bytes lexicographically rather than as a little-endian integer produced a +# ``hashOutputHashes`` the node disagreed with, and made dMint contract-output +# signing fail about half the time. + + +def _base_blob_compare_body() -> str: + src = _strip_comments(vendored_source("uint256.h")) + start = src.find("int Compare(const base_blob") + if start < 0: + raise OracleParseError("could not locate base_blob::Compare in vendored uint256.h") + end = src.find("friend ", start) + if end < 0: + raise OracleParseError("could not delimit the body of base_blob::Compare in vendored uint256.h") + return src[start:end] + + +@lru_cache(maxsize=1) +def uint288_width_bytes() -> int: + """``uint288``'s byte width, as ``base_blob`` computes it from ``BITS``. + + Read as ``BITS / 8`` from the two places that define it rather than as the + literal 36, so a re-parameterisation upstream fails the parse instead of + silently keeping a stale width. + """ + src = _strip_comments(vendored_source("uint256.h")) + blob = re.search(r"class\s+uint288\s*:\s*public\s+base_blob<\s*(\d+)\s*>", src) + if not blob: + raise OracleParseError("could not locate `class uint288 : public base_blob` in vendored uint256.h") + width = re.search(r"static\s+constexpr\s+unsigned\s+WIDTH\s*=\s*BITS\s*/\s*(\d+)\s*;", src) + if not width: + raise OracleParseError("base_blob no longer defines WIDTH as `BITS / ` in vendored uint256.h") + bits, divisor = int(blob.group(1)), int(width.group(1)) + if divisor == 0 or bits % divisor: + raise OracleParseError(f"uint288's base_blob<{bits}> is not divisible by {divisor}") + return bits // divisor + + +@lru_cache(maxsize=1) +def base_blob_significance_order() -> str: + """``"little"`` or ``"big"`` — which end of ``m_data`` ``Compare`` reads as most significant. + + ``Compare`` walks one index at a time and returns on the first differing + byte, so the index it starts from IS the byte order. Both directions are + recognised and exactly one must match, which is what stops a rewrite + upstream from being mistaken for "unchanged". + """ + body = _base_blob_compare_body() + if not re.search(r"const\s+uint8_t\s+a\s*=\s*m_data\[i\]\s*;", body) or not re.search( + r"const\s+uint8_t\s+b\s*=\s*other\.m_data\[i\]\s*;", body + ): + raise OracleParseError("base_blob::Compare no longer compares `m_data[i]` against `other.m_data[i]`") + if not re.search(r"if\s*\(\s*a\s*>\s*b\s*\)\s*\{?\s*return\s+1\s*;", body) or not re.search( + r"if\s*\(\s*a\s*<\s*b\s*\)\s*\{?\s*return\s+-1\s*;", body + ): + raise OracleParseError("base_blob::Compare no longer returns +1/-1 for the greater/lesser byte") + + from_the_top = bool( + re.search(r"unsigned\s+i\s*=\s*WIDTH\s*-\s*1\s*;", body) and re.search(r"while\s*\(\s*i--\s*!=\s*0\s*\)", body) + ) + from_the_bottom = bool( + re.search(r"unsigned\s+i\s*=\s*0\s*;", body) and re.search(r"while\s*\(\s*\+\+i\s*<\s*WIDTH\s*\)", body) + ) + if from_the_top == from_the_bottom: + raise OracleParseError( + "base_blob::Compare no longer walks m_data in a recognised direction — it must start at " + "WIDTH-1 and count down (little-endian) or at 0 and count up (big-endian). Re-derive the " + "ref sort order in tests/consensus_oracle.py before trusting any sighash built on it." + ) + return "little" if from_the_top else "big" + + +@lru_cache(maxsize=1) +def base_blob_less_than_sense() -> str: + """The operator ``base_blob::operator<`` applies to ``Compare``'s result — ``"<"``. + + ``std::set`` orders with ``std::less``, i.e. ``operator<``, so this is the + step that turns "Compare says a is smaller" into "a is hashed first". A flip + here would reverse the sighash ref order without touching ``Compare`` at all. + """ + src = _strip_comments(vendored_source("uint256.h")) + match = re.search( + r"operator<\s*\(\s*const\s+base_blob\s*&\s*a\s*,\s*const\s+base_blob\s*&\s*b\s*\)" + r"[^{]*\{\s*return\s+a\.Compare\(b\)\s*(<=?|>=?)\s*0\s*;", + src, + ) + if not match: + raise OracleParseError( + "base_blob::operator< is no longer defined as `return a.Compare(b) 0;` in vendored uint256.h" + ) + return match.group(1) + + +def uint288_sorted(refs: Iterable[bytes]) -> list[bytes]: + """Order 36-byte refs the way ``std::set`` iterates them. + + Assembled from the parsed C++ — the byte examined first comes from + ``Compare``'s loop direction, the ascending/descending sense from + ``operator<`` — so if either flips upstream this flips with it and the + differential against ``transaction_preimage._get_push_refs`` fails. + """ + width = uint288_width_bytes() + items = list(refs) + wrong = sorted({len(r) for r in items if len(r) != width}) + if wrong: + raise ValueError(f"uint288 is {width} bytes; got refs of length {wrong}") + + sense = base_blob_less_than_sense() + if sense == "<": + reverse = False + elif sense == ">": + reverse = True + else: + raise OracleParseError( + f"base_blob::operator< compares Compare()'s result with {sense!r} 0, which is not a strict " + "ordering; std::set iteration order cannot be derived from it." + ) + + if base_blob_significance_order() == "little": + return sorted(items, key=lambda r: r[::-1], reverse=reverse) + return sorted(items, reverse=reverse) diff --git a/tests/test_consensus_opcode_parity.py b/tests/test_consensus_opcode_parity.py index 659ff38d..eb9b701a 100644 --- a/tests/test_consensus_opcode_parity.py +++ b/tests/test_consensus_opcode_parity.py @@ -31,6 +31,9 @@ from __future__ import annotations +import importlib.util +from pathlib import Path + import pytest import pyrxd.constants as pyrxd_constants @@ -43,6 +46,8 @@ OpCode, ) from tests.consensus_oracle import ( + VENDOR_DIR, + consensus_limit, manifest, max_opcode, opcode_table, @@ -52,12 +57,16 @@ ref_operand_opcode_names, ref_operand_opcodes, ref_operand_width, + script_budgets_enforced_by_interpreter, script_limit, vendored_digest, ) pytestmark = pytest.mark.unit +_REPO_ROOT = Path(__file__).resolve().parents[1] +_REFRESH_SCRIPT = _REPO_ROOT / "scripts" / "refresh_radiant_core_vendor.py" + # Enumerators in `enum opcodetype` that are not opcodes: a sentinel used to # define MAX_OPCODE, and a value the header itself annotates "Not a real @@ -89,12 +98,22 @@ def _upstream_opcodes() -> dict[str, int]: # The oracle itself must be trustworthy before anything is asserted against it # --------------------------------------------------------------------------- +#: Every vendored file, taken from the manifest instead of hand-listed. This was +#: spelled ``["script.h", "script.cpp"]`` while eight files were vendored, so the +#: digest check — structurally correct — ran over a quarter of its subject and +#: passed vacuously for everything added after it was written. Deriving the set +#: from the manifest makes "vendored" and "digest-checked" the same list by +#: construction; ``test_every_vendored_file_is_digest_checked`` closes the other +#: direction, and asserts non-emptiness so a parametrisation over nothing cannot +#: report green. +_VENDORED_FILES = sorted(manifest()["files"]) + class TestOracleIntegrity: """Guard the oracle. Each of these failing means the differentials below are meaningless, so they must fail loudly rather than degrade.""" - @pytest.mark.parametrize("name", ["script.h", "script.cpp"]) + @pytest.mark.parametrize("name", _VENDORED_FILES) def test_vendored_sources_match_manifest_digest(self, name): expected = manifest()["files"][name]["sha256"] assert vendored_digest(name) == expected, ( @@ -104,6 +123,47 @@ def test_vendored_sources_match_manifest_digest(self, name): f"scripts/refresh_radiant_core_vendor.py to update them and the manifest together." ) + def test_every_vendored_file_is_digest_checked(self): + """Both directions, so neither half can drift out of the other's sight. + + A source in the vendor directory with no manifest entry is an oracle + input nothing digests; a manifest entry with no file on disk is a check + that has silently stopped running. Either way the failure is invisible + in the output of the test above, which only ever reports on the names it + was handed. + """ + assert _VENDORED_FILES, "MANIFEST.json lists no files — the digest parametrisation would be empty" + on_disk = {p.name for p in VENDOR_DIR.iterdir() if p.suffix in {".h", ".cpp"}} + assert on_disk == set(_VENDORED_FILES), ( + "the vendored sources and MANIFEST.json disagree about what is vendored.\n" + f" on disk, not in the manifest: {sorted(on_disk - set(_VENDORED_FILES))}\n" + f" in the manifest, not on disk: {sorted(set(_VENDORED_FILES) - on_disk)}\n" + "Re-run scripts/refresh_radiant_core_vendor.py so the two are written together." + ) + + def test_the_refresh_script_covers_every_vendored_file(self): + """The third list that has to agree: what ``--check`` actually re-fetches. + + A file added to the manifest but not to the script's ``FILES`` map is + digest-checked locally and never compared against upstream again, so it + would go stale in exactly the silent way vendoring exists to prevent. + """ + spec = importlib.util.spec_from_file_location("_refresh_radiant_core_vendor", _REFRESH_SCRIPT) + assert spec and spec.loader, f"could not load {_REFRESH_SCRIPT}" + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + assert set(module.FILES) == set(_VENDORED_FILES), ( + "scripts/refresh_radiant_core_vendor.py and MANIFEST.json disagree about what is vendored.\n" + f" in the script, not in the manifest: {sorted(set(module.FILES) - set(_VENDORED_FILES))}\n" + f" in the manifest, not in the script: {sorted(set(_VENDORED_FILES) - set(module.FILES))}" + ) + upstream = {name: manifest()["files"][name]["upstream_path"] for name in _VENDORED_FILES} + assert upstream == module.FILES, ( + "the script and the manifest disagree about where a vendored file comes from upstream; " + "a refresh would overwrite it from a different path than the one recorded as its provenance." + ) + def test_opcode_table_parsed_plausibly(self): table = opcode_table() assert len(table) > 150, f"only {len(table)} enumerators parsed from `enum opcodetype`" @@ -300,3 +360,65 @@ def test_the_radiant_push_limit_is_not_bitcoins(self): """ assert script_limit("MAX_SCRIPT_ELEMENT_SIZE") == 32_000_000 assert script_limit("MAX_SCRIPT_ELEMENT_SIZE_LEGACY") == 520 + + +#: The per-script resource budgets, and the values ``consensus.h`` gives them. +#: Pinned as literals on purpose: this is the one fact in this file with no +#: pyrxd-side constant to compare against (pyrxd builds and parses scripts, it +#: has no interpreter), so the assertion's whole job is to turn an upstream +#: change into a reviewable diff rather than a silent one. ``ONE_MEGABYTE`` is +#: 1,000,000 here, not 1,048,576 — reading the budget as 2^20-based overstates +#: the memory ceiling by 6.3 MB. +_PINNED_SCRIPT_BUDGETS = { + "MAX_SCRIPT_STACK_MEMORY_USAGE": 128_000_000, + "MAX_SCRIPT_OPCODE_COST": 1_000_000_000, +} + + +class TestScriptResourceBudgets: + """The budgets ``interpreter.cpp`` enforces and ``consensus/consensus.h`` declares. + + Both were cited in pyrxd comments while the header that defines them was not + vendored, so their VALUES could not be checked by anything — the same shape + as the ref-operand rule: a consensus number in the repo with no mechanism + tying it to the C++. + """ + + @pytest.mark.parametrize(("name", "expected"), sorted(_PINNED_SCRIPT_BUDGETS.items())) + def test_budget_matches_radiant(self, name, expected): + upstream = consensus_limit(name) + assert upstream == expected, ( + f"Radiant's consensus.h declares {name}={upstream:,}; this test pins {expected:,}. " + "A budget moving upstream is a consensus change — read the diff before re-pinning." + ) + + def test_no_enforced_budget_is_unpinned(self): + """The set comes from ``interpreter.cpp``'s comparisons, not from a list. + + Both directions: a budget the interpreter enforces and nothing here pins + is the gap this test exists for, and a name pinned above that the + interpreter never compares against means the pin has stopped describing + anything. + """ + enforced = script_budgets_enforced_by_interpreter() + assert enforced == set(_PINNED_SCRIPT_BUDGETS), ( + "the consensus.h budgets EvalScript compares against and the ones pinned here differ.\n" + f" enforced, not pinned: {sorted(enforced - set(_PINNED_SCRIPT_BUDGETS))}\n" + f" pinned, not enforced: {sorted(set(_PINNED_SCRIPT_BUDGETS) - enforced)}" + ) + + def test_the_memory_budget_admits_a_maximum_size_push(self): + """Cross-file consistency, ``consensus.h`` against ``script.h``. + + ``MAX_SCRIPT_ELEMENT_SIZE`` is what a builder may push; the stack-memory + budget is what the interpreter will hold. A budget at or below the push + limit would make a single legal push unspendable, so the two limits + being read from the same pin and still disagreeing is a fact worth + failing on rather than a coincidence worth assuming. + """ + budget = consensus_limit("MAX_SCRIPT_STACK_MEMORY_USAGE") + element = script_limit("MAX_SCRIPT_ELEMENT_SIZE") + assert budget > element, ( + f"the stack-memory budget ({budget:,}) does not exceed the maximum pushable element " + f"({element:,}); a single maximum-size push would exhaust it." + ) diff --git a/tests/test_preimage_differential.py b/tests/test_preimage_differential.py index 39b48439..fdd2669f 100644 --- a/tests/test_preimage_differential.py +++ b/tests/test_preimage_differential.py @@ -23,9 +23,13 @@ * Refs sort ascending by the **uint288 numeric value** of the 36-byte ref, which is little-endian (byte[35] most significant), and are **deduplicated** — Radiant collects them into a ``std::set``. - The reference sorts *integers* (``int.from_bytes(ref, "little")``), - deliberately a different derivation than the production code's byte-wise - sort key, so a sort-order regression cannot hide in shared code. + The set is in ``primitives/transaction.h``; the *ordering* is + ``base_blob::Compare`` and ``operator<`` in ``src/uint256.h``, which + ``std::less`` sorts by. The reference sorts *integers* + (``int.from_bytes(ref, "little")``), deliberately a different derivation + than the production code's byte-wise sort key, so a sort-order regression + cannot hide in shared code. Both are checked against the C++ itself by + ``TestUint288OrderIsTheOneRadiantUses``. The reference is itself anchored to the radiantjs-generated golden vectors committed in ``tests/test_preimage.py`` (verified against mainnet reveal tx @@ -48,8 +52,16 @@ from pyrxd.script.script import Script from pyrxd.transaction.transaction_input import TransactionInput from pyrxd.transaction.transaction_output import TransactionOutput -from pyrxd.transaction.transaction_preimage import tx_preimage, tx_preimages -from tests.consensus_oracle import push_ref_opcodes, ref_operand_opcodes, ref_operand_width +from pyrxd.transaction.transaction_preimage import _get_push_refs, tx_preimage, tx_preimages +from tests.consensus_oracle import ( + base_blob_less_than_sense, + base_blob_significance_order, + push_ref_opcodes, + ref_operand_opcodes, + ref_operand_width, + uint288_sorted, + uint288_width_bytes, +) _BUDGET_MULT = int(os.environ.get("FUZZ_BUDGET_MULTIPLIER", "1")) @@ -474,3 +486,74 @@ def test_reference_rules_come_from_the_vendored_cpp(self): assert {0xD0, 0xD1, 0xD2, 0xD3, 0xD8} == _OPERAND_OPS assert {0xD0, 0xD8} == _PUSHREF_OPS assert _OPERAND_WIDTH == 36 + + +#: Two refs whose little-endian and big-endian orderings DISAGREE — the byte +#: that decides it (index 35) points one way and the leading bytes the other. +#: A fixture where the two orders happen to coincide passes against a +#: big-endian sort as readily as a little-endian one and proves nothing, which +#: is precisely how the original bug survived single-ref outputs. +_REF_LOW_LE = bytes([0xFF] * 35 + [0x01]) # smaller as uint288, larger lexicographically +_REF_HIGH_LE = bytes([0x00] * 35 + [0x02]) # larger as uint288, smaller lexicographically + + +class TestUint288OrderIsTheOneRadiantUses: + """The sighash ref order, checked against the C++ that defines it. + + ``transaction_preimage`` claims the refs in an output are hashed "ascending + by the uint288 numeric value ... which is little-endian (byte[35] is the + most-significant)" and sorts on ``ref[::-1]`` accordingly. That claim was + cited to ``src/primitives/transaction.h``, which holds the + ``std::set`` but does **not** define its order — so for as long as + ``uint256.h`` was unvendored, the rule behind a sighash was an assertion + nothing in the repo could adjudicate. It is load-bearing: sorting the raw + bytes instead made dMint contract-output signing fail about half the time. + """ + + def test_uint288_is_thirty_six_bytes(self): + assert uint288_width_bytes() == _OPERAND_WIDTH == 36 + + def test_the_comparator_reads_the_last_byte_as_most_significant(self): + """``base_blob::Compare`` starts at ``WIDTH - 1`` and counts down.""" + assert base_blob_significance_order() == "little", ( + "Radiant's base_blob::Compare no longer treats m_data's high index as the most " + "significant byte. Every ref sort in this repo — production and reference — is " + "written for little-endian and must be re-derived before it can be trusted." + ) + + def test_the_ordering_is_ascending(self): + """``std::set`` iterates by ``std::less``, i.e. ``operator<``.""" + assert base_blob_less_than_sense() == "<", ( + "base_blob::operator< no longer means `Compare(...) < 0`; std::set would " + "iterate in the opposite order and hashOutputHashes with it." + ) + + def test_the_fixture_actually_discriminates(self): + """Non-vacuity: the two orderings must disagree on these refs. + + Without this, the differential below could pass because both sorts are + right *or* because the inputs cannot tell them apart. + """ + assert uint288_sorted([_REF_HIGH_LE, _REF_LOW_LE]) != sorted([_REF_HIGH_LE, _REF_LOW_LE]) + + def test_production_sorts_refs_the_way_the_cpp_orders_them(self): + """The differential, through the production walker that builds the sighash. + + ``_get_push_refs`` is what ``tx_preimage`` calls; the expected order is + assembled from the parsed comparator rather than restated here, so if + upstream flips either the loop direction or the comparison sense this + fails instead of agreeing with a stale rule. + """ + script = ( + bytes([0xD0]) + _REF_HIGH_LE + bytes([0xD8]) + _REF_LOW_LE + bytes([0xD0]) + _REF_HIGH_LE # duplicate + ) + assert _get_push_refs(script) == uint288_sorted({_REF_HIGH_LE, _REF_LOW_LE}) + + def test_the_reference_implementation_sorts_the_same_way(self): + """``_ref_scan_refs`` derives the order from integers, not from ``ref[::-1]``. + + Two independent spellings of the same rule, both now anchored to the + vendored comparator rather than to each other. + """ + script = bytes([0xD0]) + _REF_HIGH_LE + bytes([0xD8]) + _REF_LOW_LE + assert _ref_scan_refs(script) == uint288_sorted({_REF_HIGH_LE, _REF_LOW_LE}) diff --git a/tests/vendor/radiant_core/MANIFEST.json b/tests/vendor/radiant_core/MANIFEST.json index f5c389de..e3131c73 100644 --- a/tests/vendor/radiant_core/MANIFEST.json +++ b/tests/vendor/radiant_core/MANIFEST.json @@ -11,7 +11,7 @@ "tag": "v3.1.2", "commit": "45e0aa40d6ae022ba69439a58b706748b083a35b", "license": "MIT", - "fetched_utc": "2026-08-11", + "fetched_utc": "2026-09-04", "files": { "script.h": { "upstream_path": "src/script/script.h", @@ -44,6 +44,14 @@ "validation.cpp": { "upstream_path": "src/validation.cpp", "sha256": "c10f1c4beffd8b976a48c879415b5f58056f43550491d5be5440929e43a05c6e" + }, + "consensus.h": { + "upstream_path": "src/consensus/consensus.h", + "sha256": "c344ba585c225420c3d333f952507357e55f7069d1e816d4fe01c16dc063f4d6" + }, + "uint256.h": { + "upstream_path": "src/uint256.h", + "sha256": "e4cc8933a6e4c5a13e83b92a40d2b7d3d63ca9ebddd0403a287a0b913834def4" } } } diff --git a/tests/vendor/radiant_core/README.md b/tests/vendor/radiant_core/README.md index 5454f9f6..df04dfcb 100644 --- a/tests/vendor/radiant_core/README.md +++ b/tests/vendor/radiant_core/README.md @@ -49,10 +49,18 @@ files**, not by trusting a Python transcription of them: | which flags are mandatory vs standard | `MANDATORY_/STANDARD_SCRIPT_VERIFY_FLAGS` in `policy.h` | | which flags a **block** is connected under, and `fRequireStandard` | `GetNextBlockScriptFlags` in `validation.cpp` | | DER signature size bounds and the flags gating strict-DER / low-S | `IsValidDERSignatureEncoding` and its callers in `sigencoding.cpp` | +| the per-script stack-memory and opcode-cost budgets | `MAX_SCRIPT_STACK_MEMORY_USAGE` / `MAX_SCRIPT_OPCODE_COST` in `consensus.h` | +| the order refs are hashed into `hashOutputHashes` | `base_blob::Compare` and `operator<` in `uint256.h` | A hand-maintained Python table of the same facts would reintroduce exactly the transcription step that produced the bugs. +The last two entries were added because a citation is only evidence if the cited file is here. +`interpreter.cpp` enforces both script budgets and declares neither, so their *values* were +uncheckable; and `transaction_preimage.py` cited `src/primitives/transaction.h` for the ref sort +order, which holds the `std::set` but does not define how it orders — that lives in +`uint256.h`, and getting it wrong made dMint contract-output signing fail about half the time. + Local filenames match upstream basenames except `primitives_transaction.h`, which is `src/primitives/transaction.h` renamed so it cannot be confused with a pyrxd module; `MANIFEST.json` records every `upstream_path` verbatim. @@ -76,7 +84,11 @@ The cost is that the pin can go stale. That is handled explicitly rather than ig manifest, and reports what changed. Run it when a new Radiant Core release lands. - `tests/test_consensus_opcode_parity.py::test_vendored_sources_match_manifest_digest` fails if the vendored bytes stop matching the recorded sha256, so a local edit or a botched refresh cannot go - unnoticed. + unnoticed. Its parameters come from `MANIFEST.json`, not from a list in the test — it was + hand-listed as `["script.h", "script.cpp"]` while eight files were vendored, which is a check that + passes vacuously on whatever was added after it was written. `test_every_vendored_file_is_digest_checked` + and `test_the_refresh_script_covers_every_vendored_file` close the other two directions: a file on + disk with no manifest entry, and a manifest entry the refresh script would never re-fetch. - `scripts/refresh_radiant_core_vendor.py --check` exits non-zero when upstream has moved. It needs network, so it is **not** part of the offline test suite. Its scheduled owner is the **`vendor-freshness` job in `.github/workflows/integration.yml`**, which runs nightly and on @@ -97,9 +109,11 @@ pins the tag the SOURCE came from; `pyrxd.devnet.DEFAULT_RADIANT_VERSION` pins t regtest image's BINARY is built from, and the two are bumped on different schedules — today the source is at `v3.1.2` and the image at `v3.1.1`. That gap is fine only while the two releases share these files, so `--check` verifies exactly that and fails if they ever diverge. (They are currently -byte-identical: `script.h` `3de78962…` and `script.cpp` `759ab524…` at both tags.) If they do -diverge, every parity assertion would be describing a different script interpreter than the one the -lane asks — bump the image, or pin the source to the image's tag. +byte-identical: `script.h` `3de78962…` and `script.cpp` `759ab524…` at both tags, and likewise the +two most recently added — `consensus.h` `c344ba58…` and `uint256.h` `e4cc8933…`, checked at `v3.1.1` +and `v3.1.2` when they were vendored.) If they do diverge, every parity assertion would be +describing a different script interpreter than the one the lane asks — bump the image, or pin the +source to the image's tag. ## Refreshing diff --git a/tests/vendor/radiant_core/consensus.h b/tests/vendor/radiant_core/consensus.h new file mode 100644 index 00000000..d7e25c0f --- /dev/null +++ b/tests/vendor/radiant_core/consensus.h @@ -0,0 +1,96 @@ +// Copyright (c) 2009-2010 Satoshi Nakamoto +// Copyright (c) 2009-2016 The Bitcoin Core developers +// Copyright (c) 2022-2026 The Radiant developers +// Copyright (c) 2021 The Bitcoin developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#pragma once + +#include + +/** 1MB */ +inline constexpr uint64_t ONE_MEGABYTE = 1000000; +/** The maximum allowed size for a transaction, in bytes */ +inline constexpr uint64_t MAX_TX_SIZE = 12 * ONE_MEGABYTE; +/** The maximum allowed size for a transaction legacy, in bytes */ +inline constexpr uint64_t MAX_TX_SIZE_CONSENSUS_LEGACY = ONE_MEGABYTE; +/** The minimum allowed size for a transaction, in bytes */ +inline constexpr uint64_t MIN_TX_SIZE = 32; +/** The maximum allowed size for a block, before the UAHF */ +inline constexpr uint64_t LEGACY_MAX_BLOCK_SIZE = ONE_MEGABYTE; +/** Default setting for maximum allowed size for a block, in bytes */ +inline constexpr uint64_t DEFAULT_EXCESSIVE_BLOCK_SIZE = 256 * ONE_MEGABYTE; + +/** Default setting for maximum allowed size for a block, in bytes legacy */ +inline constexpr uint64_t DEFAULT_EXCESSIVE_BLOCK_SIZE_LEGACY = 32 * ONE_MEGABYTE; +/** + * Maximum excessive blocks size: 2GB. This is a temporary limit + * to prevent consensus failure between 32-bit and 64-bit platforms, + * until we drop 32-bit platform support altogether, at which point + * this constant should be raised well beyond 32-bit addressing limits. + */ +inline constexpr uint64_t MAX_EXCESSIVE_BLOCK_SIZE = uint64_t(2000) * ONE_MEGABYTE; +/** Allowed number of signature check operations per transaction. */ +inline constexpr uint64_t MAX_TX_SIGCHECKS = UINT64_MAX; +/** + * The ratio between the maximum allowable block size and the maximum allowable + * SigChecks (executed signature check operations) in the block. (network rule). + */ +inline constexpr int BLOCK_MAXBYTES_MAXSIGCHECKS_RATIO = 1; +/** + * Coinbase transaction outputs can only be spent after this number of new + * blocks (network rule). + */ +inline constexpr int COINBASE_MATURITY = 100; +/** Coinbase scripts have their own script size limit. */ +inline constexpr int MAX_COINBASE_SCRIPTSIG_SIZE = 2048; + +/** + * Per-script peak memory budget (bytes). Enforced when the 2026-06 + * SCRIPT_SECURITY_UPGRADE flag is active (future consensus, mainnet block + * SecurityUpgradeHeight) and, as a relay/policy guard, when + * SCRIPT_VERIFY_MEMORY_BUDGET is set (mempool acceptance, active now). Caps the + * cumulative byte size of all elements held across the main stack + altstack at + * any instant, so a script cannot balloon to multiple GB via repeated + * OP_DUP/OP_CAT/etc. of a large element. + * + * Chosen at 128 MB = 4x MAX_SCRIPT_ELEMENT_SIZE (32 MB). This gives genuine + * headroom: it admits a few max-size elements plus a working set held + * simultaneously (e.g. two 32 MB operands of OP_CAT alongside other live stack + * items) rather than the bare 2x that a single in-place OP_CAT could already + * approach. It bounds the peak transient address space a single script can + * demand while still rejecting a memory bomb that would otherwise consume up to + * MAX_STACK_SIZE * MAX_SCRIPT_ELEMENT_SIZE (~1 PB). Raising this value is safe: + * it only ever relaxes a never-yet-active budget (consensus enforcement is + * future, and the relay guard is new), so it cannot retroactively reject any + * historical block. + */ +inline constexpr uint64_t MAX_SCRIPT_STACK_MEMORY_USAGE = uint64_t(128) * ONE_MEGABYTE; + +/** + * Per-script opcode-cost budget for hashing / bytewise opcodes, enforced only + * when SCRIPT_SECURITY_UPGRADE is active. Each such opcode accrues cost + * proportional to the size of the data it processes; the running total may not + * exceed this budget. Chosen at 1 GB-equivalent of processed bytes — far above + * any plausible legitimate script (e.g. tens of MB-class hashes) but bounding + * the total CPU a single script can demand from hashing/bytewise primitives. + */ +inline constexpr uint64_t MAX_SCRIPT_OPCODE_COST = uint64_t(1000) * ONE_MEGABYTE; + +/** Flags for nSequence and nLockTime locks */ +/** Interpret sequence numbers as relative lock-time constraints. */ +inline constexpr unsigned int LOCKTIME_VERIFY_SEQUENCE = (1 << 0); +/** Use GetMedianTimePast() instead of nTime for end point timestamp. */ +inline constexpr unsigned int LOCKTIME_MEDIAN_TIME_PAST = (1 << 1); + +/** + * Compute the maximum number of sigchecks that can be contained in a block + * given the MAXIMUM block size as parameter. The maximum sigchecks scale + * linearly with the maximum block size and do not depend on the actual + * block size. The returned value is rounded down (there are no fractional + * sigchecks so the fractional part is meaningless). + */ +inline constexpr uint64_t GetMaxBlockSigChecksCount(uint64_t maxBlockSize) { + return maxBlockSize / BLOCK_MAXBYTES_MAXSIGCHECKS_RATIO; +} diff --git a/tests/vendor/radiant_core/uint256.h b/tests/vendor/radiant_core/uint256.h new file mode 100644 index 00000000..1f2de808 --- /dev/null +++ b/tests/vendor/radiant_core/uint256.h @@ -0,0 +1,212 @@ +// Copyright (c) 2009-2010 Satoshi Nakamoto +// Copyright (c) 2009-2016 The Bitcoin Core developers +// Copyright (c) 2022-2026 The Radiant developers +// Copyright (c) 2021 The Bitcoin developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#pragma once + +#include +#include +#include +#include + +/** Template base class for fixed-sized opaque blobs. */ +template class base_blob { +protected: + static constexpr unsigned WIDTH = BITS / 8; + static_assert(WIDTH * 8 == BITS && WIDTH > 0, "BITS must be evenly divisible by 8 and larger than 0"); + uint8_t m_data[WIDTH]; + +public: + constexpr base_blob() noexcept : m_data{0} {} + + /// type tag + convenience member for uninitialized c'tor + static constexpr struct Uninitialized_t {} Uninitialized{}; + + /// Uninitialized data constructor -- to be used when we want to avoid a + /// redundant zero-initialization in cases where we know we will fill-in + /// the data immediately anyway (e.g. for random generators, etc). + /// Select this c'tor with e.g.: uint256 foo{uint256::Uninitialized} + explicit constexpr base_blob(Uninitialized_t /* type tag to select this c'tor */) noexcept {} + + explicit base_blob(const std::vector &vch) noexcept; + + constexpr bool IsNull() const noexcept { + unsigned i = 0; + do { + if (m_data[i] != 0) + return false; + } while (++i < WIDTH); + return true; + } + + constexpr void SetNull() noexcept { *this = base_blob{}; } + + constexpr int Compare(const base_blob &other) const noexcept { + // compare MSB-first (in reverse because data is little endian) + unsigned i = WIDTH - 1; + do { + const uint8_t a = m_data[i]; + const uint8_t b = other.m_data[i]; + + if (a > b) { + return 1; + } + if (a < b) { + return -1; + } + } while (i-- != 0); + + return 0; + } + + friend inline constexpr bool operator==(const base_blob &a, const base_blob &b) noexcept { + return a.Compare(b) == 0; + } + friend inline constexpr bool operator!=(const base_blob &a, const base_blob &b) noexcept { + return a.Compare(b) != 0; + } + friend inline constexpr bool operator<(const base_blob &a, const base_blob &b) noexcept { + return a.Compare(b) < 0; + } + friend inline constexpr bool operator<=(const base_blob &a, const base_blob &b) noexcept { + return a.Compare(b) <= 0; + } + friend inline constexpr bool operator>(const base_blob &a, const base_blob &b) noexcept { + return a.Compare(b) > 0; + } + friend inline constexpr bool operator>=(const base_blob &a, const base_blob &b) noexcept { + return a.Compare(b) >= 0; + } + + std::string GetHex() const; + void SetHex(const char *psz) noexcept; + void SetHex(const std::string &str) noexcept; + std::string ToString() const { return GetHex(); } + + constexpr const uint8_t *data() const noexcept { return &m_data[0]; } + constexpr uint8_t *data() noexcept { return &m_data[0]; } + + constexpr uint8_t *begin() noexcept { return &m_data[0]; } + + constexpr uint8_t *end() noexcept { return begin() + size(); } + + constexpr const uint8_t *begin() const noexcept { return &m_data[0]; } + + constexpr const uint8_t *end() const noexcept { return begin() + size(); } + + static constexpr unsigned size() noexcept { return WIDTH; } + + constexpr uint64_t GetUint64(int pos) const noexcept { + const uint8_t *const ptr = &m_data[pos * 8]; + return uint64_t(ptr[0]) | (uint64_t(ptr[1]) << 8) | + (uint64_t(ptr[2]) << 16) | (uint64_t(ptr[3]) << 24) | + (uint64_t(ptr[4]) << 32) | (uint64_t(ptr[5]) << 40) | + (uint64_t(ptr[6]) << 48) | (uint64_t(ptr[7]) << 56); + } + + template void Serialize(Stream &s) const { + s.write(reinterpret_cast(begin()), size()); + } + + template void Unserialize(Stream &s) { + s.read(reinterpret_cast(begin()), size()); + } +}; + +/** + * 160-bit opaque blob. + * @note This type is called uint160 for historical reasons only. It is an + * opaque blob of 160 bits and has no integer operations. + */ +class uint160 : public base_blob<160> { +public: + using base_blob<160>::base_blob; ///< inherit constructors +}; + +/** + * 256-bit opaque blob. + * @note This type is called uint256 for historical reasons only. It is an + * opaque blob of 256 bits and has no integer operations. Use arith_uint256 if + * those are required. + */ +class uint256 : public base_blob<256> { +public: + using base_blob<256>::base_blob; ///< inherit constructors +}; + +/** + * uint256 from const char *. + * This is a separate function because the constructor uint256(const char*) can + * result in dangerously catching uint256(0). + */ +inline uint256 uint256S(const char *str) noexcept { + uint256 rv{uint256::Uninitialized}; + rv.SetHex(str); + return rv; +} + +/** + * uint256 from std::string. + * This is a separate function because the constructor uint256(const std::string + * &str) can result in dangerously catching uint256(0) via std::string(const + * char*). + */ +inline uint256 uint256S(const std::string &str) noexcept { + uint256 rv{uint256::Uninitialized}; + rv.SetHex(str); + return rv; +} + +inline uint160 uint160S(const char *str) noexcept { + uint160 rv{uint160::Uninitialized}; + rv.SetHex(str); + return rv; +} +inline uint160 uint160S(const std::string &str) noexcept { + uint160 rv{uint160::Uninitialized}; + rv.SetHex(str); + return rv; +} + +/** + * 288-bit opaque blob. + * @note It is an opaque blob of 288 bits and has no integer operations. Used for outpoint/assetId (36 bytes) + */ +class uint288 : public base_blob<288> { +public: + using base_blob<288>::base_blob; ///< inherit constructors +}; + +/** + * uint288 from const char *. + * This is a separate function because the constructor uint288(const char*) can + * result in dangerously catching uint288(0). + */ +inline uint288 uint288S(const char *str) noexcept { + uint288 rv{uint288::Uninitialized}; + rv.SetHex(str); + return rv; +} + +/** + * 512-bit opaque blob. + * @note It is an opaque blob of 512 bits and has no integer operations. + */ +class uint512 : public base_blob<512> { +public: + using base_blob<512>::base_blob; ///< inherit constructors +}; + +/** + * uint512 from const char *. + * This is a separate function because the constructor uint512(const char*) can + * result in dangerously catching uint512(0). + */ +inline uint512 uint512S(const char *str) noexcept { + uint512 rv{uint512::Uninitialized}; + rv.SetHex(str); + return rv; +}