Skip to content

Candidate for 0.30.0 - #896

Open
kushti wants to merge 26 commits into
developfrom
v0.30.0
Open

Candidate for 0.30.0#896
kushti wants to merge 26 commits into
developfrom
v0.30.0

Conversation

mwaddip and others added 18 commits April 9, 2026 03:48
`NipopowProof::has_valid_connections` was too strict compared to JVM:
it required every adjacent prefix entry to connect to its IMMEDIATE
sorted-by-height neighbour via interlink or parent_id. JVM's
`NipopowProof.scala` (`hasValidConnections`, lines ~128-148) instead
allows each `next` entry to connect to ANY of the preceding
`useLastEpochs + 3` entries (a window of up to 11 with the mainnet
default of 8).

The tolerance is load-bearing for real-world proofs: JVM-built proofs
include continuous-mode difficulty-recalculation headers and
naturally-skipped entries from sparse-superlevel walks. Those entries
do not directly connect to the immediately previous sorted entry but
do connect to a nearby earlier neighbour. The Rust verifier had been
silently rejecting such proofs since `2b69b16a` (the original
`is_better_than` impl), but it went unnoticed until sigma-rust was
deployed as a verifier against live JVM peers.

Changes:
- Add `use_last_epochs: u32` to `NipopowAlgos` (Rust analog of JVM
  `chainSettings.useLastEpochs`). Default `8`, matching mainnet and
  testnet `application.conf`. Manual `Default` impl preserves the
  default-derive call sites in `ergo-chain-generation` and the
  proptest `Arbitrary` impl.
- Rewrite `has_valid_connections` to walk a `[max(0, idx - lookback), idx)`
  window for each prefix entry, exactly matching the Scala range
  semantics. Suffix-tail check is unchanged.
- Add three regression tests:
  1. `accepts_skipped_prefix_entry` — synthesizes a chain whose
     middle prefix entry skips its immediate predecessor but connects
     to a 2-back interlink; tolerant verifier must accept.
  2. `rejects_too_far_skip` — same shape, but with `use_last_epochs`
     squeezed to `0` so the only valid backward link sits outside the
     lookback window; verifier must still reject. Proves the fix is
     not a blanket accept-all.
  3. `rejects_broken_suffix_tail` — sanity check that the
     suffix-side parent_id chain is still strictly enforced.
Adds two tests to ergo-nipopow/src/nipopow_algos.rs covering
NipopowAlgos::pack_interlinks:

- pack_interlinks_keys_are_first_occurrence_positions: asserts that the
  ExtensionKV key[1] for each duplicate-run encodes the input-vector
  index of the run's first element (JVM Ergo encoding), not a sequential
  distinct-group counter. Test currently FAILS against the pre-fix
  pack_interlinks which emits sequential ix_distinct_block_ids.

- pack_interlinks_empty_returns_empty: pins the no-panic behavior on
  empty input.

Verified empirically against mainnet block 1784124 nipopow proof
(captured 2026-05-13): the JVM-compat key encoding makes 11/11 leaf
hashes in interlinksProof.indices match against an external TS port.
The pre-fix sequential encoding matches only 2/11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ition

Replaces the sequential `ix_distinct_block_ids` counter with the
input-vector index of each duplicate-run's first element. Matches JVM
Ergo's `NipopowAlgos.packInterlinks` (ergoplatform/ergo,
`src/main/scala/org/ergoplatform/modifiers/history/popow/NipopowAlgos.scala`)
which encodes ExtensionKV keys as
`[INTERLINK_VECTOR_PREFIX, position_of_first_occurrence_in_interlinks_vector]`.

## The bug

`unpack_interlinks` filters by `key[0] == INTERLINK_VECTOR_PREFIX` only
and never reads `key[1]`, so sigma-rust round-trips its own buggy
output internally. The divergence is observable only at the Merkle-leaf
hash level: a kv-leaf's hash depends on the full kv-bytes including
`key[1]`, so a sigma-rust-packed leaf hashes differently from a
JVM-packed leaf.

