Skip to content

fix(consensus): parse a size-delimited ErgoTree by structure, not the declared size - #123

Merged
arkadianet merged 1 commit into
mainfrom
fix/structure-delimited-tree-size
Jun 21, 2026
Merged

fix(consensus): parse a size-delimited ErgoTree by structure, not the declared size#123
arkadianet merged 1 commit into
mainfrom
fix/structure-delimited-tree-size

Conversation

@arkadianet

@arkadianet arkadianet commented Jun 21, 2026

Copy link
Copy Markdown
Owner

What

The node parsed a size-delimited (has_size) ErgoTree by consuming exactly the declared size
bytes (get_u32_exact + get_bytes(size)). Scala's deserializeErgoTree is structure-delimited:
it bounds the body by a position limit of MaxPropositionSize (4096, anchored at startPos,
checked position > positionLimit before each read), and on success leaves the reader at the
actual structural body endErgoBoxCandidate.parseBody reads creationHeight immediately
after the inline tree parse. The declared size is read non-exact (getUInt().toInt) and used
only for the UnparsedErgoTree byte count on the wrap path
(numBytes = bodyPos - startPos + declaredSize, then a rewind + getBytes(numBytes)).

So the node diverged for any box whose declared size ≠ the actual body length. Honest serializers
always write size == body, so this is adversarial-only, but a real reject-valid / accept-invalid
a crafted block could exploit:

  • size past i32::MAXget_u32_exact rejected; Scala ignores it and parses.
  • size > available bytes → get_bytes failed; Scala parses the body.
  • size > the body → the node consumed trailing box fields as tree padding and read creationHeight
    from the wrong offset; Scala stops at the body end.
  • size < the body → the node truncated the body; Scala parses it in full.

How

  • Parse the body on a view of all remaining bytes under the reader's position_limit =
    MaxPropositionSize - (header + size length). The position_limit mirrors Scala's
    position > positionLimit begin-check byte-for-byte (a final read beginning exactly at the limit
    proceeds), and a CheckPositionLimit overrun (InvalidData) routes to the soft-fork wrap — like
    Scala's ReaderPositionLimitExceededValidationException.
  • parse_body is structure-delimited, so its consumed length is the true body length. On success
    advance the outer reader by that length; only when wrapping reposition to Scala's numBytes
    boundary (take_unparsed_size_region) and preserve those bytes verbatim.
  • The declared size, being non-exact, may be negative while numBytes stays in range — Scala
    still wraps (it does not reject for a negative size) and the reader can rewind before the body;
    the helper reproduces that, erroring only when numBytes is negative or past the buffer end.

Validation

Oracle-validated (sigma-state 6.0.2, run directly during review): 080208d3 / 080508d3 /
080108d3 / 08808080800808d3 (size ==, >, <, overflowed vs a 2-byte body) all PARSE;
08ffffffff0f0204 / 08feffffff0f0204 / 08faffffff0f0204 (negative sizes) wrap with byte
counts 5 / 4 / 0; 08f9ffffff0f0204 (numBytes < 0) hard-fails; and the MaxPropositionSize
boundary (total tree 4096/4097 parse, 4098 wraps) matches.

Reviewed source-level against the Scala reference across three passes — the review (which ran
the JVM oracle on edge cases) caught and fixed two real edge bugs: a negative-size over-rejection
and the position-limit anchor/begin-check boundary. New tests pin the structural advance, the
negative-size numBytes byte counts, and the parse cases. The group-element checkpoint,
byte-preservation (block 1,702,686 → 49-byte UnparsedErgoTree), depth / hard-reject, and
non-SigmaProp-root wrap behaviors are unchanged.

Out of scope (tracked follow-up): the nested SBox-constant size-delimited SKIP
(sigma_value.rs) still advances by the declared size.

Test plan

cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace      # 4399 passed

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Enhanced ErgoTree deserialization to correctly handle edge cases and align with reference implementation
    • Improved robustness of parser boundaries and size field handling in tree structure parsing
    • Refined error handling for unparsable tree components
  • Tests

    • Added validation tests for declared size handling and parsing behavior

… declared size

