Skip to content

Make tournament events authoritative, and teach the node to follow them - #274

Merged
GCdePaula merged 26 commits into
next/3.0from
feature/node-reader-update
Aug 16, 2026
Merged

Make tournament events authoritative, and teach the node to follow them#274
GCdePaula merged 26 commits into
next/3.0from
feature/node-reader-update

Conversation

@GCdePaula

@GCdePaula GCdePaula commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

This PR redraws the contract-client boundary around one rule: each fact should have one production authority.

The tournament event stream now owns enumerable history and recursive structure: commitments, matches, child tournaments, and contract-authored match-elimination schedules. Narrow point views remain only where events are insufficient: tournament standing, the validator's one active match path, and terminal bond recovery. The Rust node builds those sources into one recursive Dispute instead of maintaining parallel event and view projections of the same state.

The result removes the old O(matches) point-read scan. Cleanup of clock-bearing matches is derived directly from events; tournament cleanup needs at most one standing read per relevant tournament; and match-detail reads follow only the Hero's recursive path.

This PR also retains two earlier pieces of the same contract-client campaign:

  • bond recovery is an explicit permissionless action with a capability view, a winner bounty, and forfeited funds burned; and
  • the rejected-input revert proof now reports the state the machine actually applies, fixing a bug that could make the honest node forfeit every stf_revert-shaped dispute.

Why this shape

The earlier observer made both events and views describe candidate placement, live matches, clocks, phases, and recursive topology, then reconciled them on every tick. That was useful while the event model was incomplete, but it was not a good permanent abstraction: both answers came from the same contract and provider, so their agreement did not prove log completeness or provider honesty. It did add linear RPC work and a second authority for every fact.

The completed design keeps the useful boundaries while deleting that redundancy:

  • Events own history and structure. Solidity mappings do not enumerate tournaments or matches, so creation, advancement, sealing, deletion, and child-creation events form the recursive dispute.
  • Contracts author cleanup deadlines. MatchCreated, MatchAdvanced, and LeafMatchSealed carry the inclusive block at which that match becomes eliminable. A later response replaces the prior schedule; deletion cancels it.
  • Views answer only irreducible current questions. tournamentStanding() handles tournament closure and carryover; match views are read only along the validator's engaged path; bondRecovery() classifies terminal recovery.
  • Mutators remain the final legality check. Observation may become stale or another validator may win a race. The contract still revalidates every submitted action.

We deliberately keep the dispute in memory. The node persists the finalized raw event prefix and its cursor, reconstructs on restart, and retains one active recursive value. Moving this projection into SQLite, adding persistent collections, or creating aggregate contract views would add machinery before measurements justify it.

Finalized Solid and Latest Foam

The reader maintains one finalized Solid dispute. Each tick it advances that prefix, commits the recognized logs and cursor, deep-clones the result, and recursively extends the clone through a sampled Latest height to obtain disposable Foam.

Foam is never promoted, reverse-applied, compared with the previous tail, or persisted. The next tick derives it again from Solid. A reorg or mixed latest response may suppress or delay one eager action, but it cannot mutate the finalized prefix.

The action policy follows the consequence of being wrong:

  • joins derive their exact tournament and commitment from Solid; Latest may only suppress work that is already mined or no longer available;
  • sentry votes and settlement content derive from finalized state;
  • deadline-sensitive dispute responses and permissionless cleanup use Foam;
  • recovery walks finalized tournament trees at a low cadence and may use Latest only to suppress an already-mined recovery, never to retire an epoch.

Every mutation shares one exclusive-signer transaction lane. A Hero action outranks cleanup; a tick emits at most one of them. Recovery runs only when no dispute or settlement mutation is ready, so maintenance cannot occupy a nonce needed by defense. Pending recoveries are rediscovered across epoch rotation and restart without a durable queue.

Contract and compatibility changes

This is a coordinated, version-paired contract/client change:

  • the observer surface is part of ITournament;
  • ten legacy raw views are removed in favor of typed semantic results;
  • innerResult() replaces the former result pair;
  • CommitmentJoined.commitment is indexed;
  • match lifecycle events now carry eliminableAt;
  • leaf sealing has its missing structural event, LeafMatchSealed, and an event counter;
  • bondRecovery() exposes the same terminal classification used by tryRecoveringBond().

Storage layout and raw Match and Clock encodings are internal to a deployment generation: these contracts are not upgradeable, and no supported client reads raw slots. leafMatchSealedCount therefore lives with the other event counters, while the Solidity inspector and the one E2E clock probe follow the resulting layout. The storage fingerprint records implementation impact; it is not a promise to preserve slot numbers.

This is an explicit fresh-deployment cutover. The immutable clone payload changes from levels: uint64 to kind: TournamentKind; the MatchCreated and MatchAdvanced signatures change, indexing CommitmentJoined.commitment changes its topics/data layout, and LeafMatchSealed is new. A fresh Tournament implementation, MultiLevelTournamentFactory, and dependent Dave bundle must deploy with matching bindings, Rust and Lua clients, and artifacts. Old and new implementations, factories, clones, event layouts, and persisted logs must not be mixed, and no live dispute crosses the cutover. There is no dual decoder.

As a final repository-layout cleanup, the two papers now live together under docs/papers/, while the repository-wide E2E harness lives under test/e2e/; package-local tests remain with their components. Git records both PDFs and all 41 harness files as renames, preserving their history.

Diff distribution (approximate)

Counts are grouped by subsystem from next/3.0...HEAD; renames and cross-cutting files make the categories approximate.

Category Files Insertions Deletions Review note
Solidity implementation 9 +733 -360 Small, security-critical contract and consensus surface
Solidity tests 28 +1,977 -136 Observer, accounting, lifecycle, properties, and gas
Rust implementation 42 +11,901 -3,185 Recursive dispute, reader, observer, Hero, lane, recovery, and STF fix
Rust tests and fixtures 9 +135 -24,129 Large deletion is the retired chain recordings and second-fold oracle
Lua implementation 16 +4,764 -731 Coordinated semantic client and event-schedule support
Lua tests 12 +4,444 0 Domain, fold, planner, adapter, and actor coverage
E2E harness and scenarios 43 +1,018 -208 History-preserving relocation plus GC, timeout, recovery, and sybil scenarios
Docs and guidance 48 +2,254 -3,923 Living architecture, active-plan pruning, review evidence, and agent routing
Tooling, build, and artifacts 20 +982 -109 CI, fingerprints, devnet generation, and gates
Total 227 +28,208 -32,781

Review guide

The Solidity implementation is the protocol trust boundary: result-selection defects can become consensus failures, while clock or accounting defects can break liveness and resource bounds. The Rust node is a separate validator-safety boundary: a bad event transition, deadline decision, commitment, or proof can forfeit a dispute the honest validator should win.

Solidity first

Read docs/dispute-game.md, docs/prt-refund-accounting.md, and prt/contracts/AGENTS.md beside ITournament.sol, Tournament.sol, and MatchClocks.sol.

Questions to verify
  • Does every emitted eliminableAt equal the mutator's timeout boundary, inclusively, after creation, advancement, and leaf sealing?
  • Can any response leave a stale schedule authoritative, or can deletion fail to cancel one?
  • Does LeafMatchSealed complete the event history, with every white-box slot probe following the intentionally internal layout?
  • Does tryRecoveringBond() preserve its revert, no-op, retry, payment, and burn behavior arm-for-arm with bondRecovery()?
  • Do refunds, winner payment, and burn still conserve the tournament balance, including rounding and failed recipient callbacks?
  • Are the revised gas allocations sufficient for the additional event and counter write?

Recursive node model

Read docs/plans/recursive-dispute-reader.md and docs/node-architecture.md, then review tournament/dispute.rs, reader.rs, and observer.rs.

Questions to verify
  • Is each event transition strict, block-atomic, and sufficient to reconstruct candidate placement, live matches, and child ownership?
  • Can a child address enter the tree except through NewInnerTournament from an already trusted parent?
  • Does recursive discovery handle a child created and used in the same block?
  • Can a failed or inconsistent Latest load mutate Solid?
  • Are resolved children retained for recovery but excluded from live Hero and cleanup traversal?
  • Does match GC require no point reads, tournament GC remain bounded by tournament count, and Hero hydration read only one recursive path?
  • Does the narrow observer convert ABI values into checked domain values without reintroducing whole-tree reconciliation?

Action policy, recovery, and STF

Review the Hero actor/planners, epoch manager, recovery planner, transaction lane, and the revert path in engine/.

Questions to verify
  • Does an exact join intent agree between Solid and Foam before submission?
  • Can cleanup or recovery ever sit ahead of clock-bearing Hero work?
  • Can Latest suppress recovery without permanently retiring an epoch?
  • Do old pending recoveries survive epoch rotation and restart?
  • Does every proving operation apply the same state transition as its non-proving twin, particularly the rejected-input revert?

Validation

On the final local candidate, the following pass:

  • just check
  • just prt-contracts::test-all: 272 dispute tests, 3 STF FFI tests, and 2 fuzzy STF tests with 256 runs each
  • just test-prt-gas: 18 PRT gas tests and 12 leaf-proof FFI gas tests
  • just rollups-contracts::test: 5 tests
  • contract coverage: 245 tests, 98.59% line coverage, and 100% function coverage
  • just test-lua-client: 62 tests
  • just lint-lua
  • just rollups-tests::test-sealed-leaf-timeouts
  • 13 focused recursive-reader regressions and all-target Rust Clippy with warnings denied
  • just test-rollups-echo
  • full just test-rollups-honeypot
  • just prt-contracts::compatibility-hashes
  • just rollups-contracts::build-devnet
  • git diff --check

The compatibility report confirms the intended wire ABI, records the storage and deployment-bytecode impact, and places the Tournament runtime below the EIP-170 limit. The regenerated devnet bundle fingerprints the new deployment generation.

Out of scope

  • persisting the materialized dispute or Foam in SQLite;
  • a Safe confirmation tier, latest rollback detector, or Foam promotion;
  • aggregate or Multicall-shaped protocol APIs before RPC measurements justify them;
  • tournament-level elimination schedules and fully event-derived bond recovery;
  • decoding event topics from the previous Tournament deployment;
  • changing tournament level coordinates;
  • the emulator/solidity-step upgrade and block-builder submission backend.

Make the contracts authoritative for match phase, orientation, clocks,
timeouts, and tournament results through total typed observer views,
while retaining the event fold for history-derived structure.

Introduce rich Rust and Lua domain models, strict pinned adapters, pure
Hero and GC planners, late intent fulfillment, and single-mutation
dispatch.

Persist finalized event progress independently and rebuild the latest
tail as disposable range-fetched state. Pin semantic reads to the
sampled head and rely on contract mutators to revalidate stale or raced
work.

Replace receipt-driven submission with an exclusive-signer,
fire-and-forget transaction lane at the latest mined nonce, supporting
exact rebroadcast and bounded fee-bumped replacement.

Align sealed-leaf timeout handling across the contracts and both
clients, close fail-closed validation gaps, and add exact-boundary,
replacement, and recovery evidence.
- establish scoped agent guidance and reorganize living engineering docs
- make devnet and machine artifacts self-verifying across setup and tests
- align harness and measurement provenance with current workflows
Merge the semantic observer into ITournament and retire the legacy
surface: the ten raw views are gone, arbitrationResult yields to
tournamentStanding in DaveConsensus, and the parent protocol pair
becomes one typed innerResult that maps the inner winner to its
contested parent commitment and carries the carryover allowance as a
duration. CommitmentJoined indexes its commitment, and the event
counters stay as documented fetch-pruning and range-integrity
affordances.

Reconcile the rebased observer campaign onto the merged contract
review, make the observer methods thin compositions over promoted
MatchClocks and Match helpers, and give standing, elimination, and
propagation one finished-instant and winner-expiry authority.

Tests reach retired raw state through a vm.load inspector pinned to
the storage layout, keeping the suites raw-layout witnesses while
closure predicates become independent oracles. Both clients migrate
in kind, the Rust legacy-shadow scaffolding is deleted, the gc
scenarios pin their adversarial pairing by construction, and the
chain-recording oracles are regenerated. Gas allocations are
unchanged; witnesses re-pin the smaller runtime's interim headroom.
Result staging moves no value: tryRecoveringBond is an explicit,
permissionless action, removing the last value-moving call from the
progress and settlement paths. Terminal recovery pays one bond plus a
tenth of the forfeited residual and burns the other nine tenths,
keeping the recycling bound at ninety percent of the pooled reserves
while giving defenders a bounty that is zero in undisputed operation.

Restate terminal conservation and the anti-recycling argument, rework
the staging tests to prove acceptance advances before any recovery,
and record the node-side recovery action and the self-healing
batch-submission lane as a later campaign.
Close campaign steps 9 and 10: the plan and decision log become frozen
provenance, the dashboard records the executed retirement and the
declined aggregate views, and the living specification stays in
dispute-game.md and node-architecture.md. The node-architecture text
drops its reference to the deleted raw-getter shadow.

Serialize the STF FFI recipes with one forge thread: the machine
snapshot helper is not safe for concurrent writers sharing one
scratch cache, the same footgun the leaf-gas runner already guards.
The proving verbs claimed "prove without applying" for the revert
check, so prove_transition reported the discarded rejected state as
the closing slot's post-transition hash while the builder emits the
restored checkpoint as that leaf. The hero's pre-send check then
vetoes its own winLeafMatch every tick (PostStateMismatch) and the
honest node forfeits stf_revert disputes by clock.

Apply the revert after building the witness, in all three encodings
of the misconception: the production prover, the toy model, and the
prototype differential oracle (blind here because both proof paths
were wrong the same way). Pin the shape in-crate with a yield-image
test asserting builder, prover, and prototype agree that the closing
leaf is the restored pre-feed state.
tryRecoveringBond was the one mutator left without a capability-view
twin: its consumer had to rebuild the gate from the standing arm, a
joined-commitment inference, and the balance, with the winning
claimer unobservable. Factor the classification into one private
view shared by the mutator and the new bondRecovery() function -
TOURNAMENT_RUNNING and NO_WINNER are the revert arms, RECOVERED the
no-op arm, RECOVERABLE carries the claimer and the payment a
successful recovery transfers. Emit BondRecovered on the terminal
payment, completing the economics surface next to PartialBondRefund.

No storage change (layout hash unchanged); ABI and bytecode hashes
move by exactly the added view and event. Behavior of the mutator is
preserved arm for arm, pinned by three lifecycle tests including the
rejected-payment retry.
Record the review-round resolutions: settlement stays off the wave
(exactly-once steps whose content derives from finalized data, owning
the base nonce while the wave fills above), fees go fully stateless
(fresh market quote every tick; the recorded 1.1x last-sent guard is
dropped as a compounding hazard under wave reshuffles, its corner
covered by estimator headroom and clock allowances; no dedup memo
either - the mempool or builder already arbitrates duplicates and
replacements, so the lane carries zero mutable state), and recovery
planning becomes stateless over chain reads (CommitmentJoined
submitter logs for discovery, the bondRecovery view for capability,
an in-memory scan frontier for termination). Block-builder submission
is the expected production transport; correctness never depends on
builder trust.
A concurrent build-devnet rebuilds the bundle in place, and the node's
blockchain_reader tests read it through bare canonicalize().unwrap() -
a race that presented as an unnamed ENOENT panic. The fingerprint
already brackets the rebuild as a completeness marker; make the
bracket airtight (drop it before the first artifact deletion) and
have the test helpers check it before resolving paths, failing with
the build-devnet/doctor pointer instead of a stack trace.

