fix(consensus): parse a size-delimited ErgoTree by structure, not the declared size - #123
Conversation
… 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>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughIn ChangesErgoTree declared-size deserialization rework
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
… 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>
What
The node parsed a size-delimited (
has_size) ErgoTree by consuming exactly the declared sizebytes (
get_u32_exact+get_bytes(size)). Scala'sdeserializeErgoTreeis structure-delimited:it bounds the body by a position limit of
MaxPropositionSize(4096, anchored atstartPos,checked
position > positionLimitbefore each read), and on success leaves the reader at theactual structural body end —
ErgoBoxCandidate.parseBodyreadscreationHeightimmediatelyafter the inline tree parse. The declared size is read non-exact (
getUInt().toInt) and usedonly for the
UnparsedErgoTreebyte 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-invalida crafted block could exploit:
i32::MAX→get_u32_exactrejected; Scala ignores it and parses.get_bytesfailed; Scala parses the body.creationHeightfrom the wrong offset; Scala stops at the body end.
How
position_limit=MaxPropositionSize - (header + size length). Theposition_limitmirrors Scala'sposition > positionLimitbegin-check byte-for-byte (a final read beginning exactly at the limitproceeds), and a
CheckPositionLimitoverrun (InvalidData) routes to the soft-fork wrap — likeScala's
ReaderPositionLimitExceeded→ValidationException.parse_bodyis structure-delimited, so its consumed length is the true body length. On successadvance the outer reader by that length; only when wrapping reposition to Scala's
numBytesboundary (
take_unparsed_size_region) and preserve those bytes verbatim.numBytesstays in range — Scalastill wraps (it does not reject for a negative size) and the reader can rewind before the body;
the helper reproduces that, erroring only when
numBytesis 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 bytecounts 5 / 4 / 0;
08f9ffffff0f0204(numBytes < 0) hard-fails; and theMaxPropositionSizeboundary (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
numBytesbyte counts, and the parse cases. The group-element checkpoint,byte-preservation (block 1,702,686 → 49-byte
UnparsedErgoTree), depth / hard-reject, andnon-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
🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
Tests