Scala's `deserializeErgoTree` does NOT use the `has_size` declared size to bound
the body parse or to advance the reader on success. It bounds the body by a
POSITION LIMIT of `MaxPropositionSize` (4096) anchored at `startPos` and checked
`position > positionLimit` BEFORE each read, and on success the reader sits at the
ACTUAL structural body end — `ErgoBoxCandidate.parseBody` reads `creationHeight`
immediately after the inline tree parse. The declared size is read non-exact
(`getUInt().toInt`) and used ONLY for the `UnparsedErgoTree` byte count on the
wrap path: `numBytes = bodyPos - startPos + declaredSize`, then the reader rewinds
to `startPos` and reads those bytes.

The node instead read the size with `get_u32_exact` and consumed exactly `size`
bytes (`get_bytes(size)`) as the tree, which diverged from Scala for any box whose
declared tree size ≠ the actual body length (honest serializers always write
size == body, so this is adversarial-only, but a real reject-valid /
accept-invalid a crafted block could exploit):

- declared size past i32::MAX → `get_u32_exact` rejected; Scala ignores it (parses).
- declared size > available bytes → `get_bytes` failed; Scala parses the body.
- declared size > the actual body → the node consumed trailing box fields as tree
  padding and read `creationHeight` from the wrong offset; Scala stops at the body.
- declared size < the actual body → the node truncated the body; Scala parses it.

Parse the body on a view of all remaining bytes (NOT bounded by the declared size)
under a `position_limit` of `MaxPropositionSize - (header + size length)` — the
reader's `position_limit` mirrors Scala's `position > positionLimit` begin-check
byte-for-byte (a final read beginning exactly at the limit still proceeds), and a
`CheckPositionLimit` overrun maps to the same soft-fork wrap. `parse_body` is
structure-delimited, so its consumed length is the true body length. On success
advance the outer reader by that length; only when wrapping reposition to Scala's
`numBytes` boundary via `take_unparsed_size_region` and preserve those bytes
verbatim. The declared size, being non-exact, may be NEGATIVE while `numBytes`
stays in range — Scala still wraps (it does not reject for a negative size) and
the reader can rewind before the body; the helper reproduces that, erroring only
when `numBytes` is negative or past the buffer end.

Oracle-validated (sigma-state 6.0.2): `080208d3` / `080508d3` / `080108d3` /
`08808080800808d3` (size ==, >, <, overflowed vs a 2-byte body) all PARSE;
`08ffffffff0f0204` / `08feffffff0f0204` / `08faffffff0f0204` (negative sizes) wrap
with byte counts 5 / 4 / 0; `08f9ffffff0f0204` (numBytes < 0) hard-fails. New tests
pin the structural advance and those byte counts. The group-element checkpoint,
byte-preservation (block 1,702,686 → UnparsedErgoTree of 49 bytes), depth /
hard-reject, and non-SigmaProp-root wrap behaviors are unchanged (4399 green).

The nested `SBox`-constant size-delimited SKIP (`sigma_value.rs`) still advances by
the declared size; that rarer path is a tracked follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b196167a-a6c1-4ccd-8716-ad4d4189d3a7

📥 Commits

Reviewing files that changed from the base of the PR and between 7cc7941 and ce6545e.

📒 Files selected for processing (1)
  • ergo-ser/src/ergo_tree.rs

📝 Walkthrough

Walkthrough

In ergo_tree.rs, the has_size ErgoTree deserialization is reworked to match Scala semantics. A new MAX_PROPOSITION_BYTES constant bounds body parsing positionally. A new helper take_unparsed_size_region computes Scala-equivalent numBytes (including negative declared-size cases) for Expr::Unparsed wrapping. Success and error paths now both use this helper instead of the declared size to advance the reader or capture bytes.

Changes

ErgoTree declared-size deserialization rework