Also ignore the stf FFI gate's stray prt/contracts/logs litter where
it falls: the fingerprint's hash_contract_files uses git ls-files
--exclude-standard, so one ignore entry retires both of that file's
failure arms (fingerprint staleness and accidental commit).
Delete the replacement slot and its fee machinery: the retained
floor, the 12.5% bump ratchet, the rebroadcast fingerprint, and the
underpriced retry loop. The mempool (or block builder) is the
authority on duplicates and replacements; the lane reads the mined
nonce at latest, quotes the market fresh, signs, sends, and reports
the pool's verdict - "already known", "replacement underpriced", and
a stale nonce are benign, and callers re-derive their complete
intent every tick (docs/plans/self-healing-batch-submission.md).

submit_wave() lands the batch shape: consecutive nonces from the
mined count, position is priority, per-transaction verdicts never
abort the tail. The single submit() remains as the settlement path's
one-call wrapper. The anvil-backed tests now pin the new contract:
pool-side dedup, underpriced-wait instead of forced eviction, prefix
inclusion shifting the base, reorg reuse, and restart invisibility.
The one-action-per-tick constraint dies with the stateless lane. The
hero tick now yields its full wave contribution - the prepared hero
action first, then every currently legal cleanup innermost-first -
and the epoch manager concatenates the pending settlement step (base
nonce, undisputed phases only) ahead of it and submits everything
through submit_wave. Position is priority: hero-before-cleanup and
settlement-never-behind-dispute-work are nonce order now, not
arbitration.

EpochWritePlan, the one-gc-intent tick, and the hero's own submission
path are deleted; ArenaSender becomes a pure request factory and the
manager's loop owns all submission. No intra-wave dependency filter:
one live match yields at most one intent and an eliminable child's
tournament is never recursed into, so a plan cannot invalidate its
own suffix; reverts stay the safety net if a future planner change
breaks that argument.
The recovery planner rides every wave behind dispute and settlement
work: discovery is one CommitmentJoined log scan filtered by the
indexed submitter, capability is the bondRecovery() view (the same
classification tryRecoveringBond acts on), and termination is a
block cursor plus candidates retiring on terminal dispositions - all
in memory, rebuilt by a boot rescan, so losing state costs a rescan
and never a wrong action.

Discovery is permissionless and therefore spoofable, and paying a
spoofed candidate would let attacker code burn the gas limit every
tick. Candidates are verified before any send by comparing their
ERC-1167 prelude to a storage-known root tournament's, which also
authenticates the submitter topic: on a genuine clone it is the
msg.sender of a real join, which an attacker cannot forge. View
reads are free and skip verification.

multi_sybil now proves the lane end to end: after settlement the
root tournament's balance drains to zero - one bond back plus a
tenth of three forfeited sybil residuals, the other nine tenths
burned.
Oracle-anchored audit round 2 (six dimensions, adversarial
verification, completeness critic; recorded in docs/plans/
node-audit.md): the recovery scan cursor now advances on finalized
instead of the reorg-able latest head, matching the reader
convention; verify_candidates removes entries per verdict so a
transient get_code_at error no longer discards the unprocessed
remainder; the BondDisposition mirror gains the finding-4-class
source-parse pin; a Failed wave verdict logs at error level (a
stalled nonce tail while clocks run is not a warning); the prototype
oracle's revert predicate aligns to production's RX_REJECTED; and
three docs describing the deleted one-write-per-tick lane now
describe the wave. The Lua-mirror parity pass came back clean at
full granularity. Two leads recorded, not closed: the funding
envelope (pool reserves gas_limit x fee cap across the wave) and
post-inclusion revert observability.
Replace the recovery planner's discovery: instead of scanning
CommitmentJoined logs chain-wide by the indexed submitter and
verifying candidates as genuine clones before any send, walk each
unretired epoch's dispute tree root-down - the root address from our
own storage (written from the trusted DaveConsensus stream), children
from each trusted tournament's own NewInnerTournament events over
finalized blocks. One bondRecovery() read per tree node then decides;
the claimer answers "did we join and win" from the contract's own
records, so no join history is needed.

