Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,73 @@
# Changelog

## v0.8.1 β€” 2026-08-18

### Release summary

- Fixed: `CONTEXT.headers` exposed ten preceding headers where the reference node exposes nine, so a block every JVM node rejects could be accepted here.
- Fixed: the mempool and mining-candidate contexts also exposed ten; all three paths now expose nine.
- Fixed: a node that had spent its unknown-parent request budget could never ask for a missing parent again, leaving it stuck on an orphaned tip.

### Fixed

#### `CONTEXT.headers` exposes nine headers, not ten β€” consensus

The JVM keeps two things under similar names. `lastHeaders` holds ten entries
and **includes the block's own header at the head**
(`newHeaders = header +: lastHeaders.take(LastHeadersInContext - 1)`).
`sigmaLastHeaders` β€” what a script actually sees as `CONTEXT.headers` β€” is
`lastHeaders.drop(1)`, so **nine**; the dropped entry is the block itself.
`UpcomingStateContext` overrides that with the whole list, which is why
candidate assembly and mempool prediction saw ten.

Our window holds headers *strictly preceding* the block, because the block's
own header goes in the preheader and is never in the slice. So the JVM's
`drop(1)` never meant "drop something here" β€” it meant **take nine instead of
ten**. We took ten, on all three paths. `headerChainBack(10, …)` was cited in
the source as parity evidence; it gathers `lastHeaders`, not
`sigmaLastHeaders`, and that citation is what made ten look correct.

A script reading `CONTEXT.headers(9)` therefore evaluated fine here and threw
`ArrayIndexOutOfBoundsException` on every JVM node. The divergence was
accept-side β€” this node would follow a chain the network orphans, with nothing
in its logs reporting a problem.

Found when the same asymmetry took mainnet block production down on
2026-08-18: on the JVM, candidate construction validated at ten and the
completed block at nine, so a script reading `headers(9)` passed the first and
failed the second, and the transaction was pushed back into the mempool and
re-selected indefinitely. This node was self-consistent at ten across all three
paths and so could not hit that failure mode, but its block validation was one
header more permissive than consensus. All three paths are now nine, matching
the agreed cross-client resolution.

**No resync is required.** A canonical block cannot contain a script the
reference node rejects, so nothing this node accepted depended on the tenth
header. A script that merely read `CONTEXT.headers.size` would have produced a
different state root and failed the existing state-root check loudly; that has
not happened.

#### Unknown-parent requests are an in-flight limit, not a lifetime budget

When a header arrives whose parent is unknown, the validation pipeline buffers
it and asks peers for the parent, bounded to three concurrent requests so a
batch of orphans cannot fan out into a request storm. The set backing that
bound was only ever inserted into β€” never cleared when a parent arrived, never
expired when none ever did. Three was therefore a budget for the lifetime of
the process, not a limit on requests in flight.

Once spent, the node could no longer ask for a missing parent at all. It then
recovered only if some peer volunteered the header unprompted, which is a
matter of which peers it happens to be connected to.

Observed on 2026-08-18: a node that woke from suspend holding an orphaned tip
at height 1,853,471 sat at that height for 1h48m β€” receiving headers it could
not attach and discarding them β€” until a peer finally announced the one header
it was no longer able to request.

Request slots are now released when the parent arrives, and expire after 60
seconds when it never does.

## v0.8.0 β€” 2026-08-13

<!--
Expand Down
20 changes: 10 additions & 10 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ exclude = ["addons/fastsync", "addons/indexer"]
# with their own lockfiles. That is deliberate β€” do not "fix" it by pulling
# them into the workspace.
[workspace.package]
version = "0.8.0"
version = "0.8.1"

[package]
name = "ergo-node-rust"
Expand Down
13 changes: 9 additions & 4 deletions facts/mining.md
Original file line number Diff line number Diff line change
Expand Up @@ -482,11 +482,16 @@ crate owns the assembly:
transaction against a context built by `build_state_context(stub,
preceding_headers, parameters)`. Built from the parent alone that context
exposes a **one-header** `CONTEXT.headers` window, where block validation
exposes up to ten. A script reading `headers[5]` would then fail *during
exposes nine. A script reading `headers[5]` would then fail *during
selection* and the transaction would be evicted from the mempool as invalid β€”
a valid transaction destroyed by a selection-only artefact. Pass
`chain.headers_from(parent.height - 9, 10)` reversed, minus the parent; the
parent is prepended internally.
a valid transaction destroyed by a selection-only artefact.

⚠ **The window is nine, not ten** β€” see `facts/validation.md` Β§ "Window size:
`CONTEXT.headers` is 9 for a block, never 10". Selection must predict with the
same window block validation judges with, or it packs transactions the block
that carries them will be rejected for. Pass
`chain.headers_from(parent.height - 8, 9)` reversed, minus the parent; the
parent is prepended internally, giving nine total.