Layer / File(s) Summary
MAX_PROPOSITION_BYTES constant and take_unparsed_size_region helper
ergo-ser/src/ergo_tree.rs
Adds the 4096-byte positional cap constant and take_unparsed_size_region, which computes Scala-equivalent numBytes (including negative declared-size handling) and extracts the verbatim byte slice for Expr::Unparsed wrapping.
has_size branch: non-exact declared-size read and bounded body parsing
ergo-ser/src/ergo_tree.rs
Replaces strict get_u32_exact + get_bytes with non-exact get_uint_to_i32, caps the body budget at MAX_PROPOSITION_BYTES, parses via a limited reader view, collects v6-method checkpoint and group-element sideband, and routes version-overflow wrap through take_unparsed_size_region.
Success-path and error-path wrapping using declared-size region
ergo-ser/src/ergo_tree.rs
Success path wraps via take_unparsed_size_region when pre-v3/v6-method or non-SSigmaProp conditions are met, and otherwise advances by actual body_consumed. Error-to-wrap path now also uses take_unparsed_size_region instead of previously captured full-tree bytes.
Tests: declared-size independence and negative-size wrapping
ergo-ser/src/ergo_tree.rs
declared_size_is_not_the_body_bound_nor_the_success_advance asserts oversized/undersized/equal/overflowed declared sizes parse to SSigmaProp at structural end. negative_declared_size_wraps_with_scala_numbytes asserts negative declared sizes wrap with Scala's numBytes = bodyPos - startPos + declaredSize.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • arkadianet/ergo#117: Modifies the same has_size wrapping error-handling logic in ergo_tree.rs so HardReject escapes rather than being wrapped, directly related to this PR's error-path adjustments.
  • arkadianet/ergo#119: Modifies read_ergo_tree_tracking_wrap for pre-v3/v6-method wrapping decisions and declared-size region byte slicing, the same code paths reworked here.
  • arkadianet/ergo#122: Modifies read_ergo_tree_tracking_wrap soft-fork wrapping logic and declared-size region byte slicing for Expr::Unparsed, overlapping directly with this PR's changes.

Poem

🐇 Hoppity-hop through bytes I go,
Declared sizes? I ignore their show!
MAX_PROPOSITION_BYTES sets the bound,
numBytes from Scala, correct and sound.
Negative sizes still wrap just right —
This rabbit keeps the parser tight! 🌿

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(consensus): parse a size-delimited ErgoTree by structure, not the declared size' directly and precisely summarizes the main change: shifting from parsing by declared size to parsing by actual structure.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/structure-delimited-tree-size

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@arkadianet
arkadianet merged commit 458e581 into main Jun 21, 2026
8 checks passed
@arkadianet
arkadianet deleted the fix/structure-delimited-tree-size branch June 21, 2026 10:00
arkadianet added a commit that referenced this pull request Jun 21, 2026
… skip (#124)

Following #123 (which made the top-level size-delimited ErgoTree parse
structure-delimited), the nested `SBox`-constant path still skipped its inner
size-delimited tree by the DECLARED size: `skip_ergo_tree` read `size` and did
`get_bytes(size)`. Scala deserializes a nested box's proposition INLINE via
`ErgoTreeSerializer.deserializeErgoTree` (`ErgoBoxCandidate.parseBody`), which is
structure-delimited — the declared size does not bound the parse or advance the
reader, which is left at the actual body end where the box's `creationHeight` is
read next. So for any nested box whose declared tree size ≠ its body length
(adversarial-only, but the same reject-valid / accept-invalid class as #123), the
node consumed the wrong number of bytes and desynced the box (and the enclosing
tree's parse).

Rewind to before the header and delegate to `read_ergo_tree_tracking_wrap`, which
(since #123) parses the inner tree structurally, advances the reader by the true
body length on success or to Scala's `numBytes` boundary on a soft-fork wrap,
forwards the inner tree's group elements onto the outer reader (the JVM
curve-checks an off-curve point inside a nested box while deserializing it), and
re-raises `DepthLimitExceeded` / `HardReject` so they escape the enclosing tree's
wrap. `check_tree_version_supported` still hard-rejects a future-version inner
tree afterward. The box stays opaque for round-trip via the caller's preserved
bytes; only the reader advance moves. The sizeless nested path
(`parse_sizeless_inner_box_script` + `harden_sizeless_inner_error`) is unchanged.

New `skip_ergo_tree_size_delimited_advances_by_body_not_declared_size` test pins
the structural advance (a size-5 / body-2 tree followed by trailing box bytes
advances by 2, leaving the 3 trailing bytes). The existing nested-box v6 /
rule-1012 / version tests still pass (4400 green).

Co-authored-by: arkadianet <rkadias@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant