Skip to content

fix(consensus): faithful UnparsedErgoTree — preserve bytes + eval-error - #118

Merged
arkadianet merged 1 commit into
mainfrom
fix/unparsed-ergotree-eval-and-bytes
Jun 21, 2026
Merged

fix(consensus): faithful UnparsedErgoTree — preserve bytes + eval-error#118
arkadianet merged 1 commit into
mainfrom
fix/unparsed-ergotree-eval-and-bytes

Conversation

@arkadianet

@arkadianet arkadianet commented Jun 21, 2026

Copy link
Copy Markdown
Owner

What

Makes a soft-fork-wrapped ErgoTree — Scala's Left(UnparsedErgoTree(bytes, error))
behave faithfully on the two axes the node previously got wrong.

1. Eval must hard-ERROR, not reduce to true.
Scala's Interpreter.propositionFromErgoTree returns TrueSigmaProp only when
isSoftFork(error) holds, and isSoftFork defaults false (true only for a rule the
active block's validation settings have replaced/changed via voting). This node models no
such soft-fork activation, so an unparsed tree must error on evaluation. Reducing it to
true was a spend-path accept-invalid.

2. It must re-serialize BYTE-IDENTICALLY.
Scala preserves the wrapped tree's propositionBytes verbatim. The node was substituting a
synthetic Const(SBoolean, true) placeholder body, so re-emission diverged from the wire
bytes — the SANTA wire round-trip divergences.

How

  • New Expr::Unparsed(Vec<u8>) whole-tree body holds the full original tree bytes
    (header + size + body). read_ergo_tree_tracking_wrap captures
    data_slice(tree_start, tree_end) at every wrap site; write_ergo_tree emits them
    verbatim; eval_expr returns the new EvalError::UnparsedErgoTree.
  • Group elements collected before the wrap are still forwarded for the tx-chokepoint
    curve-check, matching Scala curve-checking GEs as it deserializes (before producing the
    UnparsedErgoTree).
  • CheckDeserializedScriptIsSigmaProp parity widened: the non-SigmaProp-root wrap check now
    also resolves a ConstPlaceholder root against the segregated constants table (as Scala's
    ConstantPlaceholderSerializer.parse types it), so a placeholder root pointing at a
    non-SigmaProp constant wraps like an inline non-SigmaProp constant.
  • Test fixtures that built a size-delimited tree from a non-SigmaProp root
    (Const(SBoolean, true) + has_size, or a ConstPlaceholder → non-SigmaProp constant) now
    correctly wrap to Expr::Unparsed on re-read — such a root is genuinely unspendable.
    Migrated to a SigmaProp root. The REST submit reject now keys on the Expr::Unparsed
    body directly instead of the obsolete placeholder heuristic.

Review

Reviewed source-level against the Scala oracle (reference/ergo-core) before opening. The core
was confirmed correct: isSoftFork default-false → eval-error is right; wrapped bytes match
Scala's UnparsedErgoTree(bytes) path; GE forwarding correct; every new Expr::Unparsed
match arm hard-errors/rejects. The ConstPlaceholder root completeness above was addressed
per that review.

A closely-related divergence in the same read_ergo_tree_tracking_wrap path — group-element
ordering past a v6 method (a size-delimited pre-v3 tree wrapping at the method, where group
elements after it must not be curve-checked) — is fixed in the follow-up #119.

Known pre-existing follow-ups (NOT introduced here; out of scope for this PR)

The wrap-trigger classification (which the byte/eval representation here does not change)
has two pre-existing gaps worth a focused follow-up:

  • version > MAX_SUPPORTED_TREE_VERSION is activation-unaware. The node always wraps a
    future-version size-delimited tree opaquely; Scala only skip-accepts when
    activatedScriptVersion is beyond the supported max, else VersionContext.withVersions
    raises and deserializeErgoTree hard-rejects (not wraps).
  • The has-size wrap catches more than Scala's ValidationException. Non-validation
    failures (e.g. a ConstantPlaceholder index overflow → ReadError::ValueTooLarge, a Scala
    SerializerException) are wrapped here but hard-rejected by Scala at tx parse.

Both are pre-existing on main and need a careful Scala exception-class / version-activation
audit; tracked separately.

Test plan

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

New/updated tests: size_flagged_non_sigmaprop_root_wraps_as_unparsed (now asserts
Expr::Unparsed + byte-identity), size_flagged_const_placeholder_non_sigmaprop_root_wraps_as_unparsed,
unparsed_ergo_tree_body_eval_errors_not_true, plus the fixture migrations.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Introduces Expr::Unparsed(Vec<u8>) as a new ErgoTree AST variant that stores the full original bytes of an unparseable or soft-fork-wrapped tree body. The ErgoTree reader is updated to capture and preserve these bytes; the writer emits them verbatim. All evaluator walkers, the REST submit validator, and cross-crate test helpers are updated to handle this new variant.

Changes

Expr::Unparsed Byte-Preserving Soft-Fork Wrapping

Layer / File(s) Summary
Expr::Unparsed variant, write guard, and register guard
ergo-ser/src/opcode/types.rs, ergo-ser/src/opcode/write.rs, ergo-ser/src/register.rs
Adds Expr::Unparsed(Vec<u8>) to the expression AST; find_v3_only_method short-circuits for it; write_expr returns WriteError::InvalidData; expr_to_register_value returns ReadError::InvalidData.
ErgoTree read: byte capture, determinable_root_type, unparsed_soft_fork_tree redefinition
ergo-ser/src/ergo_tree.rs
Records tree_start before parsing, slices full_tree_bytes, adds determinable_root_type to resolve ConstPlaceholder roots, and redefines unparsed_soft_fork_tree to store Expr::Unparsed(full_tree_bytes) instead of a synthetic boolean constant across all three wrapping paths.
write_ergo_tree: Expr::Unparsed fast-path
ergo-ser/src/ergo_tree.rs
Detects Expr::Unparsed(raw) and writes the preserved bytes verbatim, enabling byte-identical round-trips.
Evaluator guards for Expr::Unparsed
ergo-sigma/src/evaluator/types.rs, ergo-sigma/src/evaluator/dispatch.rs, ergo-sigma/src/evaluator/helpers.rs, ergo-sigma/src/evaluator/opcodes/binding.rs, ergo-sigma/src/evaluator/opcodes/method_call.rs, ergo-sigma/src/evaluator/tests.rs
Documents EvalError::UnparsedErgoTree; adds match arms that immediately error in eval_expr, eval_block_value, and expr_put_value_cost; short-circuits expr_has_deserialize, inline_placeholders, and infer_expr_type; adds a regression test asserting the error is not true.
REST submit validation and api_bridge test
ergo-rest-json/src/decode.rs, ergo-node/src/api_bridge/tests.rs
Replaces the re-serialization roundtrip check in Submit mode with a direct body == Expr::Unparsed(_) test returning NON_CANONICAL; removes write_ergo_tree and sigma-type imports; updates the api_bridge test comment to reflect the new rejection path.
ergo_tree.rs fixture helpers and oracle-parity tests
ergo-ser/src/ergo_tree.rs
Introduces sigma_prop_body(), updates existing size-delimited tests to use SSigmaProp roots, reworks oracle-parity tests to assert Expr::Unparsed byte-preservation, and adds unparsed_soft_fork_tree_roundtrips_byte_identical.
SSigmaProp root propagation in cross-crate test helpers
ergo-ser/src/ergo_box.rs, ergo-ser/src/block_transactions.rs, ergo-ser/src/transaction.rs, ergo-indexer/src/ser/boxes.rs, ergo-mining/src/candidate_selection.rs, ergo-validation/tests/trace_emission_700000.rs
Updates size_delimited_tree / trivial_tree helpers to use SSigmaProp/TrivialProp(true) roots, removes Body/simple_body() dependencies; trace helpers gain Expr::Unparsed match arms.

Sequence Diagram(s)

sequenceDiagram
    participant Caller
    participant read_ergo_tree_tracking_wrap
    participant determinable_root_type
    participant unparsed_soft_fork_tree
    participant write_ergo_tree

    Caller->>read_ergo_tree_tracking_wrap: parse bytes
    read_ergo_tree_tracking_wrap->>read_ergo_tree_tracking_wrap: record tree_start, capture full_tree_bytes
    alt version > MAX or parse Err
        read_ergo_tree_tracking_wrap->>unparsed_soft_fork_tree: full_tree_bytes
        unparsed_soft_fork_tree-->>read_ergo_tree_tracking_wrap: ErgoTree { body: Expr::Unparsed(full_tree_bytes) }
    else parse Ok
        read_ergo_tree_tracking_wrap->>determinable_root_type: resolve root SigmaType
        alt non-SSigmaProp root
            read_ergo_tree_tracking_wrap->>unparsed_soft_fork_tree: full_tree_bytes
            unparsed_soft_fork_tree-->>read_ergo_tree_tracking_wrap: ErgoTree { body: Expr::Unparsed(full_tree_bytes) }
        else SSigmaProp or unresolvable
            read_ergo_tree_tracking_wrap-->>Caller: parsed ErgoTree
        end
    end
    read_ergo_tree_tracking_wrap-->>Caller: ErgoTree
    Caller->>write_ergo_tree: re-serialize
    alt body == Expr::Unparsed(raw)
        write_ergo_tree-->>Caller: write raw bytes verbatim
    else normal body
        write_ergo_tree-->>Caller: serialize AST
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • arkadianet/ergo#83: Modifies the same has_size parsing path in read_ergo_tree_tracking_wrap to handle parse failures, directly preceding the soft-fork wrapping logic changed here.
  • arkadianet/ergo#116: Introduced find_v3_only_method in ergo-ser/src/opcode/types.rs; this PR adds the Expr::Unparsed(_) early-return to that same walker.
  • arkadianet/ergo#117: Modifies the same size-delimited deserialization and soft-fork wrapping logic in ergo-ser/src/ergo_tree.rs at the identical code layer.

Poem

🐇 Soft-fork bytes once vanished, replaced with a lie,
A boolean true standing in for the sky.
Now Unparsed arrives with its raw bytes intact—
Re-serialized verbatim, no fiction, just fact!
The evaluator rejects it, the writer stands firm,
Round-trips byte-identical—that's a very good term! 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main changeset: introducing a faithful UnparsedErgoTree implementation that preserves original bytes and returns an evaluation error instead of true.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ 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/unparsed-ergotree-eval-and-bytes

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.

A soft-fork-wrapped ErgoTree (Scala's `Left(UnparsedErgoTree(bytes, error))`)
must behave two ways the node previously got wrong:

1. On spend it must FAIL, not reduce to true. Scala's `propositionFromErgoTree`
   returns `TrueSigmaProp` only when `isSoftFork(error)` holds — and `isSoftFork`
   defaults false (it is true only for a rule the active block extensions have
   replaced/changed via voting). The node models no such activation, so an
   unparsed tree must hard-error on evaluation. Reducing it to true was a
   spend-path accept-invalid.

2. It must re-serialize byte-identically. Scala preserves the wrapped tree's
   `propositionBytes` verbatim; the node was substituting a synthetic
   `Const(SBoolean, true)` placeholder body, so re-emission diverged from the
   wire bytes (the SANTA wire round-trip divergences).

Represent the wrapped body as a new `Expr::Unparsed(Vec<u8>)` holding the full
original tree bytes (header + size + body). `read_ergo_tree_tracking_wrap`
captures those bytes at every wrap site; `write_ergo_tree` emits them verbatim;
`eval_expr` returns `EvalError::UnparsedErgoTree`. The group elements collected
before the wrap are still forwarded for the curve-check, matching Scala's
deserialize-time obligation.

The non-SigmaProp-root wrap check (`CheckDeserializedScriptIsSigmaProp`) now also
covers a `ConstPlaceholder` root, resolving its type from the segregated
constants table the way Scala's `ConstantPlaceholderSerializer.parse` does — a
placeholder root pointing at a non-SigmaProp constant wraps just like an inline
non-SigmaProp constant, instead of being accepted as a parsed proposition.

Test fixtures that built a size-delimited tree from a non-SigmaProp root
(`Const(SBoolean, true)` + has_size, or a `ConstPlaceholder` resolving to a
non-SigmaProp constant) now correctly wrap to `Expr::Unparsed` on re-read — such
a root is genuinely unspendable. Migrate them to a `SigmaProp` root, which is
what a valid size-delimited proposition must be. The REST submit path's soft-fork
reject now keys on the `Expr::Unparsed` body directly instead of the obsolete
placeholder heuristic.

Also pins a known adjacent divergence in the same GE-forwarding path (divergence
B: an off-curve group element placed after a v6-only method in a size-flagged
pre-v3 tree is curve-checked here but never reached by Scala's wrap-on-throw) via
a documented test and a pointer comment. Its fix (parser-side sideband
checkpointing) needs Scala oracle vectors and is deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@arkadianet
arkadianet force-pushed the fix/unparsed-ergotree-eval-and-bytes branch from 232beb0 to a04ec94 Compare June 21, 2026 03:36
@arkadianet
arkadianet merged commit ceae083 into main Jun 21, 2026
8 checks passed
@arkadianet
arkadianet deleted the fix/unparsed-ergotree-eval-and-bytes branch June 21, 2026 05:17
@arkadianet arkadianet changed the title fix(consensus): faithful UnparsedErgoTree — preserve bytes + eval-error fix(consensus): faithful soft-fork ErgoTree wrapping — bytes, eval-error, and v6-method GE-ordering Jun 21, 2026
@arkadianet arkadianet changed the title fix(consensus): faithful soft-fork ErgoTree wrapping — bytes, eval-error, and v6-method GE-ordering fix(consensus): faithful UnparsedErgoTree — preserve bytes + eval-error Jun 21, 2026
arkadianet added a commit that referenced this pull request Jun 21, 2026
…lease cut (#126)

0.4.3 was version-cut at #117, but #106#114 and #118#125 landed at the same
version without a CHANGELOG entry — so [0.4.3] documented only #115/#116/#117
while the deployed 0.4.3 binary actually contains all 18 PRs since the v0.4.2
cut. Backfills the section to match what shipped:

- New ### Added subsection for the native /api/v1/wallet surface (#112, #113,
  #114).
- ### Fixed now covers the signed-byte parity fixes (#106, #107), the
  Coll-equality cost short-circuit (#108), EIP-27 re-emission enforcement
  (#109 + #111, merged — the P0 fork fix, now across block/mempool/mining), and
  the full v6/EIP-50 + ErgoTree wire-deserialization cluster (#118#125).
- Broadened the release intro to name the EIP-27 enforcement and the native
  wallet surface alongside the v6/EIP-50 conformance work.
- Added the missing (#117) reference to the box-deserialize entry.

Docs only; no code or behavior change. Entries were drafted from each PR's own
commit/description and accuracy-reviewed (e.g. the EIP-27 soak figure is the
corrected 172 reward-box burns, not the repudiated 1,173).

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