Concrete impact:
- `PoPowHeader::check_interlinks_proof` FAILS on real mainnet proofs
  because the expected Merkle root it computes (via the buggy pack)
  doesn't match the root the proof's walk-up reaches (built from
  JVM-packed leaves).
- `NipopowProof::is_better_than` rejects all real mainnet proofs as
  "invalid" because it calls `is_valid()` → `has_valid_proofs()` →
  `check_interlinks_proof`, which fails for any block with at least
  one duplicate-run starting past position 1 (≈ every mainnet block).

## Validation

Verified against mainnet block 1784124 nipopow proof (captured
2026-05-13 via ergo-node-rust): with this fix, all 11 leaf hashes in
the proof's interlinksProof.indices match the JVM-generated leaves
exactly. Pre-fix matches only 2 (positions 0 and 1 happen to align
because distinct_ix=0,1 also = first_pos=0,1 for the first two runs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sigma-state restricts tuples to exactly 2 elements: `Tuple.eval` rejects
`items.length != 2` with "Invalid tuple" (values.scala: "in v5.0 version we
support only tuples of 2 elements to be equivalent with v4.x"). The check is
unconditional (no version gate); sigma-state 6.0.3 is JIT-only and evaluates
every height through it.

sigma-rust models tuples as flat N-ary (`TupleItems = BoundedVec<2,255>`) and
its `Tuple` eval had no arity check, so it accepted arity>=3 tuples the JVM
rejects — a consensus accept/reject divergence: sigma-rust would accept a spend
the JVM rejects (crafter-constructible). Surfaced by the santa eval fixture
`tuple_triple_bool_byte_short` (tree 0086030101020703a413): JVM rejects "Invalid
tuple"; sigma-rust accepted Tuple[true, 7, 1234].

Mirror the JVM: reject `items.len() != 2` at eval. No mainnet block can carry an
arity>=3 tuple (the JVM rejects them), so this cannot regress historical sync.
Regression test added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
powHit computes the Autolykos-2 PoW hit value (a big integer), so its
result type is UnsignedBigInt — matching Scala SGlobalMethods.powHit and
the interpreter's POW_HIT_EVAL_FN, which already yields
Value::UnsignedBigInt. The descriptor mis-declared SBoolean, so
coll.map(x => Global.powHit(..)).exists((u: UnsignedBigInt) => ..) failed
parse-time type-checking ("Invalid condition tpe"), wedging a full node
off testnet at canonical block 28,474.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TransactionContext::validate summed input and output box values with
plain u64 sums, so an overflowing aggregate panicked in debug builds
and wrapped in release builds before BoxValue::new could reject it —
the input check misfired and the output sum had no check at all. The
reference implementation sums with Math.addExact over longs
(ErgoTransaction.validateStateful), trapping every addition.

Sum with i64 try_fold/checked_add — the same idiom validate_stateless
already uses — returning InputSumOverflow/OutputSumOverflow. This also
drops the spurious lower-bound check the BoxValue::new construction
applied to the aggregate (the reference bounds boxes, not the sum).

Closes #881.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tion

The JVM reads the Option DATA tag through getOption (sigma
Extensions.getOption): `if (tag != 0) Some(getValue) else None`. Our
sigma-ser get_option mapped only tag 1 to Some and silently returned None
on any other nonzero tag WITHOUT consuming the value bytes, desyncing the
stream — a v3 tree carrying an SOption constant with DATA tag 0x02 failed
to parse where the JVM evaluates it to Some(5).

Fix the shared reader helper to mirror the convention; put_option still
writes 0/1, so the write side is unchanged. The only other caller
(ergo-p2p peer-spec parsing) aligns with the same scorex reader
convention.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ContextExtension keys are a signed Byte JVM-side. ErgoLikeContext
.toSigmaContext builds the contextVars as new Array(maxKey+1) indexed by
the signed key (ErgoLikeContext.scala:140-146), so a self-box extension
carrying a key >= 0x80 (signed-negative) crashes context construction
(NegativeArraySizeException / ArrayIndexOutOfBoundsException) BEFORE any
bytecode runs — the spend never validates. The crash is on the key's
PRESENCE, independent of whether the script reads it.

sigma-rust stores extension keys as unsigned u8 and resolves them lazily
at GetVar, so it would otherwise ACCEPT a >= 0x80 key where the JVM
rejects — a consensus fork on the attacker-supplied spending extension
(the node feeds the spending input's extension into ctx.extension before
reduce_to_crypto). Reject any self-extension key outside 0..=127 at the
reduction boundary, mirroring the construction-time crash as a clean
rejection. This is the top-level self extension only; the per-input
getVarFromInput path masks the id with & 0xff by design and is untouched,
and a GetVar of a >= 0x80 id with the key absent still resolves to None.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… gate

The JVM `HeaderWithoutPow` reads `version` as a signed `Byte` and gates the
`unparsedBytes` region on `version > 1`. sigma-rust compared `version` as
`u8`, so a version byte 0x80 (-128 signed) was seen as 128 > 1: the parser
consumed `unparsedBytes`, shifting the AutolykosSolution parse and yielding a
different `minerPk` than the JVM (which skips the region) -- a deserialization
fork vs sigma-state 6.0.3.

Compare the signed interpretation `(version as i8) > 1` at the parse and
serialize gates. Versions 1/2/3 are positive, so signed == unsigned; only
version >= 0x80 changes, matching the JVM. The two `version > 1` sites in the
arbitrary proptest generator are left unchanged: it emits only versions {1,2},
and one is a v2-field nulling gate (`!= 1`), not an unparsedBytes gate. Adds a
regression test on the 0x80/0x7f witness headers (minerPk infinity vs real).
fix: Option DATA parser treats any nonzero tag as Some, per JVM getOption
fix: read Header version as a signed Byte at the unparsedBytes gate (JVM parity)
Reject self ContextExtension keys >= 0x80 (JVM context-construction parity)
fix: reject non-pair tuples at eval to match sigma-state consensus
fix(ergotree-ir): Global.powHit returns UnsignedBigInt, not Boolean
…checkType)

The JVM checkTypes each Tuple item (values.scala:801/804) via
SType.isValueOfType, which rejects a tuple type of arity != 2 (and a function
type of arity != 1) with "Unsupported tuple type" (SType.scala:200-205).
sigma-rust models flat N-ary tuples (TupleItems 2..=255), so an arity-3 tuple
carried as a constant (the item of a valid pair) evaluated where the JVM
rejects — a consensus accept/reject divergence.

Add check_value_type mirroring isValueOfType's reachable rejections and call it
at the Tuple-eval items. Placeholder constants are substituted into the body
before eval on this branch, so the segregated case reduces to the same inline
Tuple-item case; the eni cherry-pick additionally guards ConstantPlaceholder
eval (where eni resolves placeholders lazily).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirror the JVM order: checkType before evaluating each item
(values.scala:801/804). Aborts earlier if the type is unsupported
rather than doing the eval work first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fix: reject unsupported (arity != 2) tuple-typed values at eval (JVM checkType)
@kushti
kushti requested a review from sethdusek July 9, 2026 15:32
kushti and others added 8 commits July 15, 2026 13:29
fix: checked ERG summation in stateful transaction validation
…okback

ergo-nipopow: tolerate skipped prefix entries in has_valid_connections
…compat

fix(nipopow): pack_interlinks key encoding — use first-occurrence position
…roof

ergo-nipopow: validate the initial proof before selection
`ergo-nipopow/src/lib.rs` sets `#![deny(clippy::expect_used)]` crate-wide,
so `cargo clippy --all-targets -- -D warnings` fails on v0.30.0 at the two
`curr_first_pos.try_into().expect(..)` casts in `pack_interlinks`.

Annotate both sites, matching the existing site-level
`#[allow(clippy::unwrap_used)]` usage elsewhere in this file. No behaviour
change — this only restores the style gate.
`cargo fmt --check` fails on v0.30.0: develop is clean, so the drift
arrived with the nipopow merges. Formatting only, no behaviour change.
style: restore fmt and clippy gates on v0.30.0 (ergo-nipopow)
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.

3 participants