`generate_candidate` returns `GeneratedCandidate { block, work, invalid_txs }`.
⚠ **`invalid_txs` MUST be routed to the mempool for eviction** or Step 3.6 is
Expand Down
2 changes: 1 addition & 1 deletion facts/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ info:
# Tracks the node version. Bump with the workspace version at release β€”
# it sat at 0.6.5 through six releases because nothing tied the two
# together. Moves with Cargo.toml, the addons, and their lockfiles.
version: 0.8.0
version: 0.8.1
description: |
REST surface exposed by the `api/` crate. Most endpoints aim for byte- or
shape-compatibility with the JVM reference node (`ergoplatform/ergo`) so
Expand Down
75 changes: 75 additions & 0 deletions facts/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,81 @@ pub fn build_upcoming_state_context(
) -> ErgoStateContext;
```

### Window size: `CONTEXT.headers` is 9 for a block, never 10

**`build_state_context` must expose exactly 9 preceding headers.** This is a
consensus rule, not a tuning choice.

The JVM keeps two different things under similar names, and conflating them is
what produced the 2026-08-18 divergence:

| JVM name | Size | What it is |
|---|---|---|
| `lastHeaders` | 10 | internal list, **includes the block's own header at the head** |
| `sigmaLastHeaders` | **9** | what a script sees as `CONTEXT.headers` |

`ErgoStateContext.scala`:

- L233 β€” `newHeaders = header +: lastHeaders.take(LastHeadersInContext - 1)`.
Appending block `B` yields `[B, h-1 … h-9]`: ten entries, `B` at the head.
- L87 β€” base class: `sigmaLastHeaders = lastHeaders.drop(1)` β†’ `[h-1 … h-9]`,
**nine**. The dropped entry is `B` itself.
- L46 β€” `UpcomingStateContext` overrides `sigmaLastHeaders` with the whole
`lastHeaders`, **no drop** β†’ ten. There is no block of its own to drop.

Our builders take headers **strictly preceding** the block β€” the block's own
header goes in the preheader, never in the window. So the JVM's `drop(1)` does
not translate to dropping anything on our side; it translates to **taking nine
instead of ten**. `headerChainBack(10, …)` gathers `lastHeaders`, not
`sigmaLastHeaders`, and citing it as parity evidence for a 10-header window is
the specific error to avoid.

#### Caller obligations

| Path | `CONTEXT.headers` | Consensus |
|---|---|---|
| block validation (`build_state_context`) | **9** | **yes** |
| mining candidate assembly | 9 | no |
| mempool / API (`build_upcoming_state_context`) | 9 | no |

Only the first is consensus. The other two are set to 9 **deliberately**, so a
transaction can never be admitted to the mempool or packed into a candidate and
then rejected by the block validation that must follow it. A path that predicts
with a wider window than the one that judges is the exact shape of the JVM
incident below.

⚠ **This is convergence with the JVM, not a divergence from it.** The agreed
resolution is nine on every path β€” kushti, 2026-08-18: *"There must be 9 plus
preheader everywhere."* The JVM is aligning `UpcomingStateContext` down to nine
rather than widening full-block validation to ten, which would have needed
coordinated protocol activation.

Until that lands, a transaction reading `CONTEXT.headers(9)` is still accepted
by a JVM mempool and refused by ours. Ours is the safe side of a transient gap:
that transaction cannot be mined into a block any node will accept, so refusing
it early costs nothing.

#### Why this matters

A script reading `CONTEXT.headers(9)` or branching on `CONTEXT.headers.size`
sees a different chain depending on which client validates it. With a 10-header
window we **accept a block every JVM node rejects** with
`ArrayIndexOutOfBoundsException`, and follow a chain the network orphans. The
divergence is accept-side, which is the dangerous direction: nothing in our logs
reports a problem.

Observed on mainnet 2026-08-18 (JVM block-production incident at ~1853471–4,
reported by kushti): a script at `b44970ed…` reads `headers(9)`, so it passed
JVM candidate construction at ten and threw on the completed block at nine. Our
node was 10/10/10 β€” internally consistent, so it could not hit the JVM's
candidate-vs-block failure, but its full-block validation was one header more
permissive than consensus. It was spared only because it never obtained the
block body in question.

`sigma-rust` is **not** the constraint: `Headers = BoundedVec<Header, 1, 10>`
permits one through ten and enforces nothing about which. The window size is
entirely the caller's contract.

### Why two

An unconfirmed transaction is not a member of the chain tip β€” it is a candidate
Expand Down
Loading
Loading