The scanned design's verification argument held under audit, but its
posture was wrong: it admitted an attacker-writable candidate set and
made a filter's correctness load-bearing forever. A candidate set
derived from the node's own knowledge needs no filter - the topic
scan, the unverified queue, and the clone-prelude machinery all
delete, and the audit's two recovery findings dissolve with them.
The rejected alternative and its lesson are recorded in
docs/plans/bond-recovery-redesign.md.
The view asserted cross-view clock shapes the timeout mutators never
check: classifyTimeoutAt is total by design and the mutators gate on
its outcome, so the view was stricter than the mutation path it
fronts. As corruption tripwires the asserts sat on the wrong side of
the read/write divide - they could not stop a corrupt transition,
only punish observers, and the node observes every live match through
this view every tick: one impossible shape would have blinded the
honest hero across every match while all clocks ran.

Classify the stored clocks as they are, and observe canonical zeros
for the one shape clock arithmetic cannot process (an uninitialized
clock, which no transition path creates). Shape invariants stay
enforced where transitions create the shapes; assertLeafRace and
assertInnerSeal delete with their only caller. The observer tests
flip from pinning the panic to pinning what the total view reports on
each odd shape. ABI and storage-layout hashes are unchanged; the
bytecode shrink cheapened the seal path across a rounding boundary,
so SEAL_LEAF_MATCH's interim retained headroom moves to 2,000
(recorded in the 2026-08-04 repin record; allocations stay frozen for
the deferred acceptance run).
Emit absolute match elimination schedules, build one recursive event-authoritative dispute, and restrict contract observations to standings and the Hero path. Retire the redundant fold and stale chain-recording oracle.
@GCdePaula GCdePaula changed the title Ossify the tournament interface, and teach the node to ride it Make tournament events authoritative, and teach the node to follow them Aug 10, 2026
@GCdePaula
GCdePaula requested review from guidanoli and stephenctw and removed request for guidanoli August 10, 2026 10:44
@GCdePaula
GCdePaula marked this pull request as ready for review August 10, 2026 10:46
Move durable invariants into living documentation, retain only active review aids, include the devnet fingerprint in release archives, and remove stale tooling references.
Comment thread prt/contracts/src/tournament/Tournament.sol Outdated
Comment thread prt/contracts/src/tournament/Tournament.sol Outdated
Move the leaf-seal counter with the event counters, confine raw slot reads to white-box test probes, and document deployment-generation compatibility.
State transaction priority as a same-tick property, explain the intentional empty lane during join finality gaps, and document successful log completeness as part of the configured RPC trust boundary. Align the Solid cursor and child-validation wording with the implementation.

@stephenctw stephenctw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went through the Solidity surface against the review guide (ITournament / Tournament / MatchClocks, plus the refund docs).

What I checked and am happy with:

  • eliminableAt lines up with the inclusive ELIMINATE_BOTH boundary for both clock shapes, and the emit/cancel path (create, advance, leaf seal, delete, inner seal) looks right
  • bondRecovery / tryRecoveringBond stay on the same arms, including the bounty formula and the failed-recipient retry case
  • tournamentStanding and innerResult share the same finish/expiry authority, so parent reads shouldn’t drift

I also re-read on the latest tip — the reserve-doc payment wording looks consistent with the code, and the earlier review threads on counter placement / named returns look settled.

Comment thread cartesi-rollups/contracts/foundry.toml
Comment thread cartesi-rollups/contracts/src/DaveConsensus.sol
Comment thread justfile Outdated
Comment thread prt/contracts/src/ITournament.sol
Expose the trusted factory parameter table without repeating total depth in tournament clones. Validate the compiled root stride and machine span before the node opens its database, and report factory deployment fingerprints.
Make the root justfile authoritative for the CI and doctor Foundry version, and have the setup action query that pin after installing Just.

@guidanoli guidanoli left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking great! Just raising one minor nit.

Comment thread justfile Outdated
Comment thread justfile Outdated

@guidanoli guidanoli left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 🚀

@GCdePaula
GCdePaula merged commit c9e3b2a into next/3.0 Aug 16, 2026
8 checks passed
@GCdePaula
GCdePaula deleted the feature/node-reader-update branch August 16, 2026 13:06
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