From 458c00c72a5cb41374eed9537c61467a4b8afe05 Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Thu, 27 Aug 2026 11:13:00 -0300 Subject: [PATCH 01/16] feat!(prt): expose finish instant in tournament standing Append finishedAt to the standing ABI, populate it from the existing finish-time authority, and update strict Rust and Lua consumers without a compatibility fallback. --- .../contracts/test/DaveAppFactory.t.sol | 3 +- .../node/src/tournament/observer.rs | 49 ++++++++++- docs/dispute-game.md | 6 ++ prt/client-lua/player/adapter.lua | 25 +++++- prt/client-lua/player/reader.lua | 4 +- prt/client-lua/tests/adapter_test.lua | 86 +++++++++++++++++++ prt/client-lua/tests/semantic_reader_test.lua | 5 ++ prt/contracts/src/ITournament.sol | 5 +- prt/contracts/src/tournament/Tournament.sol | 1 + prt/contracts/test/TournamentObserver.t.sol | 30 ++++--- .../TournamentLifecycleInvariant.t.sol | 5 +- 11 files changed, 200 insertions(+), 19 deletions(-) diff --git a/cartesi-rollups/contracts/test/DaveAppFactory.t.sol b/cartesi-rollups/contracts/test/DaveAppFactory.t.sol index f3e82aa0..646f09d5 100644 --- a/cartesi-rollups/contracts/test/DaveAppFactory.t.sol +++ b/cartesi-rollups/contracts/test/DaveAppFactory.t.sol @@ -951,7 +951,8 @@ contract DaveAppFactoryTest is ConsensusTestUtils { hasCandidate: false, candidate: Tree.ZERO_NODE, finalState: Machine.ZERO_STATE, - parentCommitment: Tree.ZERO_NODE + parentCommitment: Tree.ZERO_NODE, + finishedAt: Time.currentTime() }); vm.mockCall(address(tournament), abi.encodeCall(ITournament.tournamentStanding, ()), abi.encode(failedStanding)); diff --git a/cartesi-rollups/node/src/tournament/observer.rs b/cartesi-rollups/node/src/tournament/observer.rs index c51871db..13c31258 100644 --- a/cartesi-rollups/node/src/tournament/observer.rs +++ b/cartesi-rollups/node/src/tournament/observer.rs @@ -100,6 +100,8 @@ pub enum ObserverError { StandingJoinMismatch { standing: u8, accepts_joins: bool }, #[error("standing {standing} has invalid hasCandidate value {has_candidate}")] StandingCandidateShape { standing: u8, has_candidate: bool }, + #[error("standing {standing} has invalid finishedAt value {finished_at}")] + StandingFinishedAtShape { standing: u8, finished_at: u64 }, #[error("inner winner does not map to either side of its recursive parent match")] InnerWinnerOutsideParentMatch, #[error("live match {match_id_hash} carries an impossible child relationship")] @@ -133,8 +135,8 @@ pub async fn read_descriptor( /// event-derived tree supplies root/inner position and the exact parent match /// used to interpret an inner winner. It is not reconciled with redundant /// match-count, final-state, or topology projections. Nonterminal candidate -/// payloads are canonicality-checked and then discarded because events own -/// commitment placement. +/// payloads and finish instants are canonicality-checked and then discarded +/// because events own commitment placement and Hero acts only on current state. pub async fn read_standings( chain: &Chain, dispute: &Dispute, @@ -363,6 +365,7 @@ fn decode_standing( let standing_discriminant = wire.standing; let candidate = decode_candidate_shape(standing_discriminant, wire.hasCandidate, wire.candidate)?; + validate_finished_at_shape(standing_discriminant, wire.finishedAt)?; let standing = match standing_discriminant { 0 => { @@ -682,6 +685,22 @@ fn decode_candidate_shape( } } +fn validate_finished_at_shape(standing: u8, finished_at: u64) -> ObserverResult<()> { + let valid = match standing { + 0 | 1 => finished_at == 0, + 2..=6 => finished_at != 0, + other => return Err(ObserverError::UnknownTournamentStanding(other)), + }; + if valid { + Ok(()) + } else { + Err(ObserverError::StandingFinishedAtShape { + standing, + finished_at, + }) + } +} + fn require_terminal_shape( standing: u8, wire: &AbiTournamentStandingView, @@ -768,6 +787,7 @@ mod tests { candidate: candidate.map_or(B256::ZERO, Into::into), finalState: B256::ZERO, parentCommitment: B256::ZERO, + finishedAt: u64::from(standing >= 2), } } @@ -909,11 +929,36 @@ mod tests { ); } + #[test] + fn standing_finished_at_shape_matches_terminality() { + for standing in [0, 1] { + assert_eq!(validate_finished_at_shape(standing, 0), Ok(())); + assert_eq!( + validate_finished_at_shape(standing, 42), + Err(ObserverError::StandingFinishedAtShape { + standing, + finished_at: 42, + }) + ); + } + for standing in 2..=6 { + assert_eq!(validate_finished_at_shape(standing, 42), Ok(())); + assert_eq!( + validate_finished_at_shape(standing, 0), + Err(ObserverError::StandingFinishedAtShape { + standing, + finished_at: 0, + }) + ); + } + } + #[test] fn standing_retains_only_fields_needed_for_actions() { let root = descriptor(address(1), 0, TournamentKind::Leaf, digest(9), 0); let mut wire = standing_wire(2, false, Some(digest(77))); wire.finalState = hash(88); + wire.finishedAt = 42; assert_eq!( decode_standing(root, None, wire).unwrap(), diff --git a/docs/dispute-game.md b/docs/dispute-game.md index ccaf0ee3..a8c70eaa 100644 --- a/docs/dispute-game.md +++ b/docs/dispute-game.md @@ -444,6 +444,12 @@ duration, while `ELIMINABLE` covers both a no-winner child and an expired winner. Propagation requires `WINNER` and elimination requires `ELIMINABLE`, so the parent verbs partition exactly. +The standing also reports `finishedAt`, the block-number instant when the +tournament became safe to decide: the later of its closure deadline and its +last match deletion. It is canonically zero for `MATCHES_ACTIVE` and +`AWAITING_CLOSURE`, and remains fixed at the exact finish instant for every +terminal standing. + If no commitment remains, the tournament has finished without a winner. A root in that state settles nothing. A parent may eventually eliminate a no-winner child. diff --git a/prt/client-lua/player/adapter.lua b/prt/client-lua/player/adapter.lua index 9cfa3c64..2e0d5399 100644 --- a/prt/client-lua/player/adapter.lua +++ b/prt/client-lua/player/adapter.lua @@ -174,7 +174,7 @@ function Adapter.decode_result(view, raw) } end if view == Adapter.View.STANDING then - local words = abi_words(raw, 6, name) + local words = abi_words(raw, 7, name) return { standing = word_small(words[1], 8, name .. ".standing"), accepts_joins = word_bool(words[2], name .. ".acceptsJoins"), @@ -182,6 +182,7 @@ function Adapter.decode_result(view, raw) candidate = word_hash(words[4]), final_state = word_hash(words[5]), parent_commitment = word_hash(words[6]), + finished_at = word_small(words[7], 64, name .. ".finishedAt"), } end if view == Adapter.View.TIMEOUT then @@ -311,6 +312,27 @@ local function terminal_shape(wire, expected_candidate) end end +local function require_finished_at_shape(wire) + local finished_at = required(wire.finished_at, "standing finishedAt") + assert(type(finished_at) == "number" + and math.type(finished_at) == "integer" + and finished_at >= 0, + "standing finishedAt must be a nonnegative Lua integer") + if wire.standing <= 1 then + assert(finished_at == 0, + string.format( + "standing %d requires zero finishedAt", + wire.standing + )) + else + assert(finished_at ~= 0, + string.format( + "standing %d requires nonzero finishedAt", + wire.standing + )) + end +end + local function decode_standing( fold, tournament_fold, @@ -319,6 +341,7 @@ local function decode_standing( wire ) local candidate = candidate_shape(wire) + require_finished_at_shape(wire) local expected_candidate = fold:candidate( tournament_fold.address ) diff --git a/prt/client-lua/player/reader.lua b/prt/client-lua/player/reader.lua index ecc0a9bc..3601b284 100644 --- a/prt/client-lua/player/reader.lua +++ b/prt/client-lua/player/reader.lua @@ -341,13 +341,13 @@ end function Reader:root_tournament_winner(address) local sig = - "tournamentStanding()((uint8,bool,bool,bytes32,bytes32,bytes32))" + "tournamentStanding()((uint8,bool,bool,bytes32,bytes32,bytes32,uint64))" local ret = self:_call(address, sig, {}) assert(#ret == 1) local compact = sanitize_string(ret[1]) local standing, candidate, final_state = compact:match( - "^%((%d+),%a+,%a+,(0x%x+),(0x%x+),0x%x+%)$" + "^%((%d+),%a+,%a+,(0x%x+),(0x%x+),0x%x+,%d+%)$" ) assert(standing, "could not decode tournamentStanding") standing = tonumber(standing) diff --git a/prt/client-lua/tests/adapter_test.lua b/prt/client-lua/tests/adapter_test.lua index bc1f16fa..2a01aa32 100644 --- a/prt/client-lua/tests/adapter_test.lua +++ b/prt/client-lua/tests/adapter_test.lua @@ -37,6 +37,10 @@ end local function standing(tag, fields) fields = fields or {} + local finished_at = fields.finished_at + if finished_at == nil then + finished_at = tag <= 1 and 0 or 12 + end return { standing = tag, accepts_joins = fields.accepts_joins or false, @@ -44,6 +48,7 @@ local function standing(tag, fields) candidate = fields.candidate or Hash.zero, final_state = fields.final_state or Hash.zero, parent_commitment = fields.parent_commitment or Hash.zero, + finished_at = finished_at, } end @@ -219,6 +224,7 @@ return { hash_word(digest(2)), hash_word(digest(3)), hash_word(Hash.zero), + uint_word(42), } ) Test.equal(standing_wire.standing, 2) @@ -226,6 +232,7 @@ return { Test.equal(standing_wire.has_candidate, true) Test.truthy(Hash:is_of_type_hash(standing_wire.candidate)) Test.equal(standing_wire.final_state, digest(3)) + Test.equal(standing_wire.finished_at, 42) local timeout_wire = Adapter.decode_result( Adapter.View.TIMEOUT, @@ -301,6 +308,36 @@ return { hash_word(Hash.zero), hash_word(Hash.zero), hash_word(Hash.zero), + uint_word(0), + } + ) + end) + + Test.error_like("expected 7", function() + Adapter.decode_result( + Adapter.View.STANDING, + encoded { + uint_word(0), + uint_word(0), + uint_word(0), + hash_word(Hash.zero), + hash_word(Hash.zero), + hash_word(Hash.zero), + } + ) + end) + + Test.error_like("exceeds uint64", function() + Adapter.decode_result( + Adapter.View.STANDING, + encoded { + uint_word(0), + uint_word(0), + uint_word(0), + hash_word(Hash.zero), + hash_word(Hash.zero), + hash_word(Hash.zero), + string.rep("0", 47) .. "1" .. string.rep("0", 16), } ) end) @@ -407,6 +444,55 @@ return { end end), + Test.case("standing finish instant matches lifecycle state", function() + local root = address(1) + local responses = { + [root] = { + tournamentDescriptor = descriptor(), + tournamentStanding = standing(0, { finished_at = 1 }), + }, + } + local transport = mock_transport(responses) + Test.error_like("standing 0 requires zero finishedAt", function() + Adapter.observe_fold(transport, Fold.new(root), head()) + end) + + responses[root].tournamentStanding = standing(3, { + finished_at = 0, + }) + Test.error_like("standing 3 requires nonzero finishedAt", function() + Adapter.observe_fold(transport, Fold.new(root), head()) + end) + end), + + Test.case("legacy winner reader accepts the seven-field standing", function() + local candidate = digest(90) + local final_state = digest(91) + local reader = Reader:new("unused") + function reader._call(_reader, _address, signature, arguments) + Test.equal( + signature, + "tournamentStanding()" + .. "((uint8,bool,bool,bytes32,bytes32,bytes32,uint64))" + ) + Test.equal(#arguments, 0) + return { + string.format( + "(2, false, true, %s, %s, %s, 42)", + candidate:hex_string(), + final_state:hex_string(), + Hash.zero:hex_string() + ), + } + end + + local winner = reader:root_tournament_winner(address(1)) + Test.equal(winner.has_winner, true) + Test.equal(winner.commitment, candidate) + Test.equal(winner.final, final_state) + Test.equal(winner.finished_at, nil) + end), + Test.case("phase and tournament-kind cross-product constructs domain variants", function() local rows = { { diff --git a/prt/client-lua/tests/semantic_reader_test.lua b/prt/client-lua/tests/semantic_reader_test.lua index f813d48b..063b743a 100644 --- a/prt/client-lua/tests/semantic_reader_test.lua +++ b/prt/client-lua/tests/semantic_reader_test.lua @@ -157,6 +157,10 @@ end local function standing(tag, fields) fields = fields or {} + local finished_at = fields.finished_at + if finished_at == nil then + finished_at = tag <= 1 and 0 or 12 + end return { standing = tag, accepts_joins = fields.accepts_joins or false, @@ -164,6 +168,7 @@ local function standing(tag, fields) candidate = fields.candidate or Hash.zero, final_state = fields.final_state or Hash.zero, parent_commitment = fields.parent_commitment or Hash.zero, + finished_at = finished_at, } end diff --git a/prt/contracts/src/ITournament.sol b/prt/contracts/src/ITournament.sol index 04c007ee..05946940 100644 --- a/prt/contracts/src/ITournament.sol +++ b/prt/contracts/src/ITournament.sol @@ -169,6 +169,7 @@ interface ITournament { Tree.Node candidate; Machine.Hash finalState; Tree.Node parentCommitment; + Time.Instant finishedAt; } /// @notice A child tournament's settlement disposition for its parent. @@ -729,7 +730,9 @@ interface ITournament { /// every standing, including closed tournaments that still have active /// matches. `hasCandidate` disambiguates the zero node. `finalState` is /// populated only for `ROOT_WINNER`, and `parentCommitment` only for - /// `INNER_WINNER`. + /// `INNER_WINNER`. `finishedAt` is canonically zero while unfinished and + /// otherwise reports the exact block-number instant the tournament became + /// safe to decide. function tournamentStanding() external view diff --git a/prt/contracts/src/tournament/Tournament.sol b/prt/contracts/src/tournament/Tournament.sol index c652f147..6da1b9f8 100644 --- a/prt/contracts/src/tournament/Tournament.sol +++ b/prt/contracts/src/tournament/Tournament.sol @@ -937,6 +937,7 @@ contract Tournament is ITournament { standing.candidate = candidate; (bool finished, Time.Instant resultAt) = _timeFinished(args); + standing.finishedAt = resultAt; if (matchCount != 0) { standing.standing = TournamentStanding.MATCHES_ACTIVE; } else if (!finished) { diff --git a/prt/contracts/test/TournamentObserver.t.sol b/prt/contracts/test/TournamentObserver.t.sol index ed340449..c4f4fc56 100644 --- a/prt/contracts/test/TournamentObserver.t.sol +++ b/prt/contracts/test/TournamentObserver.t.sol @@ -266,7 +266,8 @@ contract TournamentObserverTest is Test { hasCandidate: true, candidate: candidate, finalState: Machine.ZERO_STATE, - parentCommitment: Tree.ZERO_NODE + parentCommitment: Tree.ZERO_NODE, + finishedAt: Time.ZERO_INSTANT }) ); } @@ -284,7 +285,8 @@ contract TournamentObserverTest is Test { hasCandidate: false, candidate: Tree.ZERO_NODE, finalState: Machine.ZERO_STATE, - parentCommitment: Tree.ZERO_NODE + parentCommitment: Tree.ZERO_NODE, + finishedAt: Time.ZERO_INSTANT }) ); } @@ -303,7 +305,8 @@ contract TournamentObserverTest is Test { hasCandidate: true, candidate: candidate, finalState: Machine.ZERO_STATE, - parentCommitment: Tree.ZERO_NODE + parentCommitment: Tree.ZERO_NODE, + finishedAt: Time.ZERO_INSTANT }) ); } @@ -323,7 +326,8 @@ contract TournamentObserverTest is Test { hasCandidate: true, candidate: first, finalState: Machine.ZERO_STATE, - parentCommitment: Tree.ZERO_NODE + parentCommitment: Tree.ZERO_NODE, + finishedAt: Time.ZERO_INSTANT }) ); @@ -336,7 +340,8 @@ contract TournamentObserverTest is Test { hasCandidate: false, candidate: Tree.ZERO_NODE, finalState: Machine.ZERO_STATE, - parentCommitment: Tree.ZERO_NODE + parentCommitment: Tree.ZERO_NODE, + finishedAt: Time.ZERO_INSTANT }) ); } @@ -357,7 +362,8 @@ contract TournamentObserverTest is Test { hasCandidate: true, candidate: candidate, finalState: finalState, - parentCommitment: Tree.ZERO_NODE + parentCommitment: Tree.ZERO_NODE, + finishedAt: _instant(120) }) ); @@ -370,7 +376,8 @@ contract TournamentObserverTest is Test { hasCandidate: false, candidate: Tree.ZERO_NODE, finalState: Machine.ZERO_STATE, - parentCommitment: Tree.ZERO_NODE + parentCommitment: Tree.ZERO_NODE, + finishedAt: _instant(120) }) ); } @@ -423,7 +430,8 @@ contract TournamentObserverTest is Test { hasCandidate: true, candidate: candidate, finalState: Machine.ZERO_STATE, - parentCommitment: Tree.ZERO_NODE + parentCommitment: Tree.ZERO_NODE, + finishedAt: _instant(125) }) ); } @@ -444,7 +452,8 @@ contract TournamentObserverTest is Test { hasCandidate: false, candidate: Tree.ZERO_NODE, finalState: Machine.ZERO_STATE, - parentCommitment: Tree.ZERO_NODE + parentCommitment: Tree.ZERO_NODE, + finishedAt: _instant(125) }) ); } @@ -877,7 +886,8 @@ contract TournamentObserverTest is Test { hasCandidate: true, candidate: candidate, finalState: Machine.ZERO_STATE, - parentCommitment: parentCommitment + parentCommitment: parentCommitment, + finishedAt: _instant(125) }) ); } diff --git a/prt/contracts/test/properties/TournamentLifecycleInvariant.t.sol b/prt/contracts/test/properties/TournamentLifecycleInvariant.t.sol index 52bf009b..a225784b 100644 --- a/prt/contracts/test/properties/TournamentLifecycleInvariant.t.sol +++ b/prt/contracts/test/properties/TournamentLifecycleInvariant.t.sol @@ -751,10 +751,10 @@ contract TournamentLifecycleHandler is Test { (bool timeIsFinal, Time.Instant timeFinished) = TOURNAMENT.timeFinished(); assertEq(timeIsFinal, expectedFinished); + uint64 expectedTime; if (expectedFinished) { uint64 closedAt = START_BLOCK + MAX_ALLOWANCE; - uint64 expectedTime = - _lastDeleted > closedAt ? _lastDeleted : closedAt; + expectedTime = _lastDeleted > closedAt ? _lastDeleted : closedAt; assertEq(Time.Instant.unwrap(timeFinished), expectedTime); } else { assertEq(Time.Instant.unwrap(timeFinished), 0); @@ -762,6 +762,7 @@ contract TournamentLifecycleHandler is Test { ITournament.TournamentStandingView memory standing = TOURNAMENT.tournamentStanding(); + assertEq(Time.Instant.unwrap(standing.finishedAt), expectedTime); if (!expectedFinished) { assertTrue( standing.standing From 739e3de841a7cc34e7a9259626485bbe972d7779 Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Thu, 27 Aug 2026 15:55:13 -0300 Subject: [PATCH 02/16] feat!(prt): expose inner winner final state in tournament standing The INNER_WINNER arm of tournamentStanding() now populates finalState with the winner's claimed final state, which the view already loads to map the parent commitment. Saves observers a per-tournament log query and makes the winner arms uniform. Breaking for strict observers: the field was previously canonical-zero in this arm. The Rust observer discards the value (events own commitment records; a zero claim is on-chain-representable, so no shape check), and the Lua adapter cross-checks it against the folded join record. --- .../node/src/tournament/observer.rs | 5 ++- docs/dispute-game.md | 4 +- prt/client-lua/player/adapter.lua | 7 ++- prt/client-lua/tests/adapter_test.lua | 45 +++++++++++++++++++ prt/contracts/src/ITournament.sol | 3 +- prt/contracts/src/tournament/Tournament.sol | 4 +- prt/contracts/test/TournamentObserver.t.sol | 7 +-- 7 files changed, 67 insertions(+), 8 deletions(-) diff --git a/cartesi-rollups/node/src/tournament/observer.rs b/cartesi-rollups/node/src/tournament/observer.rs index 13c31258..7ae28e88 100644 --- a/cartesi-rollups/node/src/tournament/observer.rs +++ b/cartesi-rollups/node/src/tournament/observer.rs @@ -426,7 +426,9 @@ fn decode_standing( return Err(ObserverError::StandingKindMismatch); } require_terminal_shape(standing_discriminant, &wire, true)?; - require_zero_hash("tournamentStanding", "finalState", wire.finalState)?; + // finalState carries the winner's claimed final state, which + // events own; a zero claim is representable on-chain, so no + // shape check applies and the value is discarded. let parent_commitment: Digest = wire.parentCommitment.into(); let parent_match = parent_match.expect("non-root position has a parent match"); if parent_commitment != parent_match.commitment_one @@ -981,6 +983,7 @@ mod tests { }; let mut wire = standing_wire(4, false, Some(digest(30))); wire.parentCommitment = hash(2); + wire.finalState = hash(31); assert_eq!( decode_standing(child, Some(parent_match), wire.clone()).unwrap(), diff --git a/docs/dispute-game.md b/docs/dispute-game.md index a8c70eaa..f0338bf3 100644 --- a/docs/dispute-game.md +++ b/docs/dispute-game.md @@ -437,7 +437,9 @@ time elapsed since the child finished. Root consumers read the result through `tournamentStanding()`: `ROOT_WINNER` carries the candidate and its final state, and `ROOT_FAILED` marks a finished -root without a winner. Parents read one typed `innerResult()` from their +root without a winner. Observers of an inner tournament read `INNER_WINNER` +from the same view, which carries the winning candidate, its claimed final +state, and the mapped parent commitment. Parents read one typed `innerResult()` from their recorded child instead: `WINNER` maps the inner winner back to a contested parent commitment and carries its remaining carryover allowance as a typed duration, while `ELIMINABLE` covers both a no-winner child and an expired diff --git a/prt/client-lua/player/adapter.lua b/prt/client-lua/player/adapter.lua index 2e0d5399..d36b3aaa 100644 --- a/prt/client-lua/player/adapter.lua +++ b/prt/client-lua/player/adapter.lua @@ -387,7 +387,12 @@ local function decode_standing( standing = Domain.root_failed() elseif wire.standing == 4 then terminal_shape(wire, true) - require_zero_hash("tournamentStanding", "finalState", wire.final_state) + local commitment = assert( + tournament_fold.commitments[candidate], + "inner winner candidate is absent from fold" + ) + assert(same(wire.final_state, commitment.final_state), + "inner winner final state disagrees with joined commitment record") assert(parent_match, "inner winner standing used without a folded parent match") assert(same(wire.parent_commitment, parent_match.commitment_one) diff --git a/prt/client-lua/tests/adapter_test.lua b/prt/client-lua/tests/adapter_test.lua index 2a01aa32..6d3feace 100644 --- a/prt/client-lua/tests/adapter_test.lua +++ b/prt/client-lua/tests/adapter_test.lua @@ -784,6 +784,7 @@ return { tournamentStanding = standing(4, { has_candidate = true, candidate = child_candidate, + final_state = digest(99), parent_commitment = one, }), } @@ -795,4 +796,48 @@ return { ) end) end), + + Test.case("inner-winner wire final state must agree with the fold", function() + local root = address(1) + local child = address(2) + local fold, _, one = live_fold(root, child) + local child_candidate = digest(80) + fold:apply(Fold.event( + child, + 7, + Fold.Event.commitment_joined(child_candidate, digest(99)) + )) + local parent_projection = sealed(3, { + final_state_one = digest(99), + final_state_two = digest(82), + }) + local responses = live_responses( + root, + descriptor { kind = 1 }, + 3, + parent_projection + ) + responses[child] = { + tournamentDescriptor = descriptor { + initial_hash = parent_projection.agree_state, + base_cycle = parent_projection.divergence_cycle, + height = 2, + level = 1, + kind = 0, + }, + tournamentStanding = standing(4, { + has_candidate = true, + candidate = child_candidate, + final_state = digest(98), + parent_commitment = one, + }), + } + Test.error_like("disagrees with joined commitment record", function() + Adapter.observe_fold( + mock_transport(responses), + fold, + head() + ) + end) + end), } diff --git a/prt/contracts/src/ITournament.sol b/prt/contracts/src/ITournament.sol index 05946940..1eb67297 100644 --- a/prt/contracts/src/ITournament.sol +++ b/prt/contracts/src/ITournament.sol @@ -729,7 +729,8 @@ interface ITournament { /// exactly while the tournament's global allowance has not elapsed, for /// every standing, including closed tournaments that still have active /// matches. `hasCandidate` disambiguates the zero node. `finalState` is - /// populated only for `ROOT_WINNER`, and `parentCommitment` only for + /// populated for `ROOT_WINNER` and `INNER_WINNER` with the winner's + /// claimed final state, and `parentCommitment` only for /// `INNER_WINNER`. `finishedAt` is canonically zero while unfinished and /// otherwise reports the exact block-number instant the tournament became /// safe to decide. diff --git a/prt/contracts/src/tournament/Tournament.sol b/prt/contracts/src/tournament/Tournament.sol index 6da1b9f8..fbdda9cd 100644 --- a/prt/contracts/src/tournament/Tournament.sol +++ b/prt/contracts/src/tournament/Tournament.sol @@ -956,8 +956,10 @@ contract Tournament is ITournament { TournamentStanding.INNER_ELIMINABLE_WINNER_EXPIRED; } else { standing.standing = TournamentStanding.INNER_WINNER; + Machine.Hash finalState = finalStates[candidate]; + standing.finalState = finalState; standing.parentCommitment = - _parentCommitment(args.nestedDispute, finalStates[candidate]); + _parentCommitment(args.nestedDispute, finalState); } } diff --git a/prt/contracts/test/TournamentObserver.t.sol b/prt/contracts/test/TournamentObserver.t.sol index c4f4fc56..ab89bdc1 100644 --- a/prt/contracts/test/TournamentObserver.t.sol +++ b/prt/contracts/test/TournamentObserver.t.sol @@ -401,9 +401,9 @@ contract TournamentObserverTest is Test { vm.roll(134); tournament.storeFinalState(candidate, finalOne); - _assertInnerWinner(tournament, candidate, parentOne); + _assertInnerWinner(tournament, candidate, finalOne, parentOne); tournament.storeFinalState(candidate, finalTwo); - _assertInnerWinner(tournament, candidate, parentTwo); + _assertInnerWinner(tournament, candidate, finalTwo, parentTwo); } function testStandingInnerWinnerExpiresAtExactBoundary() public { @@ -876,6 +876,7 @@ contract TournamentObserverTest is Test { function _assertInnerWinner( TournamentObserverHarness tournament, Tree.Node candidate, + Machine.Hash finalState, Tree.Node parentCommitment ) internal view { _assertStanding( @@ -885,7 +886,7 @@ contract TournamentObserverTest is Test { acceptsJoins: false, hasCandidate: true, candidate: candidate, - finalState: Machine.ZERO_STATE, + finalState: finalState, parentCommitment: parentCommitment, finishedAt: _instant(125) }) From a067b1317f955c18a7853b58ba3c75bca5e3db54 Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Thu, 27 Aug 2026 16:06:19 -0300 Subject: [PATCH 03/16] feat!(contracts): make canStageTournamentResult total A view named "can I stage" should answer, not revert: ROOT_FAILED now returns (isFinished = true, isTournamentFailed = true, zeroed winner fields) through a new isTournamentFailed flag instead of reverting with TournamentFailedNoWinner. The internal classifier is total; the revert posture moves into stageTournamentResult unchanged (same error, same precedence). The node's stage planner asserts loudly on isTournamentFailed, matching the existing impossible-state idiom: a failed tournament on a defended epoch means the protocol assumption broke. --- .../contracts/src/DaveConsensus.sol | 24 +++-- .../contracts/src/IDaveConsensus.sol | 12 ++- .../contracts/test/DaveAppFactory.t.sol | 102 +++++++++++------- cartesi-rollups/node/src/epoch_manager/mod.rs | 5 + 4 files changed, 93 insertions(+), 50 deletions(-) diff --git a/cartesi-rollups/contracts/src/DaveConsensus.sol b/cartesi-rollups/contracts/src/DaveConsensus.sol index 2f51e5cb..3280abb1 100644 --- a/cartesi-rollups/contracts/src/DaveConsensus.sol +++ b/cartesi-rollups/contracts/src/DaveConsensus.sol @@ -146,6 +146,7 @@ contract DaveConsensus is IDaveConsensus, ERC165, ApplicationChecker { override returns ( bool isFinished, + bool isTournamentFailed, bool isTournamentResultStaged, uint256 epochNumber, Tree.Node winnerCommitment, @@ -154,7 +155,8 @@ contract DaveConsensus is IDaveConsensus, ERC165, ApplicationChecker { { epochNumber = _epochNumber; isTournamentResultStaged = _isTournamentResultStaged; - (isFinished, winnerCommitment, winnerPostEpochMachineStateHash) = _tournamentResult(_tournament); + (isFinished, isTournamentFailed, winnerCommitment, winnerPostEpochMachineStateHash) = + _tournamentResult(_tournament); } function stageTournamentResult(uint256 epochNumber, MachineValidityProof calldata proof) @@ -168,8 +170,9 @@ contract DaveConsensus is IDaveConsensus, ERC165, ApplicationChecker { // Check whether the tournament result is staged require(!_isTournamentResultStaged, TournamentResultAlreadyStaged()); - // Check tournament finished - (bool isFinished,, Machine.Hash finalMachineStateHash) = _tournamentResult(_tournament); + // Check tournament finished with a winner + (bool isFinished, bool isTournamentFailed,, Machine.Hash finalMachineStateHash) = _tournamentResult(_tournament); + require(!isTournamentFailed, ITournament.TournamentFailedNoWinner()); require(isFinished, TournamentNotFinishedYet()); // Validate post-epoch machine state and prove outputs Merkle root @@ -426,20 +429,23 @@ contract DaveConsensus is IDaveConsensus, ERC165, ApplicationChecker { } /// @notice Read the root tournament's result through its typed standing. - /// @dev A failed root (finished without a winner) reverts, preserving the - /// staging posture: such an epoch cannot be settled from this tournament. + /// @dev Total over the three root outcomes: a failed root (finished + /// without a winner) is reported, not reverted, so read paths never + /// revert on a normal terminal state. `stageTournamentResult` preserves + /// the revert posture: such an epoch cannot be settled from this + /// tournament. function _tournamentResult(ITournament tournament) internal view - returns (bool finished, Tree.Node winnerCommitment, Machine.Hash finalMachineStateHash) + returns (bool finished, bool tournamentFailed, Tree.Node winnerCommitment, Machine.Hash finalMachineStateHash) { ITournament.TournamentStandingView memory standing = tournament.tournamentStanding(); if (standing.standing == ITournament.TournamentStanding.ROOT_WINNER) { - return (true, standing.candidate, standing.finalState); + return (true, false, standing.candidate, standing.finalState); } else if (standing.standing == ITournament.TournamentStanding.ROOT_FAILED) { - revert ITournament.TournamentFailedNoWinner(); + return (true, true, Tree.ZERO_NODE, Machine.ZERO_STATE); } else { - return (false, Tree.ZERO_NODE, Machine.ZERO_STATE); + return (false, false, Tree.ZERO_NODE, Machine.ZERO_STATE); } } diff --git a/cartesi-rollups/contracts/src/IDaveConsensus.sol b/cartesi-rollups/contracts/src/IDaveConsensus.sol index 3a6cd26c..cf422377 100644 --- a/cartesi-rollups/contracts/src/IDaveConsensus.sol +++ b/cartesi-rollups/contracts/src/IDaveConsensus.sol @@ -262,16 +262,22 @@ interface IDaveConsensus is /// @notice Check whether the tournament result of the current sealed epoch can be staged. /// @return isFinished Whether the current sealed epoch tournament is finished + /// @return isTournamentFailed Whether the tournament finished without a winner, + /// in which case the epoch cannot be settled from this tournament /// @return isTournamentResultStaged Whether the tournament result (if there is one) is staged /// @return epochNumber The current sealed epoch number - /// @return winnerCommitment If the tournament has finished, the winner commitment - /// @return winnerPostEpochMachineStateHash If the tournament has finished, the winner post-epoch machine state hash - /// @dev Validators should only call `stageTournamentResult` if isFinished is true and isTournamentResultStaged is false. + /// @return winnerCommitment If the tournament has finished with a winner, the winner commitment + /// @return winnerPostEpochMachineStateHash If the tournament has finished with a winner, the winner post-epoch machine state hash + /// @dev Total over every terminal state: no standing makes this view + /// revert. Validators should only call `stageTournamentResult` if + /// isFinished is true, isTournamentFailed is false, and + /// isTournamentResultStaged is false. function canStageTournamentResult() external view returns ( bool isFinished, + bool isTournamentFailed, bool isTournamentResultStaged, uint256 epochNumber, Tree.Node winnerCommitment, diff --git a/cartesi-rollups/contracts/test/DaveAppFactory.t.sol b/cartesi-rollups/contracts/test/DaveAppFactory.t.sol index 646f09d5..bad19498 100644 --- a/cartesi-rollups/contracts/test/DaveAppFactory.t.sol +++ b/cartesi-rollups/contracts/test/DaveAppFactory.t.sol @@ -289,13 +289,15 @@ contract DaveAppFactoryTest is ConsensusTestUtils { { bool val1; bool val2; - uint256 val3; + bool val3; + uint256 val4; - (val1, val2, val3,,) = daveConsensus.canStageTournamentResult(); + (val1, val2, val3, val4,,) = daveConsensus.canStageTournamentResult(); assertFalse(val1); // isFinished - assertFalse(val2); // isTournamentResultStaged - assertEq(val3, 0); // epochNumber + assertFalse(val2); // isTournamentFailed + assertFalse(val3); // isTournamentResultStaged + assertEq(val4, 0); // epochNumber } // Check epoch acceptance readiness @@ -352,17 +354,19 @@ contract DaveAppFactoryTest is ConsensusTestUtils { { bool val1; bool val2; - uint256 val3; - Tree.Node val4; - Machine.Hash val5; + bool val3; + uint256 val4; + Tree.Node val5; + Machine.Hash val6; - (val1, val2, val3, val4, val5) = daveConsensus.canStageTournamentResult(); + (val1, val2, val3, val4, val5, val6) = daveConsensus.canStageTournamentResult(); assertTrue(val1); // isFinished - assertFalse(val2); // isTournamentResultStaged - assertEq(val3, 0); // epochNumber - assertEq(Tree.Node.unwrap(val4), commitment); - assertEq(Machine.Hash.unwrap(val5), machineMerkleRoot); + assertFalse(val2); // isTournamentFailed + assertFalse(val3); // isTournamentResultStaged + assertEq(val4, 0); // epochNumber + assertEq(Tree.Node.unwrap(val5), commitment); + assertEq(Machine.Hash.unwrap(val6), machineMerkleRoot); } // Check epoch acceptance readiness @@ -477,17 +481,19 @@ contract DaveAppFactoryTest is ConsensusTestUtils { { bool val1; bool val2; - uint256 val3; - Tree.Node val4; - Machine.Hash val5; + bool val3; + uint256 val4; + Tree.Node val5; + Machine.Hash val6; - (val1, val2, val3, val4, val5) = daveConsensus.canStageTournamentResult(); + (val1, val2, val3, val4, val5, val6) = daveConsensus.canStageTournamentResult(); assertTrue(val1); // isFinished - assertTrue(val2); // isTournamentResultStaged - assertEq(val3, 0); // epochNumber - assertEq(Tree.Node.unwrap(val4), commitment); - assertEq(Machine.Hash.unwrap(val5), machineMerkleRoot); + assertFalse(val2); // isTournamentFailed + assertTrue(val3); // isTournamentResultStaged + assertEq(val4, 0); // epochNumber + assertEq(Tree.Node.unwrap(val5), commitment); + assertEq(Machine.Hash.unwrap(val6), machineMerkleRoot); } // Check epoch acceptance readiness @@ -567,17 +573,19 @@ contract DaveAppFactoryTest is ConsensusTestUtils { { bool val1; bool val2; - uint256 val3; - Tree.Node val4; - Machine.Hash val5; + bool val3; + uint256 val4; + Tree.Node val5; + Machine.Hash val6; - (val1, val2, val3, val4, val5) = daveConsensus.canStageTournamentResult(); + (val1, val2, val3, val4, val5, val6) = daveConsensus.canStageTournamentResult(); assertTrue(val1); // isFinished - assertTrue(val2); // isTournamentResultStaged - assertEq(val3, 0); // epochNumber - assertEq(Tree.Node.unwrap(val4), commitment); - assertEq(Machine.Hash.unwrap(val5), machineMerkleRoot); + assertFalse(val2); // isTournamentFailed + assertTrue(val3); // isTournamentResultStaged + assertEq(val4, 0); // epochNumber + assertEq(Tree.Node.unwrap(val5), commitment); + assertEq(Machine.Hash.unwrap(val6), machineMerkleRoot); } // Check epoch acceptance readiness @@ -642,13 +650,15 @@ contract DaveAppFactoryTest is ConsensusTestUtils { { bool val1; bool val2; - uint256 val3; + bool val3; + uint256 val4; - (val1, val2, val3,,) = daveConsensus.canStageTournamentResult(); + (val1, val2, val3, val4,,) = daveConsensus.canStageTournamentResult(); assertFalse(val1); // isFinished - assertFalse(val2); // isTournamentResultStaged - assertEq(val3, 1); // epochNumber + assertFalse(val2); // isTournamentFailed + assertFalse(val3); // isTournamentResultStaged + assertEq(val4, 1); // epochNumber } // Check epoch acceptance readiness @@ -956,8 +966,22 @@ contract DaveAppFactoryTest is ConsensusTestUtils { }); vm.mockCall(address(tournament), abi.encodeCall(ITournament.tournamentStanding, ()), abi.encode(failedStanding)); - vm.expectRevert(ITournament.TournamentFailedNoWinner.selector); - daveConsensus.canStageTournamentResult(); + { + ( + bool isFinished, + bool isTournamentFailed, + bool isTournamentResultStaged, + uint256 epochNumber, + Tree.Node winnerCommitment, + Machine.Hash winnerPostEpochMachineStateHash + ) = daveConsensus.canStageTournamentResult(); + assertTrue(isFinished); + assertTrue(isTournamentFailed); + assertFalse(isTournamentResultStaged); + assertEq(epochNumber, 0); + assertEq(Tree.Node.unwrap(winnerCommitment), bytes32(0)); + assertEq(Machine.Hash.unwrap(winnerPostEpochMachineStateHash), bytes32(0)); + } // The tournament standing is checked before the machine validity proof, // so any proof can be provided as to reach the expected revert. @@ -1342,13 +1366,15 @@ contract DaveAppFactoryTest is ConsensusTestUtils { { bool val1; bool val2; - uint256 val3; + bool val3; + uint256 val4; - (val1, val2, val3,,) = daveConsensus.canStageTournamentResult(); + (val1, val2, val3, val4,,) = daveConsensus.canStageTournamentResult(); assertFalse(val1); // isFinished - assertFalse(val2); // isTournamentResultStaged - assertEq(val3, 0); // epochNumber + assertFalse(val2); // isTournamentFailed + assertFalse(val3); // isTournamentResultStaged + assertEq(val4, 0); // epochNumber } assertEq(address(daveConsensus.getInputBox()), address(_contracts.core.inputBox)); diff --git a/cartesi-rollups/node/src/epoch_manager/mod.rs b/cartesi-rollups/node/src/epoch_manager/mod.rs index 2d55ab5b..15fced1f 100644 --- a/cartesi-rollups/node/src/epoch_manager/mod.rs +++ b/cartesi-rollups/node/src/epoch_manager/mod.rs @@ -296,6 +296,11 @@ impl EpochManager { .call() .await?; + assert!( + !can_stage.isTournamentFailed, + "Tournament finished without a winner, notify all users!" + ); + if !can_stage.isFinished || can_stage.isTournamentResultStaged { trace!("tournament result not ready to be staged"); return Ok(None); From a8fc1108213501649b723492e3d006a5e751ceb4 Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Thu, 27 Aug 2026 16:21:29 -0300 Subject: [PATCH 04/16] feat!(prt): emit post-advance position in MatchAdvanced MatchAdvanced gains segmentStartPosition (the same value the bisecting projection reports), so a finished match's bisection trajectory survives match deletion in the event history. The value is already in match storage at emit time; the direction remains derivable from consecutive events except when the waiting side's children are identical, which is exactly the ambiguity the explicit field removes. The Lua client folds the position as a per-match breadcrumb (seeded at zero on creation) and cross-checks it against the live projections, alongside the existing otherParent/leftNode breadcrumbs. The Rust node needs only regenerated bindings: its domain event keeps the minimal deadline-replacement shape. --- docs/dispute-game.md | 4 +++- prt/client-lua/player/adapter.lua | 7 +++++++ prt/client-lua/player/fold.lua | 8 ++++++++ prt/client-lua/player/semantic_reader.lua | 7 ++++--- prt/client-lua/tests/adapter_test.lua | 7 +++++++ prt/client-lua/tests/fold_test.lua | 8 ++++---- prt/client-lua/tests/semantic_reader_test.lua | 12 +++++++----- prt/contracts/src/ITournament.sol | 10 +++++++--- prt/contracts/src/tournament/Tournament.sol | 10 +++++++++- .../test/fixtures/SmallSingleLevelTournament.t.sol | 12 +++++++----- 10 files changed, 63 insertions(+), 22 deletions(-) diff --git a/docs/dispute-game.md b/docs/dispute-game.md index f0338bf3..da588b98 100644 --- a/docs/dispute-game.md +++ b/docs/dispute-game.md @@ -195,7 +195,9 @@ must reveal the next children. A valid `advanceMatch` moves the shared first-divergence frontier one tree level, then switches the turn. A height-`H` match has exactly `H` eligible responses: `H - 1` advances and one final leaf or inner seal. If both child subtrees differ, bisection selects the left child, -preserving the first-divergence rule. +preserving the first-divergence rule. Each `MatchAdvanced` event publishes the +post-advance segment start position, so a finished match's bisection +trajectory survives match deletion in the event history. The stored reveal is deliberately staggered. At the start of a turn, the waiting commitment's children are already cached. The current revealer opens diff --git a/prt/client-lua/player/adapter.lua b/prt/client-lua/player/adapter.lua index d36b3aaa..ef0d64c2 100644 --- a/prt/client-lua/player/adapter.lua +++ b/prt/client-lua/player/adapter.lua @@ -602,16 +602,19 @@ local function validate_match_event_history(descriptor, match_fold, state) local remaining_height local revealing_parent local waiting_left + local segment_start_position if state._tag == Domain.LiveMatchState.BISECTING then remaining_height = state.remaining_height revealing_parent = state.revealing_parent waiting_left = state.waiting_children.left + segment_start_position = state.coordinate.leaf_position elseif state._tag == Domain.LiveMatchState.READY_TO_SEAL_LEAF or state._tag == Domain.LiveMatchState.READY_TO_DELEGATE then remaining_height = 1 revealing_parent = state.revealing_parent waiting_left = state.waiting_children.left + segment_start_position = state.coordinate.leaf_position else assert(state._tag == Domain.LiveMatchState.SEALED_LEAF or state._tag == Domain.LiveMatchState.AWAITING_CHILD, @@ -634,6 +637,10 @@ local function validate_match_event_history(descriptor, match_fold, state) "folded otherParent breadcrumb disagrees with projection") assert(same(match_fold.last_left_node, waiting_left), "folded leftNode breadcrumb disagrees with projection") + assert(bint.eq( + match_fold.last_segment_start_position, + segment_start_position + ), "folded segmentStartPosition breadcrumb disagrees with projection") end end diff --git a/prt/client-lua/player/fold.lua b/prt/client-lua/player/fold.lua index a5690b1b..cad3be03 100644 --- a/prt/client-lua/player/fold.lua +++ b/prt/client-lua/player/fold.lua @@ -114,6 +114,7 @@ local function copy_match(match) advances = match.advances, last_other_parent = match.last_other_parent, last_left_node = match.last_left_node, + last_segment_start_position = match.last_segment_start_position, inner_tournament = match.inner_tournament, deleted = deleted, } @@ -201,12 +202,15 @@ function Fold.Event.match_advanced( match_id_hash, other_parent, left_node, + segment_start_position, eliminable_at ) return event_kind(Fold.EventKind.MATCH_ADVANCED, { match_id_hash = required(match_id_hash, "match id hash"), other_parent = required(other_parent, "other parent"), left_node = required(left_node, "left node"), + segment_start_position = + required(segment_start_position, "segment start position"), eliminable_at = uint64(eliminable_at, "match elimination block"), }) end @@ -380,6 +384,9 @@ function Fold:apply(event) advances = 0, last_other_parent = kind.commitment_one, last_left_node = kind.left_of_two, + -- Bisection starts at leaf position zero; advances replace this + -- with the emitted post-advance segment start position. + last_segment_start_position = 0, inner_tournament = nil, deleted = nil, } @@ -393,6 +400,7 @@ function Fold:apply(event) match.advances = match.advances + 1 match.last_other_parent = kind.other_parent match.last_left_node = kind.left_node + match.last_segment_start_position = kind.segment_start_position match.eliminable_at = uint64( kind.eliminable_at, "match elimination block" diff --git a/prt/client-lua/player/semantic_reader.lua b/prt/client-lua/player/semantic_reader.lua index 4ec57295..34ae02ef 100644 --- a/prt/client-lua/player/semantic_reader.lua +++ b/prt/client-lua/player/semantic_reader.lua @@ -152,7 +152,7 @@ local EVENT_SIGNATURES = { kind = Fold.EventKind.MATCH_CREATED, }, { - signature = "MatchAdvanced(bytes32,bytes32,bytes32,uint64)", + signature = "MatchAdvanced(bytes32,bytes32,bytes32,uint256,uint64)", kind = Fold.EventKind.MATCH_ADVANCED, }, { @@ -286,12 +286,13 @@ function SemanticReader.decode_event_log(log, topic_map) ) elseif kind == Fold.EventKind.MATCH_ADVANCED then require_topic_count(log, 2, "MatchAdvanced") - local words = data_words(log.data, 3, "MatchAdvanced") + local words = data_words(log.data, 4, "MatchAdvanced") event = Fold.Event.match_advanced( topic_hash(log.topics[2], "MatchAdvanced.matchIdHash"), Hash:from_digest_hex("0x" .. words[1]), Hash:from_digest_hex("0x" .. words[2]), - word_uint64(words[3], "MatchAdvanced.eliminableAt") + bint("0x" .. words[3]), + word_uint64(words[4], "MatchAdvanced.eliminableAt") ) elseif kind == Fold.EventKind.LEAF_MATCH_SEALED then require_topic_count(log, 2, "LeafMatchSealed") diff --git a/prt/client-lua/tests/adapter_test.lua b/prt/client-lua/tests/adapter_test.lua index 6d3feace..a259b177 100644 --- a/prt/client-lua/tests/adapter_test.lua +++ b/prt/client-lua/tests/adapter_test.lua @@ -135,6 +135,7 @@ local function apply_advances( match.id_hash, index == count and final_other_parent or digest(50 + index), index == count and final_left_node or digest(60 + index), + 0, 100 + index ) )) @@ -582,6 +583,12 @@ return { Test.error_like("otherParent breadcrumb", function() Adapter.observe_fold(transport, fold, head()) end) + + projection.revealing_parent = digest(10) + projection.segment_start_position = 4 + Test.error_like("segmentStartPosition breadcrumb", function() + Adapter.observe_fold(transport, fold, head()) + end) end), Test.case("sealed nonleaf projection validates recursive child topology", function() diff --git a/prt/client-lua/tests/fold_test.lua b/prt/client-lua/tests/fold_test.lua index 9932ac47..7ab5f14b 100644 --- a/prt/client-lua/tests/fold_test.lua +++ b/prt/client-lua/tests/fold_test.lua @@ -45,7 +45,7 @@ return { "a", "b", "b-left", "a:b", 30 )), event("root", 4, E.match_advanced( - "a:b", "other", "left", 40 + "a:b", "other", "left", 0, 40 )), event("root", 5, E.new_inner_tournament("a:b", "child")), event("child", 6, E.commitment_joined("c", "c-final")), @@ -133,7 +133,7 @@ return { new_fold():apply(event( "root", 1, - E.match_advanced("missing", "other", "left", 30) + E.match_advanced("missing", "other", "left", 0, 30) )) end, }, @@ -156,7 +156,7 @@ return { fold:apply(event( "root", 5, - E.match_advanced("a:b", "other", "left", 30) + E.match_advanced("a:b", "other", "left", 0, 30) )) end, }, @@ -316,7 +316,7 @@ return { fold:apply(event( "root", 4, - E.match_advanced("a:b", "other", "left", 40) + E.match_advanced("a:b", "other", "left", 0, 40) )) Test.equal(tostring( fold:match_by_id_hash("root", "a:b").eliminable_at diff --git a/prt/client-lua/tests/semantic_reader_test.lua b/prt/client-lua/tests/semantic_reader_test.lua index 063b743a..a43a8a4b 100644 --- a/prt/client-lua/tests/semantic_reader_test.lua +++ b/prt/client-lua/tests/semantic_reader_test.lua @@ -64,6 +64,7 @@ local function event_match_advanced( id_hash, other_parent, left, + segment_start_position, eliminable_at ) return { @@ -74,6 +75,7 @@ local function event_match_advanced( data = data { word_hash(other_parent), word_hash(left), + word_uint(segment_start_position), word_uint(eliminable_at), }, } @@ -229,7 +231,7 @@ local function semantic_fixture() transaction_index = 0, log_index = 3, event = event_match_advanced( - id_hash, digest(31), digest(32), 21 + id_hash, digest(31), digest(32), 0, 21 ), }, raw_log { @@ -239,7 +241,7 @@ local function semantic_fixture() transaction_index = 0, log_index = 4, event = event_match_advanced( - id_hash, digest(33), digest(34), 22 + id_hash, digest(33), digest(34), 0, 22 ), }, raw_log { @@ -249,7 +251,7 @@ local function semantic_fixture() transaction_index = 0, log_index = 5, event = event_match_advanced( - id_hash, digest(35), digest(36), 23 + id_hash, digest(35), digest(36), 0, 23 ), }, }, @@ -412,7 +414,7 @@ return { local signatures = { "CommitmentJoined(bytes32,bytes32,address)", "MatchCreated(bytes32,bytes32,bytes32,bytes32,uint64)", - "MatchAdvanced(bytes32,bytes32,bytes32,uint64)", + "MatchAdvanced(bytes32,bytes32,bytes32,uint256,uint64)", "LeafMatchSealed(bytes32,uint64)", "MatchDeleted(bytes32,bytes32,bytes32,uint8,uint8)", "NewInnerTournament(bytes32,address)", @@ -448,7 +450,7 @@ return { tag = Fold.EventKind.MATCH_ADVANCED, event = event_match_advanced( - id_hash, digest(6), digest(7), 21 + id_hash, digest(6), digest(7), 0, 21 ), eliminable_at = 21, }, diff --git a/prt/contracts/src/ITournament.sol b/prt/contracts/src/ITournament.sol index 1eb67297..4b4823ec 100644 --- a/prt/contracts/src/ITournament.sol +++ b/prt/contracts/src/ITournament.sol @@ -216,19 +216,23 @@ interface ITournament { /// @param matchIdHash The match ID hash /// @param otherParent The parent the next responder must reveal /// @param leftNode The waiting side's left child after the advance + /// @param segmentStartPosition The post-advance start position of the + /// disputed segment, as also reported by `BisectingMatchView` /// @param eliminableAt The first inclusive instant at which both /// commitments can be eliminated if the match does not advance again /// @dev Each advance selects the left half when the two left children differ, /// otherwise the right half, then swaps revealing and waiting roles. The - /// event exposes the post-advance revealing parent and waiting left child. - /// The waiting right child is unnecessary for selecting the next branch, - /// which depends only on left-child equality; the full live state remains + /// event exposes the post-advance revealing parent, waiting left child, + /// and segment start position; a right descent raises the position by two + /// to the pre-advance height minus one, a left descent keeps it, so the + /// position also encodes the selected branch. The full live state remains /// available through the phase projections (`bisectingMatch` and its /// siblings). event MatchAdvanced( Match.IdHash indexed matchIdHash, Tree.Node otherParent, Tree.Node leftNode, + uint256 segmentStartPosition, Time.Instant eliminableAt ); diff --git a/prt/contracts/src/tournament/Tournament.sol b/prt/contracts/src/tournament/Tournament.sol index fbdda9cd..936abb18 100644 --- a/prt/contracts/src/tournament/Tournament.sol +++ b/prt/contracts/src/tournament/Tournament.sol @@ -263,6 +263,7 @@ contract Tournament is ITournament { matchIdHash, _matchState.otherParent, _matchState.leftNode, + _matchState.runningLeafPosition, MatchClocks.eliminableAt(clockOne, clockTwo) ); } @@ -1332,9 +1333,16 @@ contract Tournament is ITournament { Match.IdHash matchIdHash, Tree.Node otherParent, Tree.Node leftNode, + uint256 segmentStartPosition, Time.Instant eliminableAt ) private { - emit MatchAdvanced(matchIdHash, otherParent, leftNode, eliminableAt); + emit MatchAdvanced( + matchIdHash, + otherParent, + leftNode, + segmentStartPosition, + eliminableAt + ); ++matchAdvancedCount; } diff --git a/prt/contracts/test/fixtures/SmallSingleLevelTournament.t.sol b/prt/contracts/test/fixtures/SmallSingleLevelTournament.t.sol index 08ac6ba9..36503553 100644 --- a/prt/contracts/test/fixtures/SmallSingleLevelTournament.t.sol +++ b/prt/contracts/test/fixtures/SmallSingleLevelTournament.t.sol @@ -216,20 +216,22 @@ contract SmallSingleLevelTournamentTest is Test { (Tree.Node newLeft, Tree.Node newRight) = one.children(HEIGHT - 1, childIndex); Tree.Node expectedOtherParent = descendRight ? twoRight : twoLeft; + uint256 expectedPosition = descendRight ? uint256(1) << (HEIGHT - 1) : 0; vm.expectEmit(true, false, false, true, address(tournament)); emit ITournament.MatchAdvanced( - id.hashFromId(), expectedOtherParent, newLeft, advancedEliminableAt + id.hashFromId(), + expectedOtherParent, + newLeft, + expectedPosition, + advancedEliminableAt ); tournament.advanceMatch(id, oneLeft, oneRight, newLeft, newRight); Match.State memory state = tournament.getMatch(id.hashFromId()); assertTrue(state.isInit); assertEq(state.currentHeight, HEIGHT - 1); - assertEq( - state.runningLeafPosition, - descendRight ? uint256(1) << (HEIGHT - 1) : 0 - ); + assertEq(state.runningLeafPosition, expectedPosition); assertTrue(state.otherParent.eq(expectedOtherParent)); assertTrue(state.leftNode.eq(newLeft)); assertTrue(state.rightNode.eq(newRight)); From e2882c251cd47def255b0f1a01f147088039e3d5 Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Thu, 27 Aug 2026 16:53:46 -0300 Subject: [PATCH 05/16] feat!(prt): expose instance time envelope in tournament descriptor TournamentDescriptor gains startInstant and allowance, appended so the existing fields keep their positions. Both are clone-immutable creation arguments with no read path until now; an inner clone's allowance is inherited from the parent match clocks at seal time, and joins close at startInstant + allowance under inclusive expiry (now documented on the view). The Lua domain descriptor carries and bounds-checks both fields; the legacy compact reader decodes the widened tuple. The Rust observer leaves them undecoded (Hero acts eagerly on current state) and only its wire fixtures change. --- cartesi-rollups/node/src/tournament/observer.rs | 4 ++++ cartesi-rollups/node/src/tournament/reader.rs | 2 ++ prt/client-lua/player/adapter.lua | 6 +++++- prt/client-lua/player/domain.lua | 8 ++++++++ prt/client-lua/player/reader.lua | 4 ++-- prt/client-lua/tests/actor_test.lua | 2 ++ prt/client-lua/tests/adapter_test.lua | 10 +++++++--- prt/client-lua/tests/context_test.lua | 2 ++ prt/client-lua/tests/domain_test.lua | 2 ++ prt/client-lua/tests/fulfiller_test.lua | 2 ++ prt/client-lua/tests/gc_planner_test.lua | 2 ++ prt/client-lua/tests/planner_test.lua | 2 ++ prt/client-lua/tests/semantic_reader_test.lua | 2 ++ prt/contracts/src/ITournament.sol | 11 +++++++++-- prt/contracts/src/tournament/Tournament.sol | 4 +++- prt/contracts/test/TournamentObserver.t.sol | 2 ++ 16 files changed, 56 insertions(+), 9 deletions(-) diff --git a/cartesi-rollups/node/src/tournament/observer.rs b/cartesi-rollups/node/src/tournament/observer.rs index 7ae28e88..13a41406 100644 --- a/cartesi-rollups/node/src/tournament/observer.rs +++ b/cartesi-rollups/node/src/tournament/observer.rs @@ -341,6 +341,8 @@ fn decode_descriptor( address: Address, wire: AbiTournamentDescriptor, ) -> ObserverResult { + // startInstant/allowance describe the join window for observers; Hero + // acts eagerly on current state, so they are not decoded here. TournamentDescriptor::try_new( address, wire.level, @@ -774,6 +776,8 @@ mod tests { height: 4, level, kind, + startInstant: 100, + allowance: 20, } } diff --git a/cartesi-rollups/node/src/tournament/reader.rs b/cartesi-rollups/node/src/tournament/reader.rs index 91cebc3e..d3562682 100644 --- a/cartesi-rollups/node/src/tournament/reader.rs +++ b/cartesi-rollups/node/src/tournament/reader.rs @@ -757,6 +757,8 @@ mod tests { TournamentKind::Leaf => 0, TournamentKind::NonLeaf => 1, }, + startInstant: 100, + allowance: 20, } } diff --git a/prt/client-lua/player/adapter.lua b/prt/client-lua/player/adapter.lua index ef0d64c2..cc95da23 100644 --- a/prt/client-lua/player/adapter.lua +++ b/prt/client-lua/player/adapter.lua @@ -163,7 +163,7 @@ function Adapter.decode_result(view, raw) required(view, "observer view") local name = required(view.name, "observer view name") if view == Adapter.View.DESCRIPTOR then - local words = abi_words(raw, 6, name) + local words = abi_words(raw, 8, name) return { initial_hash = word_hash(words[1]), base_cycle = word_uint(words[2]), @@ -171,6 +171,8 @@ function Adapter.decode_result(view, raw) height = word_small(words[4], 64, name .. ".height"), level = word_small(words[5], 64, name .. ".level"), kind = word_small(words[6], 8, name .. ".kind"), + start_instant = word_small(words[7], 64, name .. ".startInstant"), + allowance = word_small(words[8], 64, name .. ".allowance"), } end if view == Adapter.View.STANDING then @@ -249,6 +251,8 @@ local function decode_descriptor(tournament_fold, wire) base_cycle = wire.base_cycle, log2_stride = wire.log2_stride, height = wire.height, + start_instant = wire.start_instant, + allowance = wire.allowance, } return descriptor end diff --git a/prt/client-lua/player/domain.lua b/prt/client-lua/player/domain.lua index 9912207c..84dc2862 100644 --- a/prt/client-lua/player/domain.lua +++ b/prt/client-lua/player/domain.lua @@ -146,6 +146,12 @@ local function uint256(value, name) return bint(value) end +local function instant(value) + local parsed = uint256(value, "block instant") + assert(bint.ule(parsed, MAX_U64), "block instant exceeds uint64") + return parsed +end + local function duration(value) local parsed = uint256(value, "block duration") assert(bint.ule(parsed, MAX_U64), "block duration exceeds uint64") @@ -266,6 +272,8 @@ function Domain.descriptor(args) base_cycle = base_cycle, log2_stride = log2_stride, height = height, + start_instant = instant(args.start_instant), + allowance = duration(args.allowance), } end diff --git a/prt/client-lua/player/reader.lua b/prt/client-lua/player/reader.lua index 3601b284..22b61cd1 100644 --- a/prt/client-lua/player/reader.lua +++ b/prt/client-lua/player/reader.lua @@ -274,14 +274,14 @@ end function Reader:read_constants(tournament_address) local sig = "tournamentDescriptor()" - .. "((bytes32,uint256,uint64,uint64,uint64,uint8))" + .. "((bytes32,uint256,uint64,uint64,uint64,uint8,uint64,uint64))" local ret = self:_call(tournament_address, sig, {}) assert(#ret == 1) local compact = sanitize_string(ret[1]) local log2_stride, height, level, kind = compact:match( - "^%(0x%x+,%d+,(%d+),(%d+),(%d+),(%d+)%)$" + "^%(0x%x+,%d+,(%d+),(%d+),(%d+),(%d+),%d+,%d+%)$" ) assert(kind, "could not decode tournamentDescriptor") kind = tonumber(kind) diff --git a/prt/client-lua/tests/actor_test.lua b/prt/client-lua/tests/actor_test.lua index 4987df99..b4dad264 100644 --- a/prt/client-lua/tests/actor_test.lua +++ b/prt/client-lua/tests/actor_test.lua @@ -33,6 +33,8 @@ local function descriptor(root, initial) base_cycle = 0, log2_stride = 0, height = 2, + start_instant = 1, + allowance = 1000, } end diff --git a/prt/client-lua/tests/adapter_test.lua b/prt/client-lua/tests/adapter_test.lua index a259b177..d05c64fe 100644 --- a/prt/client-lua/tests/adapter_test.lua +++ b/prt/client-lua/tests/adapter_test.lua @@ -32,6 +32,8 @@ local function descriptor(fields) height = fields.height or 4, level = fields.level or 0, kind = fields.kind or 0, + start_instant = fields.start_instant or 1, + allowance = fields.allowance or 1000, } end @@ -350,11 +352,11 @@ return { Test.equal( signature, "tournamentDescriptor()" - .. "((bytes32,uint256,uint64,uint64,uint64,uint8))" + .. "((bytes32,uint256,uint64,uint64,uint64,uint8,uint64,uint64))" ) Test.equal(#arguments, 0) return { - "(0x" .. string.rep("01", 32) .. ",3,44,48,2,1)", + "(0x" .. string.rep("01", 32) .. ",3,44,48,2,1,7,900)", } end @@ -366,7 +368,7 @@ return { function reader._call() return { - "(0x" .. string.rep("01", 32) .. ",3,44,48,2,9)", + "(0x" .. string.rep("01", 32) .. ",3,44,48,2,9,7,900)", } end Test.error_like("unknown tournament kind", function() @@ -383,6 +385,8 @@ return { uint_word(1), uint_word(0), uint_word(0), + uint_word(1), + uint_word(1000), } local accepted_wire = Adapter.decode_result( Adapter.View.DESCRIPTOR, diff --git a/prt/client-lua/tests/context_test.lua b/prt/client-lua/tests/context_test.lua index bc7b7815..3eb4fcef 100644 --- a/prt/client-lua/tests/context_test.lua +++ b/prt/client-lua/tests/context_test.lua @@ -35,6 +35,8 @@ local function descriptor(at, initial_hash, level, kind, base_cycle) base_cycle = base_cycle or 0, log2_stride = 0, height = 2, + start_instant = 1, + allowance = 1000, } end diff --git a/prt/client-lua/tests/domain_test.lua b/prt/client-lua/tests/domain_test.lua index fa9ca236..fbbcc3ea 100644 --- a/prt/client-lua/tests/domain_test.lua +++ b/prt/client-lua/tests/domain_test.lua @@ -15,6 +15,8 @@ local function descriptor(args) base_cycle = args.base_cycle or 0, log2_stride = args.log2_stride or 0, height = args.height or 4, + start_instant = args.start_instant or 1, + allowance = args.allowance or 1000, } end diff --git a/prt/client-lua/tests/fulfiller_test.lua b/prt/client-lua/tests/fulfiller_test.lua index 9a7a831b..40bb33ce 100644 --- a/prt/client-lua/tests/fulfiller_test.lua +++ b/prt/client-lua/tests/fulfiller_test.lua @@ -40,6 +40,8 @@ local function descriptor(at, initial, level, kind) base_cycle = 0, log2_stride = 0, height = 2, + start_instant = 1, + allowance = 1000, } end diff --git a/prt/client-lua/tests/gc_planner_test.lua b/prt/client-lua/tests/gc_planner_test.lua index 29765ea3..c9c680d0 100644 --- a/prt/client-lua/tests/gc_planner_test.lua +++ b/prt/client-lua/tests/gc_planner_test.lua @@ -43,6 +43,8 @@ local function descriptor(address, level, kind, initial_hash, base_cycle) base_cycle = base_cycle, log2_stride = 0, height = 4, + start_instant = 1, + allowance = 1000, } end diff --git a/prt/client-lua/tests/planner_test.lua b/prt/client-lua/tests/planner_test.lua index 63c03101..c8ff79e1 100644 --- a/prt/client-lua/tests/planner_test.lua +++ b/prt/client-lua/tests/planner_test.lua @@ -15,6 +15,8 @@ local function descriptor(kind, address, level, initial_hash, base_cycle) base_cycle = base_cycle or 0, log2_stride = 0, height = 4, + start_instant = 1, + allowance = 1000, } end diff --git a/prt/client-lua/tests/semantic_reader_test.lua b/prt/client-lua/tests/semantic_reader_test.lua index a43a8a4b..a2b63325 100644 --- a/prt/client-lua/tests/semantic_reader_test.lua +++ b/prt/client-lua/tests/semantic_reader_test.lua @@ -154,6 +154,8 @@ local function descriptor(fields) height = fields.height or 4, level = fields.level or 0, kind = fields.kind == nil and 1 or fields.kind, + start_instant = fields.start_instant or 1, + allowance = fields.allowance or 1000, } end diff --git a/prt/contracts/src/ITournament.sol b/prt/contracts/src/ITournament.sol index 4b4823ec..63f48ad5 100644 --- a/prt/contracts/src/ITournament.sol +++ b/prt/contracts/src/ITournament.sol @@ -160,6 +160,8 @@ interface ITournament { uint64 height; uint64 level; TournamentKind kind; + Time.Instant startInstant; + Time.Duration allowance; } struct TournamentStandingView { @@ -720,9 +722,14 @@ interface ITournament { Time.Duration deferredCharge ); - /// @notice Return immutable geometry and level identity for this clone. + /// @notice Return immutable geometry, level identity, and the instance + /// time envelope for this clone. /// @dev `kind` distinguishes leaf from non-leaf; root versus inner is - /// derived from `level`. + /// derived from `level`. `startInstant` and `allowance` are clone + /// creation arguments: an inner clone inherits its allowance from the + /// parent match clocks at seal time, not from the configured default. + /// Joins are accepted strictly before `startInstant + allowance` + /// (expiry is inclusive). function tournamentDescriptor() external view diff --git a/prt/contracts/src/tournament/Tournament.sol b/prt/contracts/src/tournament/Tournament.sol index 936abb18..648cfd7f 100644 --- a/prt/contracts/src/tournament/Tournament.sol +++ b/prt/contracts/src/tournament/Tournament.sol @@ -920,7 +920,9 @@ contract Tournament is ITournament { log2Stride: commitmentArgs.log2step, height: commitmentArgs.height, level: args.level, - kind: args.kind + kind: args.kind, + startInstant: args.startInstant, + allowance: args.allowance }); } diff --git a/prt/contracts/test/TournamentObserver.t.sol b/prt/contracts/test/TournamentObserver.t.sol index ab89bdc1..3ac7d427 100644 --- a/prt/contracts/test/TournamentObserver.t.sol +++ b/prt/contracts/test/TournamentObserver.t.sol @@ -871,6 +871,8 @@ contract TournamentObserverTest is Test { assertEq(descriptor.height, height); assertEq(descriptor.level, level); assertEq(uint8(descriptor.kind), uint8(kind)); + assertEq(Time.Instant.unwrap(descriptor.startInstant), 100); + assertEq(Time.Duration.unwrap(descriptor.allowance), 20); } function _assertInnerWinner( From f85c866004f5fcce30471a143806436c025d4883 Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Thu, 27 Aug 2026 17:00:15 -0300 Subject: [PATCH 06/16] feat!(prt): add per-commitment standing view commitmentStanding(root) projects one commitment's raw join record and clock snapshot: joined flag, claimed final state, claimer, and either the paused clock's frozen reserve or the running clock's fixed inclusive deadline. Total over unjoined roots (canonical zeros), and deliberately a snapshot rather than a status: storage keeps no elimination tombstone or commitment-to-match link, so liveness stays topological, and eliminated commitments keep their last written clock (the alpha.3 caveat, now documented on the struct). Unlike alpha.3's removed getCommitment, the raw Clock.State encoding stays off the wire and the claimer is exposed. No per-block-varying field: running-clock consumers derive remaining time from the fixed deadline client-side, which suits pinned reads. The Lua adapter gains the decode arm; the Rust node needs bindings only: the sling has no consumer (join idempotency is event replay and its response policy is eager). --- docs/dispute-game.md | 5 +++ prt/client-lua/player/adapter.lua | 23 +++++++++++ prt/client-lua/tests/adapter_test.lua | 23 +++++++++++ prt/contracts/src/ITournament.sol | 28 +++++++++++++ prt/contracts/src/tournament/Tournament.sol | 20 +++++++++ prt/contracts/test/TournamentObserver.t.sol | 41 +++++++++++++++++++ .../fixtures/SmallSingleLevelTournament.t.sol | 21 ++++++++++ 7 files changed, 161 insertions(+) diff --git a/docs/dispute-game.md b/docs/dispute-game.md index da588b98..d242a00f 100644 --- a/docs/dispute-game.md +++ b/docs/dispute-game.md @@ -448,6 +448,11 @@ duration, while `ELIMINABLE` covers both a no-winner child and an expired winner. Propagation requires `WINNER` and elimination requires `ELIMINABLE`, so the parent verbs partition exactly. +`commitmentStanding(root)` projects one commitment's raw join record and +clock snapshot: claimed final state, claimer, and either the paused clock's +frozen reserve or the running clock's fixed inclusive deadline. Eliminated +commitments keep their last written clock; liveness stays topological. + The standing also reports `finishedAt`, the block-number instant when the tournament became safe to decide: the later of its closure deadline and its last match deletion. It is canonically zero for `MATCHES_ACTIVE` and diff --git a/prt/client-lua/player/adapter.lua b/prt/client-lua/player/adapter.lua index cc95da23..162f29df 100644 --- a/prt/client-lua/player/adapter.lua +++ b/prt/client-lua/player/adapter.lua @@ -23,6 +23,10 @@ Adapter.View = { name = "tournamentStanding", signature = "tournamentStanding()", }, + COMMITMENT = { + name = "commitmentStanding", + signature = "commitmentStanding(bytes32)", + }, TIMEOUT = { name = "classifyMatchTimeout", signature = "classifyMatchTimeout((bytes32,bytes32))", @@ -158,6 +162,12 @@ local function word_bool(word, name) error(name .. " is not a canonical ABI boolean", 2) end +local function word_address(word, name) + assert(word:sub(1, 24):match("^0*$"), + name .. " is not a canonical ABI address") + return normalize_address("0x" .. word:sub(25), name) +end + -- Provider-free decoding for the six static observer return shapes. function Adapter.decode_result(view, raw) required(view, "observer view") @@ -187,6 +197,19 @@ function Adapter.decode_result(view, raw) finished_at = word_small(words[7], 64, name .. ".finishedAt"), } end + if view == Adapter.View.COMMITMENT then + local words = abi_words(raw, 6, name) + return { + joined = word_bool(words[1], name .. ".joined"), + final_state = word_hash(words[2]), + claimer = word_address(words[3], name .. ".claimer"), + clock_running = word_bool(words[4], name .. ".clockRunning"), + clock_deadline = + word_small(words[5], 64, name .. ".clockDeadline"), + clock_allowance = + word_small(words[6], 64, name .. ".clockAllowance"), + } + end if view == Adapter.View.TIMEOUT then local words = abi_words(raw, 3, name) return { diff --git a/prt/client-lua/tests/adapter_test.lua b/prt/client-lua/tests/adapter_test.lua index d05c64fe..b6eca503 100644 --- a/prt/client-lua/tests/adapter_test.lua +++ b/prt/client-lua/tests/adapter_test.lua @@ -237,6 +237,29 @@ return { Test.equal(standing_wire.final_state, digest(3)) Test.equal(standing_wire.finished_at, 42) + Test.equal(Adapter.View.COMMITMENT.name, "commitmentStanding") + Test.equal( + Adapter.View.COMMITMENT.signature, + "commitmentStanding(bytes32)" + ) + local commitment_wire = Adapter.decode_result( + Adapter.View.COMMITMENT, + encoded { + uint_word(1), + hash_word(digest(7)), + string.rep("0", 24) .. address(5):sub(3), + uint_word(0), + uint_word(0), + uint_word(900), + } + ) + Test.equal(commitment_wire.joined, true) + Test.equal(commitment_wire.final_state, digest(7)) + Test.equal(commitment_wire.claimer, address(5)) + Test.equal(commitment_wire.clock_running, false) + Test.equal(commitment_wire.clock_deadline, 0) + Test.equal(commitment_wire.clock_allowance, 900) + local timeout_wire = Adapter.decode_result( Adapter.View.TIMEOUT, encoded { diff --git a/prt/contracts/src/ITournament.sol b/prt/contracts/src/ITournament.sol index 63f48ad5..de7b0b29 100644 --- a/prt/contracts/src/ITournament.sol +++ b/prt/contracts/src/ITournament.sol @@ -174,6 +174,25 @@ interface ITournament { Time.Instant finishedAt; } + /// @notice A commitment's raw join record and clock snapshot. + /// @dev A historical snapshot of commitment storage: eliminated + /// commitments intentionally keep their last written clock, and match + /// topology, not clock storage, determines liveness. `claimer` is zeroed + /// after a successful terminal bond recovery pays the winning claimer. + /// For a paused clock, `clockAllowance` is the frozen remaining reserve + /// and `clockDeadline` is canonically zero. For a running clock, + /// `clockDeadline` is the first inclusive instant at which the clock is + /// timed out (`startInstant + allowance`, fixed between clock writes) + /// and `clockAllowance` is the raw allowance behind that deadline. + struct CommitmentStandingView { + bool joined; + Machine.Hash finalState; + address claimer; + bool clockRunning; + Time.Instant clockDeadline; + Time.Duration clockAllowance; + } + /// @notice A child tournament's settlement disposition for its parent. enum InnerTournamentDisposition { UNSETTLED, @@ -750,6 +769,15 @@ interface ITournament { view returns (TournamentStandingView memory); + /// @notice Project one commitment's join record and clock by its root. + /// @dev Total: an unjoined commitment root returns a canonical all-zero + /// view with `joined` false. See `CommitmentStandingView` for the + /// snapshot semantics and the staleness caveat. + function commitmentStanding(Tree.Node commitmentRoot) + external + view + returns (CommitmentStandingView memory); + /// @notice Classify the terminal bond recovery available now. /// @dev The same classification `tryRecoveringBond` acts on: /// `TOURNAMENT_RUNNING` and `NO_WINNER` are its revert arms, diff --git a/prt/contracts/src/tournament/Tournament.sol b/prt/contracts/src/tournament/Tournament.sol index 648cfd7f..aa05ea5b 100644 --- a/prt/contracts/src/tournament/Tournament.sol +++ b/prt/contracts/src/tournament/Tournament.sol @@ -966,6 +966,26 @@ contract Tournament is ITournament { } } + function commitmentStanding(Tree.Node _commitmentRoot) + external + view + override + returns (CommitmentStandingView memory standing) + { + Clock.State memory clock = clocks[_commitmentRoot]; + if (!clock.isInitialized()) { + return standing; + } + standing.joined = true; + standing.finalState = finalStates[_commitmentRoot]; + standing.claimer = claimers[_commitmentRoot]; + standing.clockAllowance = clock.allowance; + if (clock.isRunning()) { + standing.clockRunning = true; + standing.clockDeadline = clock.startInstant.add(clock.allowance); + } + } + // // Time predicates // diff --git a/prt/contracts/test/TournamentObserver.t.sol b/prt/contracts/test/TournamentObserver.t.sol index 3ac7d427..b6d16f2d 100644 --- a/prt/contracts/test/TournamentObserver.t.sol +++ b/prt/contracts/test/TournamentObserver.t.sol @@ -41,6 +41,10 @@ contract TournamentObserverHarness is Tournament { finalStates[commitment] = finalState; } + function storeClaimer(Tree.Node commitment, address claimer) external { + claimers[commitment] = claimer; + } + function storeTopology( Tree.Node candidate, uint256 activeMatchCount, @@ -875,6 +879,43 @@ contract TournamentObserverTest is Test { assertEq(Time.Duration.unwrap(descriptor.allowance), 20); } + function testCommitmentStandingProjectsJoinRecordAndClock() public { + TournamentObserverHarness tournament = _newLeafTournament(); + Tree.Node commitment = _node(0xc1); + + ITournament.CommitmentStandingView memory absent = + tournament.commitmentStanding(commitment); + assertFalse(absent.joined); + assertEq(Machine.Hash.unwrap(absent.finalState), bytes32(0)); + assertEq(absent.claimer, address(0)); + assertFalse(absent.clockRunning); + assertEq(Time.Instant.unwrap(absent.clockDeadline), 0); + assertEq(Time.Duration.unwrap(absent.clockAllowance), 0); + + tournament.storeClock(commitment, _pausedClock(10)); + tournament.storeFinalState(commitment, _hash(0xf1)); + tournament.storeClaimer(commitment, address(0xdead)); + ITournament.CommitmentStandingView memory paused = + tournament.commitmentStanding(commitment); + assertTrue(paused.joined); + assertEq( + Machine.Hash.unwrap(paused.finalState), + Machine.Hash.unwrap(_hash(0xf1)) + ); + assertEq(paused.claimer, address(0xdead)); + assertFalse(paused.clockRunning); + assertEq(Time.Instant.unwrap(paused.clockDeadline), 0); + assertEq(Time.Duration.unwrap(paused.clockAllowance), 10); + + tournament.storeClock(commitment, _runningClock(10, 120)); + ITournament.CommitmentStandingView memory running = + tournament.commitmentStanding(commitment); + assertTrue(running.joined); + assertTrue(running.clockRunning); + assertEq(Time.Instant.unwrap(running.clockDeadline), 130); + assertEq(Time.Duration.unwrap(running.clockAllowance), 10); + } + function _assertInnerWinner( TournamentObserverHarness tournament, Tree.Node candidate, diff --git a/prt/contracts/test/fixtures/SmallSingleLevelTournament.t.sol b/prt/contracts/test/fixtures/SmallSingleLevelTournament.t.sol index 36503553..c342b58e 100644 --- a/prt/contracts/test/fixtures/SmallSingleLevelTournament.t.sol +++ b/prt/contracts/test/fixtures/SmallSingleLevelTournament.t.sol @@ -106,6 +106,20 @@ contract SmallSingleLevelTournamentTest is Test { Machine.Hash.unwrap(finalStateOne), Machine.Hash.unwrap(one.finalState()) ); + ITournament.CommitmentStandingView memory oneStanding = + tournament.commitmentStanding(one.root()); + assertTrue(oneStanding.joined); + assertFalse(oneStanding.clockRunning); + assertEq(Time.Instant.unwrap(oneStanding.clockDeadline), 0); + assertEq( + Time.Duration.unwrap(oneStanding.clockAllowance), MAX_ALLOWANCE + ); + assertEq(oneStanding.claimer, CLAIMER_ONE); + assertEq( + Machine.Hash.unwrap(oneStanding.finalState), + Machine.Hash.unwrap(one.finalState()) + ); + assertFalse(tournament.commitmentStanding(two.root()).joined); assertEq(tournament.getCommitmentJoinedCount(), 1); assertEq(tournament.getMatchCreatedCount(), 0); assertEq(tournament.getLeafMatchSealedCount(), 0); @@ -133,6 +147,13 @@ contract SmallSingleLevelTournamentTest is Test { assertTrue(pairedTwo.startInstant.isZero()); assertEq(Time.Duration.unwrap(pairedOne.allowance), MAX_ALLOWANCE); assertEq(Time.Duration.unwrap(pairedTwo.allowance), MAX_ALLOWANCE); + oneStanding = tournament.commitmentStanding(one.root()); + assertTrue(oneStanding.clockRunning); + assertEq( + Time.Instant.unwrap(oneStanding.clockDeadline), + START_BLOCK + MAX_ALLOWANCE + ); + assertFalse(tournament.commitmentStanding(two.root()).clockRunning); assertEq(tournament.getCommitmentJoinedCount(), 2); assertEq(tournament.getMatchCreatedCount(), 1); assertEq(tournament.getMatchAdvancedCount(), 0); From 3cc756e4ca55a17c72160d298ad56d4ae1055d42 Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Thu, 27 Aug 2026 17:09:44 -0300 Subject: [PATCH 07/16] feat!(prt): expose inner winner expiry in tournament standing TournamentStandingView gains winnerExpiresAt, populated only in the INNER_WINNER arm: the first inclusive instant at which the winner becomes eliminable and the standing degrades to INNER_ELIMINABLE_WINNER_EXPIRED. The arm already evaluates exactly this boundary through _winnerExpired, so the field adds no new arithmetic authority, and it is fixed once the tournament finishes (the winner clock freezes with the tournament). The consensus-consumed innerResult protocol surface is untouched. The boundary test now pins the reported instant as the exact flip block: INNER_WINNER one block before it, eliminable at it. The Rust observer and Lua adapter canonicality-check the field (nonzero exactly for INNER_WINNER, strictly after finishedAt) and discard it, and the legacy compact reader decodes the widened tuple. --- .../contracts/test/DaveAppFactory.t.sol | 3 +- .../node/src/tournament/observer.rs | 50 ++++++++++++++- docs/dispute-game.md | 3 +- prt/client-lua/player/adapter.lua | 31 +++++++++- prt/client-lua/player/reader.lua | 6 +- prt/client-lua/tests/adapter_test.lua | 62 +++++++++++++++++-- prt/client-lua/tests/semantic_reader_test.lua | 5 ++ prt/contracts/src/ITournament.sol | 6 +- prt/contracts/src/tournament/Tournament.sol | 2 + prt/contracts/test/TournamentObserver.t.sol | 41 ++++++++---- 10 files changed, 185 insertions(+), 24 deletions(-) diff --git a/cartesi-rollups/contracts/test/DaveAppFactory.t.sol b/cartesi-rollups/contracts/test/DaveAppFactory.t.sol index bad19498..f8757cf9 100644 --- a/cartesi-rollups/contracts/test/DaveAppFactory.t.sol +++ b/cartesi-rollups/contracts/test/DaveAppFactory.t.sol @@ -962,7 +962,8 @@ contract DaveAppFactoryTest is ConsensusTestUtils { candidate: Tree.ZERO_NODE, finalState: Machine.ZERO_STATE, parentCommitment: Tree.ZERO_NODE, - finishedAt: Time.currentTime() + finishedAt: Time.currentTime(), + winnerExpiresAt: Time.ZERO_INSTANT }); vm.mockCall(address(tournament), abi.encodeCall(ITournament.tournamentStanding, ()), abi.encode(failedStanding)); diff --git a/cartesi-rollups/node/src/tournament/observer.rs b/cartesi-rollups/node/src/tournament/observer.rs index 13a41406..05f7a45b 100644 --- a/cartesi-rollups/node/src/tournament/observer.rs +++ b/cartesi-rollups/node/src/tournament/observer.rs @@ -102,6 +102,11 @@ pub enum ObserverError { StandingCandidateShape { standing: u8, has_candidate: bool }, #[error("standing {standing} has invalid finishedAt value {finished_at}")] StandingFinishedAtShape { standing: u8, finished_at: u64 }, + #[error("standing {standing} has invalid winnerExpiresAt value {winner_expires_at}")] + StandingWinnerExpiresAtShape { + standing: u8, + winner_expires_at: u64, + }, #[error("inner winner does not map to either side of its recursive parent match")] InnerWinnerOutsideParentMatch, #[error("live match {match_id_hash} carries an impossible child relationship")] @@ -135,8 +140,9 @@ pub async fn read_descriptor( /// event-derived tree supplies root/inner position and the exact parent match /// used to interpret an inner winner. It is not reconciled with redundant /// match-count, final-state, or topology projections. Nonterminal candidate -/// payloads and finish instants are canonicality-checked and then discarded -/// because events own commitment placement and Hero acts only on current state. +/// payloads, finish instants, and winner-expiry instants are +/// canonicality-checked and then discarded because events own commitment +/// placement and Hero acts only on current state. pub async fn read_standings( chain: &Chain, dispute: &Dispute, @@ -368,6 +374,7 @@ fn decode_standing( let candidate = decode_candidate_shape(standing_discriminant, wire.hasCandidate, wire.candidate)?; validate_finished_at_shape(standing_discriminant, wire.finishedAt)?; + validate_winner_expires_at_shape(standing_discriminant, wire.winnerExpiresAt)?; let standing = match standing_discriminant { 0 => { @@ -705,6 +712,22 @@ fn validate_finished_at_shape(standing: u8, finished_at: u64) -> ObserverResult< } } +fn validate_winner_expires_at_shape(standing: u8, winner_expires_at: u64) -> ObserverResult<()> { + let valid = match standing { + 4 => winner_expires_at != 0, + 0..=3 | 5 | 6 => winner_expires_at == 0, + other => return Err(ObserverError::UnknownTournamentStanding(other)), + }; + if valid { + Ok(()) + } else { + Err(ObserverError::StandingWinnerExpiresAtShape { + standing, + winner_expires_at, + }) + } +} + fn require_terminal_shape( standing: u8, wire: &AbiTournamentStandingView, @@ -794,6 +817,7 @@ mod tests { finalState: B256::ZERO, parentCommitment: B256::ZERO, finishedAt: u64::from(standing >= 2), + winnerExpiresAt: if standing == 4 { 130 } else { 0 }, } } @@ -959,6 +983,28 @@ mod tests { } } + #[test] + fn standing_winner_expires_at_shape_matches_inner_winner() { + for standing in [0, 1, 2, 3, 5, 6] { + assert_eq!(validate_winner_expires_at_shape(standing, 0), Ok(())); + assert_eq!( + validate_winner_expires_at_shape(standing, 42), + Err(ObserverError::StandingWinnerExpiresAtShape { + standing, + winner_expires_at: 42, + }) + ); + } + assert_eq!(validate_winner_expires_at_shape(4, 42), Ok(())); + assert_eq!( + validate_winner_expires_at_shape(4, 0), + Err(ObserverError::StandingWinnerExpiresAtShape { + standing: 4, + winner_expires_at: 0, + }) + ); + } + #[test] fn standing_retains_only_fields_needed_for_actions() { let root = descriptor(address(1), 0, TournamentKind::Leaf, digest(9), 0); diff --git a/docs/dispute-game.md b/docs/dispute-game.md index d242a00f..7adbcea1 100644 --- a/docs/dispute-game.md +++ b/docs/dispute-game.md @@ -441,7 +441,8 @@ Root consumers read the result through `tournamentStanding()`: `ROOT_WINNER` carries the candidate and its final state, and `ROOT_FAILED` marks a finished root without a winner. Observers of an inner tournament read `INNER_WINNER` from the same view, which carries the winning candidate, its claimed final -state, and the mapped parent commitment. Parents read one typed `innerResult()` from their +state, the mapped parent commitment, and the first inclusive instant at which +the winner becomes eliminable, fixed once the tournament finishes. Parents read one typed `innerResult()` from their recorded child instead: `WINNER` maps the inner winner back to a contested parent commitment and carries its remaining carryover allowance as a typed duration, while `ELIMINABLE` covers both a no-winner child and an expired diff --git a/prt/client-lua/player/adapter.lua b/prt/client-lua/player/adapter.lua index 162f29df..6f08baa0 100644 --- a/prt/client-lua/player/adapter.lua +++ b/prt/client-lua/player/adapter.lua @@ -186,7 +186,7 @@ function Adapter.decode_result(view, raw) } end if view == Adapter.View.STANDING then - local words = abi_words(raw, 7, name) + local words = abi_words(raw, 8, name) return { standing = word_small(words[1], 8, name .. ".standing"), accepts_joins = word_bool(words[2], name .. ".acceptsJoins"), @@ -195,6 +195,8 @@ function Adapter.decode_result(view, raw) final_state = word_hash(words[5]), parent_commitment = word_hash(words[6]), finished_at = word_small(words[7], 64, name .. ".finishedAt"), + winner_expires_at = + word_small(words[8], 64, name .. ".winnerExpiresAt"), } end if view == Adapter.View.COMMITMENT then @@ -360,6 +362,32 @@ local function require_finished_at_shape(wire) end end +local function require_winner_expires_at_shape(wire) + local winner_expires_at = + required(wire.winner_expires_at, "standing winnerExpiresAt") + assert(type(winner_expires_at) == "number" + and math.type(winner_expires_at) == "integer" + and winner_expires_at >= 0, + "standing winnerExpiresAt must be a nonnegative Lua integer") + if wire.standing == 4 then + assert(winner_expires_at ~= 0, + string.format( + "standing %d requires nonzero winnerExpiresAt", + wire.standing + )) + -- The winner clock's allowance is positive, so expiry strictly + -- follows the finish instant. + assert(winner_expires_at > wire.finished_at, + "winner expiry must strictly follow the finish instant") + else + assert(winner_expires_at == 0, + string.format( + "standing %d requires zero winnerExpiresAt", + wire.standing + )) + end +end + local function decode_standing( fold, tournament_fold, @@ -369,6 +397,7 @@ local function decode_standing( ) local candidate = candidate_shape(wire) require_finished_at_shape(wire) + require_winner_expires_at_shape(wire) local expected_candidate = fold:candidate( tournament_fold.address ) diff --git a/prt/client-lua/player/reader.lua b/prt/client-lua/player/reader.lua index 22b61cd1..e88ee715 100644 --- a/prt/client-lua/player/reader.lua +++ b/prt/client-lua/player/reader.lua @@ -340,14 +340,14 @@ function Reader:read_clone_args(address, decode_sig) end function Reader:root_tournament_winner(address) - local sig = - "tournamentStanding()((uint8,bool,bool,bytes32,bytes32,bytes32,uint64))" + local sig = "tournamentStanding()" + .. "((uint8,bool,bool,bytes32,bytes32,bytes32,uint64,uint64))" local ret = self:_call(address, sig, {}) assert(#ret == 1) local compact = sanitize_string(ret[1]) local standing, candidate, final_state = compact:match( - "^%((%d+),%a+,%a+,(0x%x+),(0x%x+),0x%x+,%d+%)$" + "^%((%d+),%a+,%a+,(0x%x+),(0x%x+),0x%x+,%d+,%d+%)$" ) assert(standing, "could not decode tournamentStanding") standing = tonumber(standing) diff --git a/prt/client-lua/tests/adapter_test.lua b/prt/client-lua/tests/adapter_test.lua index b6eca503..a9cd00be 100644 --- a/prt/client-lua/tests/adapter_test.lua +++ b/prt/client-lua/tests/adapter_test.lua @@ -43,6 +43,10 @@ local function standing(tag, fields) if finished_at == nil then finished_at = tag <= 1 and 0 or 12 end + local winner_expires_at = fields.winner_expires_at + if winner_expires_at == nil then + winner_expires_at = tag == 4 and 20 or 0 + end return { standing = tag, accepts_joins = fields.accepts_joins or false, @@ -51,6 +55,7 @@ local function standing(tag, fields) final_state = fields.final_state or Hash.zero, parent_commitment = fields.parent_commitment or Hash.zero, finished_at = finished_at, + winner_expires_at = winner_expires_at, } end @@ -228,6 +233,7 @@ return { hash_word(digest(3)), hash_word(Hash.zero), uint_word(42), + uint_word(0), } ) Test.equal(standing_wire.standing, 2) @@ -236,6 +242,7 @@ return { Test.truthy(Hash:is_of_type_hash(standing_wire.candidate)) Test.equal(standing_wire.final_state, digest(3)) Test.equal(standing_wire.finished_at, 42) + Test.equal(standing_wire.winner_expires_at, 0) Test.equal(Adapter.View.COMMITMENT.name, "commitmentStanding") Test.equal( @@ -335,11 +342,12 @@ return { hash_word(Hash.zero), hash_word(Hash.zero), uint_word(0), + uint_word(0), } ) end) - Test.error_like("expected 7", function() + Test.error_like("expected 8", function() Adapter.decode_result( Adapter.View.STANDING, encoded { @@ -364,6 +372,7 @@ return { hash_word(Hash.zero), hash_word(Hash.zero), string.rep("0", 47) .. "1" .. string.rep("0", 16), + uint_word(0), } ) end) @@ -493,7 +502,7 @@ return { end) end), - Test.case("legacy winner reader accepts the seven-field standing", function() + Test.case("legacy winner reader accepts the eight-field standing", function() local candidate = digest(90) local final_state = digest(91) local reader = Reader:new("unused") @@ -501,12 +510,12 @@ return { Test.equal( signature, "tournamentStanding()" - .. "((uint8,bool,bool,bytes32,bytes32,bytes32,uint64))" + .. "((uint8,bool,bool,bytes32,bytes32,bytes32,uint64,uint64))" ) Test.equal(#arguments, 0) return { string.format( - "(2, false, true, %s, %s, %s, 42)", + "(2, false, true, %s, %s, %s, 42, 0)", candidate:hex_string(), final_state:hex_string(), Hash.zero:hex_string() @@ -831,6 +840,51 @@ return { end) end), + Test.case("inner-winner standing requires a nonzero winner expiry", function() + local root = address(1) + local child = address(2) + local fold, _, one = live_fold(root, child) + local child_candidate = digest(80) + fold:apply(Fold.event( + child, + 7, + Fold.Event.commitment_joined(child_candidate, digest(99)) + )) + local parent_projection = sealed(3, { + final_state_one = digest(99), + final_state_two = digest(82), + }) + local responses = live_responses( + root, + descriptor { kind = 1 }, + 3, + parent_projection + ) + responses[child] = { + tournamentDescriptor = descriptor { + initial_hash = parent_projection.agree_state, + base_cycle = parent_projection.divergence_cycle, + height = 2, + level = 1, + kind = 0, + }, + tournamentStanding = standing(4, { + has_candidate = true, + candidate = child_candidate, + final_state = digest(99), + parent_commitment = one, + winner_expires_at = 0, + }), + } + Test.error_like("requires nonzero winnerExpiresAt", function() + Adapter.observe_fold( + mock_transport(responses), + fold, + head() + ) + end) + end), + Test.case("inner-winner wire final state must agree with the fold", function() local root = address(1) local child = address(2) diff --git a/prt/client-lua/tests/semantic_reader_test.lua b/prt/client-lua/tests/semantic_reader_test.lua index a2b63325..87b47c57 100644 --- a/prt/client-lua/tests/semantic_reader_test.lua +++ b/prt/client-lua/tests/semantic_reader_test.lua @@ -165,6 +165,10 @@ local function standing(tag, fields) if finished_at == nil then finished_at = tag <= 1 and 0 or 12 end + local winner_expires_at = fields.winner_expires_at + if winner_expires_at == nil then + winner_expires_at = tag == 4 and 20 or 0 + end return { standing = tag, accepts_joins = fields.accepts_joins or false, @@ -173,6 +177,7 @@ local function standing(tag, fields) final_state = fields.final_state or Hash.zero, parent_commitment = fields.parent_commitment or Hash.zero, finished_at = finished_at, + winner_expires_at = winner_expires_at, } end diff --git a/prt/contracts/src/ITournament.sol b/prt/contracts/src/ITournament.sol index de7b0b29..f385cf62 100644 --- a/prt/contracts/src/ITournament.sol +++ b/prt/contracts/src/ITournament.sol @@ -172,6 +172,7 @@ interface ITournament { Machine.Hash finalState; Tree.Node parentCommitment; Time.Instant finishedAt; + Time.Instant winnerExpiresAt; } /// @notice A commitment's raw join record and clock snapshot. @@ -763,7 +764,10 @@ interface ITournament { /// claimed final state, and `parentCommitment` only for /// `INNER_WINNER`. `finishedAt` is canonically zero while unfinished and /// otherwise reports the exact block-number instant the tournament became - /// safe to decide. + /// safe to decide. `winnerExpiresAt` is populated only for `INNER_WINNER`: + /// the first inclusive instant at which the winner becomes eliminable, + /// when the standing degrades to `INNER_ELIMINABLE_WINNER_EXPIRED`. It is + /// fixed once the tournament finishes. function tournamentStanding() external view diff --git a/prt/contracts/src/tournament/Tournament.sol b/prt/contracts/src/tournament/Tournament.sol index aa05ea5b..ffdfd484 100644 --- a/prt/contracts/src/tournament/Tournament.sol +++ b/prt/contracts/src/tournament/Tournament.sol @@ -963,6 +963,8 @@ contract Tournament is ITournament { standing.finalState = finalState; standing.parentCommitment = _parentCommitment(args.nestedDispute, finalState); + standing.winnerExpiresAt = + resultAt.add(clocks[candidate].pausedAllowance()); } } diff --git a/prt/contracts/test/TournamentObserver.t.sol b/prt/contracts/test/TournamentObserver.t.sol index b6d16f2d..626881c1 100644 --- a/prt/contracts/test/TournamentObserver.t.sol +++ b/prt/contracts/test/TournamentObserver.t.sol @@ -271,7 +271,8 @@ contract TournamentObserverTest is Test { candidate: candidate, finalState: Machine.ZERO_STATE, parentCommitment: Tree.ZERO_NODE, - finishedAt: Time.ZERO_INSTANT + finishedAt: Time.ZERO_INSTANT, + winnerExpiresAt: Time.ZERO_INSTANT }) ); } @@ -290,7 +291,8 @@ contract TournamentObserverTest is Test { candidate: Tree.ZERO_NODE, finalState: Machine.ZERO_STATE, parentCommitment: Tree.ZERO_NODE, - finishedAt: Time.ZERO_INSTANT + finishedAt: Time.ZERO_INSTANT, + winnerExpiresAt: Time.ZERO_INSTANT }) ); } @@ -310,7 +312,8 @@ contract TournamentObserverTest is Test { candidate: candidate, finalState: Machine.ZERO_STATE, parentCommitment: Tree.ZERO_NODE, - finishedAt: Time.ZERO_INSTANT + finishedAt: Time.ZERO_INSTANT, + winnerExpiresAt: Time.ZERO_INSTANT }) ); } @@ -331,7 +334,8 @@ contract TournamentObserverTest is Test { candidate: first, finalState: Machine.ZERO_STATE, parentCommitment: Tree.ZERO_NODE, - finishedAt: Time.ZERO_INSTANT + finishedAt: Time.ZERO_INSTANT, + winnerExpiresAt: Time.ZERO_INSTANT }) ); @@ -345,7 +349,8 @@ contract TournamentObserverTest is Test { candidate: Tree.ZERO_NODE, finalState: Machine.ZERO_STATE, parentCommitment: Tree.ZERO_NODE, - finishedAt: Time.ZERO_INSTANT + finishedAt: Time.ZERO_INSTANT, + winnerExpiresAt: Time.ZERO_INSTANT }) ); } @@ -367,7 +372,8 @@ contract TournamentObserverTest is Test { candidate: candidate, finalState: finalState, parentCommitment: Tree.ZERO_NODE, - finishedAt: _instant(120) + finishedAt: _instant(120), + winnerExpiresAt: Time.ZERO_INSTANT }) ); @@ -381,7 +387,8 @@ contract TournamentObserverTest is Test { candidate: Tree.ZERO_NODE, finalState: Machine.ZERO_STATE, parentCommitment: Tree.ZERO_NODE, - finishedAt: _instant(120) + finishedAt: _instant(120), + winnerExpiresAt: Time.ZERO_INSTANT }) ); } @@ -423,8 +430,17 @@ contract TournamentObserverTest is Test { tournament.storeTopology(candidate, 0, _instant(125)); tournament.storeFinalState(candidate, nested.contestedFinalStateOne); tournament.storeClock(candidate, _pausedClock(10)); - vm.roll(135); + // One block before the reported expiry the winner still stands. + vm.roll(134); + _assertInnerWinner( + tournament, + candidate, + nested.contestedFinalStateOne, + nested.contestedCommitmentOne + ); + + vm.roll(135); _assertStanding( tournament, ITournament.TournamentStandingView({ @@ -435,7 +451,8 @@ contract TournamentObserverTest is Test { candidate: candidate, finalState: Machine.ZERO_STATE, parentCommitment: Tree.ZERO_NODE, - finishedAt: _instant(125) + finishedAt: _instant(125), + winnerExpiresAt: Time.ZERO_INSTANT }) ); } @@ -457,7 +474,8 @@ contract TournamentObserverTest is Test { candidate: Tree.ZERO_NODE, finalState: Machine.ZERO_STATE, parentCommitment: Tree.ZERO_NODE, - finishedAt: _instant(125) + finishedAt: _instant(125), + winnerExpiresAt: Time.ZERO_INSTANT }) ); } @@ -931,7 +949,8 @@ contract TournamentObserverTest is Test { candidate: candidate, finalState: finalState, parentCommitment: parentCommitment, - finishedAt: _instant(125) + finishedAt: _instant(125), + winnerExpiresAt: _instant(135) }) ); } From 74c4dcda1e60ea092fe4ae249afabfac74a5f8b9 Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Thu, 27 Aug 2026 17:16:07 -0300 Subject: [PATCH 08/16] feat!(prt): advertise exact interfaces through ERC-165 Tournament answers supportsInterface for type(ITournament).interfaceId (reachable through the clones), and MultiLevelTournamentFactory for both type(IMultiLevelTournamentFactory).interfaceId and the base type(ITournamentFactory).interfaceId, since type(I).interfaceId excludes inherited functions. Any interface change flips the id, so the answer doubles as an exact-generation gate for consumers, mirroring DaveConsensus. Applied last in this interface pass so the advertised ids are the final shipped shapes. No client changes: nothing in this repo consumes the gate. --- prt/contracts/src/ITournament.sol | 4 ++ prt/contracts/src/tournament/Tournament.sol | 17 ++++++- .../factories/MultiLevelTournamentFactory.sol | 19 ++++++- prt/contracts/test/Erc165.t.sol | 51 +++++++++++++++++++ 4 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 prt/contracts/test/Erc165.t.sol diff --git a/prt/contracts/src/ITournament.sol b/prt/contracts/src/ITournament.sol index f385cf62..b9bac709 100644 --- a/prt/contracts/src/ITournament.sol +++ b/prt/contracts/src/ITournament.sol @@ -12,6 +12,10 @@ import {Machine} from "prt-contracts/types/Machine.sol"; import {Tree} from "prt-contracts/types/Tree.sol"; /// @notice Tournament interface +/// @dev Deployed Tournament implementations advertise +/// `type(ITournament).interfaceId` through ERC-165 `supportsInterface`. +/// Any interface change flips the id, so the answer doubles as an +/// exact-generation gate for consumers. interface ITournament { // // Types diff --git a/prt/contracts/src/tournament/Tournament.sol b/prt/contracts/src/tournament/Tournament.sol index ffdfd484..9b0e0ba2 100644 --- a/prt/contracts/src/tournament/Tournament.sol +++ b/prt/contracts/src/tournament/Tournament.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.17; import {Clones} from "@openzeppelin-contracts-5.5.0/proxy/Clones.sol"; +import {ERC165} from "@openzeppelin-contracts-5.5.0/utils/introspection/ERC165.sol"; import {Math} from "@openzeppelin-contracts-5.5.0/utils/math/Math.sol"; import {IStateTransition} from "prt-contracts/IStateTransition.sol"; @@ -51,7 +52,7 @@ import {Tree} from "prt-contracts/types/Tree.sol"; /// * Non-leaf tournaments: /// - Use `sealInnerMatchAndCreateInnerTournament` and `winInnerTournament`. /// - Can recursively create new inner tournaments via `instantiateInner`. -contract Tournament is ITournament { +contract Tournament is ITournament, ERC165 { using Clones for address; using Machine for Machine.Hash; using Tree for Tree.Node; @@ -1323,6 +1324,20 @@ contract Tournament is ITournament { return newInnerTournamentCount; } + /// @notice ERC-165 advertisement of this deployment generation's exact + /// tournament interface. + /// @dev Any `ITournament` change flips the id, so the answer doubles as + /// an exact-generation gate for consumers. + function supportsInterface(bytes4 interfaceId) + public + view + override + returns (bool) + { + return interfaceId == type(ITournament).interfaceId + || super.supportsInterface(interfaceId); + } + function _ensureTournamentIsNotFinished() private view { TournamentArguments memory args = _tournamentArgs(); require(!_isFinished(args), TournamentIsFinished()); diff --git a/prt/contracts/src/tournament/factories/MultiLevelTournamentFactory.sol b/prt/contracts/src/tournament/factories/MultiLevelTournamentFactory.sol index 1a085606..6bcbc526 100644 --- a/prt/contracts/src/tournament/factories/MultiLevelTournamentFactory.sol +++ b/prt/contracts/src/tournament/factories/MultiLevelTournamentFactory.sol @@ -5,11 +5,13 @@ pragma solidity ^0.8.17; import {Clones} from "@openzeppelin-contracts-5.5.0/proxy/Clones.sol"; import {Errors} from "@openzeppelin-contracts-5.5.0/utils/Errors.sol"; +import {ERC165} from "@openzeppelin-contracts-5.5.0/utils/introspection/ERC165.sol"; import {IMultiLevelTournamentFactory} from "./IMultiLevelTournamentFactory.sol"; import {IDataProvider} from "prt-contracts/IDataProvider.sol"; import {IStateTransition} from "prt-contracts/IStateTransition.sol"; import {ITournament} from "prt-contracts/ITournament.sol"; +import {ITournamentFactory} from "prt-contracts/ITournamentFactory.sol"; import {ITournamentParametersProvider} from "prt-contracts/arbitration-config/ITournamentParametersProvider.sol"; import {Tournament} from "prt-contracts/tournament/Tournament.sol"; import {Commitment} from "prt-contracts/tournament/libs/Commitment.sol"; @@ -20,7 +22,7 @@ import {Tree} from "prt-contracts/types/Tree.sol"; /// @dev The immutable provider is the sole authority for a parameter table /// trusted to remain coherent and stable for this factory's lifetime. -contract MultiLevelTournamentFactory is IMultiLevelTournamentFactory { +contract MultiLevelTournamentFactory is IMultiLevelTournamentFactory, ERC165 { using Clones for address; Tournament immutable IMPL; @@ -183,4 +185,19 @@ contract MultiLevelTournamentFactory is IMultiLevelTournamentFactory { ? ITournament.TournamentKind.LEAF : ITournament.TournamentKind.NON_LEAF; } + + /// @notice ERC-165 advertisement of the exact factory interfaces. + /// @dev `type(I).interfaceId` excludes inherited functions, so the + /// multi-level id and the base `ITournamentFactory` id are advertised + /// separately. + function supportsInterface(bytes4 interfaceId) + public + view + override + returns (bool) + { + return interfaceId == type(IMultiLevelTournamentFactory).interfaceId + || interfaceId == type(ITournamentFactory).interfaceId + || super.supportsInterface(interfaceId); + } } diff --git a/prt/contracts/test/Erc165.t.sol b/prt/contracts/test/Erc165.t.sol new file mode 100644 index 00000000..eaac614e --- /dev/null +++ b/prt/contracts/test/Erc165.t.sol @@ -0,0 +1,51 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.17; + +import {IERC165} from "@openzeppelin-contracts-5.5.0/utils/introspection/IERC165.sol"; + +import {IDataProvider} from "src/IDataProvider.sol"; +import {ITournament} from "src/ITournament.sol"; +import {ITournamentFactory} from "src/ITournamentFactory.sol"; +import {IMultiLevelTournamentFactory} from "src/tournament/factories/IMultiLevelTournamentFactory.sol"; +import {MultiLevelTournamentFactory} from "src/tournament/factories/MultiLevelTournamentFactory.sol"; +import {Machine} from "src/types/Machine.sol"; + +import {Util} from "./Util.sol"; + +contract Erc165Test is Util { + function testFactoryAdvertisesExactFactoryInterfaces() public { + MultiLevelTournamentFactory factory = + instantiateSingleLevelTournamentFactory(0, 3); + + assertTrue( + factory.supportsInterface( + type(IMultiLevelTournamentFactory).interfaceId + ) + ); + assertTrue( + factory.supportsInterface(type(ITournamentFactory).interfaceId) + ); + assertTrue(factory.supportsInterface(type(IERC165).interfaceId)); + assertFalse(factory.supportsInterface(0xffffffff)); + assertFalse(factory.supportsInterface(type(ITournament).interfaceId)); + } + + function testTournamentCloneAdvertisesExactTournamentInterface() public { + MultiLevelTournamentFactory factory = + instantiateSingleLevelTournamentFactory(0, 3); + ITournament tournament = + factory.instantiate(Machine.ZERO_STATE, IDataProvider(address(0))); + + IERC165 clone = IERC165(address(tournament)); + assertTrue(clone.supportsInterface(type(ITournament).interfaceId)); + assertTrue(clone.supportsInterface(type(IERC165).interfaceId)); + assertFalse(clone.supportsInterface(0xffffffff)); + assertFalse( + clone.supportsInterface( + type(IMultiLevelTournamentFactory).interfaceId + ) + ); + } +} From af9403aef8d5b13f0b1316754bcf3af11b8d6c93 Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Thu, 27 Aug 2026 17:25:35 -0300 Subject: [PATCH 09/16] docs: record settlement decoupling, foreclosure intent, claimer constraint Three commitments from the alpha.4 API review reply: - epoch-lifecycle.md states that settlement never touches the bond path and why (consensus liveness must not depend on the tournament payment path; no recipient code on settlement transactions), and names the obligation it creates: every node implementation owns driving bond recovery per retired tournament, one bond per epoch at stake. - epoch-lifecycle.md states the staging period's second role: the reaction interval in which application-layer foreclosure stops a decided-but-wrong result, freezing the epoch with its inputs reported as never finalized - an intended terminal state, not a stranded-value bug. - joinTournament NatSpec warns at the decision point that the caller becomes the fixed recovery claimer and must be able to receive ETH within the gas-bounded recovery payment (comment-only: hash gate confirms only metadata fingerprints moved). --- docs/epoch-lifecycle.md | 20 ++++++++++++++++++++ prt/contracts/src/ITournament.sol | 4 ++++ 2 files changed, 24 insertions(+) diff --git a/docs/epoch-lifecycle.md b/docs/epoch-lifecycle.md index 7ec26014..44974ca8 100644 --- a/docs/epoch-lifecycle.md +++ b/docs/epoch-lifecycle.md @@ -47,6 +47,26 @@ application freezes epoch progress entirely. Check foreclosure status before debugging an unexpected revert on any settlement call. Deeper contract context: `cartesi-rollups/contracts/AGENTS.md`. +The staging period is not only a sentry window: it is the reaction +interval in which the application-layer foreclosure switch can stop a +decided-but-wrong result from ever finalizing. Foreclosure freezes the +epoch exactly where it stands: a staged result is never accepted, the +input-index lower bound never advances, and `wasInputFinalized` keeps +reporting the frozen epoch's inputs as never finalized - the signal the +application layer's deposit-refund path keys on, while withdrawals fall +back to the last finalized state. The freeze is an intended terminal +state, not a stranded-value bug. + +Settlement never touches the tournament's bond path: staging and +acceptance move no value, and nothing on the consensus path calls +`tryRecoveringBond`. The decoupling is deliberate - consensus liveness +must not depend on the tournament payment path, and no recipient code +runs inside a settlement transaction. Its cost is an obligation: every +node implementation owns driving bond recovery for each retired +tournament as a permanent background duty, or one bond per epoch stays +locked with no error reported anywhere. The reference driver walks +unretired sealed epochs; see the node data flow below. + ## Node data flow Three worker threads share one SQLite database (see diff --git a/prt/contracts/src/ITournament.sol b/prt/contracts/src/ITournament.sol index b9bac709..e175cbeb 100644 --- a/prt/contracts/src/ITournament.sol +++ b/prt/contracts/src/ITournament.sol @@ -540,6 +540,10 @@ interface ITournament { /// This function must be called while passing a /// minimum amount of Wei, given by the `bondValue` view function. /// The contract will retain any extra amount. + /// The caller becomes the commitment's claimer: terminal bond recovery + /// pays this exact address through a gas-bounded call, with no recipient + /// re-designation, so the address must be able to receive ETH within the + /// recovery payment gas ceiling (see `tryRecoveringBond`). /// To better illustrate the parameters of this function, /// the diagram below displays an example commitment tree /// with a purposefully low depth for didatic reasons. From 03c7a5bf54d462f15263112c2a49e795b1083090 Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Thu, 27 Aug 2026 17:44:07 -0300 Subject: [PATCH 10/16] fix(tooling): re-pin leaf gas dependency digest for alpha 9 EXPECTED_DEPENDENCIES_SHA256 still described the pre-alpha-9 dependency tree: the pin was last set by the runner extraction (f4695f4f) and the alpha-9 bump (98f355f7) never updated it, so the reproducible leaf gas gate rejected every correctly restored checkout. The new digest was reproduced from a pristine soldeer.lock restore (wipe and reinstall yield the same value). --- cartesi-rollups/contracts/script/measure-prt-leaf-gas.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cartesi-rollups/contracts/script/measure-prt-leaf-gas.sh b/cartesi-rollups/contracts/script/measure-prt-leaf-gas.sh index c35032fe..135fd24e 100755 --- a/cartesi-rollups/contracts/script/measure-prt-leaf-gas.sh +++ b/cartesi-rollups/contracts/script/measure-prt-leaf-gas.sh @@ -2,7 +2,7 @@ set -euo pipefail readonly EXPECTED_FOUNDRY_VERSION="1.5.1-v1.5.1" -readonly EXPECTED_DEPENDENCIES_SHA256="bf5c94f033883d49e851fe57111f5031bfbbc1969c6027aedc6ac607815d4234" +readonly EXPECTED_DEPENDENCIES_SHA256="0390394d7559329a94913a96b298a798c16fb03446600ca746760d5942ae6f4d" readonly EXPECTED_MACHINE_HASH="9b358eac8ebd2aa2c7ab4c00d098da7fd90906dc571ec83ec16e889fd220e0fb" readonly EXPECTED_FOUNDRY_CONFIG='{"solc":"0.8.30","via_ir":true,"optimizer":true,"optimizer_runs":200,"evm_version":"prague"}' From f5a764fcc090fa0e42bec696b86f187e4e87a689 Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Thu, 27 Aug 2026 17:48:26 -0300 Subject: [PATCH 11/16] fix!(prt): recalibrate WIN_LEAF_MATCH to the measured maximum The 4_420_000 allocation predated the machine-yield check and the alpha-9 rollups-contracts bump, which together made the maximum-input leaf-proof path cheaper; the exact-relationship witnesses have been failing since. Under the pinned release environment (forge 1.5.1-v1.5.1, clean tree, matching dependency digests) the maximum rounded recommendation is 3_885_000 (two-winning orientation; the one-winning orientation rounds 1_000 lower and is recorded as alternate slack). The selection adopts the recommendation exactly with zero retained headroom. Propagation: leaf terminal allocation 4_550_000 -> 4_015_000, leaf refund cap 0.221 -> 0.19425 ether, and the leaf-height work reserves and join bonds shrink accordingly (non-leaf values unchanged). Every other action family's recommendation stayed within its configured allocation. Constants-only: ABI and storage unchanged, bytecode and deployment identity change. --- .../contracts/test/gas/PrtLeafProofGasFfi.t.sol | 8 ++++++-- prt/contracts/src/tournament/libs/Gas.sol | 2 +- prt/contracts/test/accounting/RefundReserve.t.sol | 14 +++++++------- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/cartesi-rollups/contracts/test/gas/PrtLeafProofGasFfi.t.sol b/cartesi-rollups/contracts/test/gas/PrtLeafProofGasFfi.t.sol index d5451c60..fb4c36f5 100644 --- a/cartesi-rollups/contracts/test/gas/PrtLeafProofGasFfi.t.sol +++ b/cartesi-rollups/contracts/test/gas/PrtLeafProofGasFfi.t.sol @@ -69,7 +69,11 @@ contract RevertLeafWinTwoFfiTest is LeafTournamentGasFixture { abstract contract InputLeafWinFfiTest is LeafTournamentGasFixture { uint256 internal constant SECOND_INPUT_COUNTER = 1 << 68; - uint256 internal constant WIN_LEAF_MATCH_RETAINED_HEADROOM = 1_000; + // The selection adopts the maximum rounded recommendation exactly: the + // two-winning orientation. The one-winning orientation rounds 1,000 + // units lower, so the alternate records that slack explicitly. + uint256 internal constant WIN_LEAF_MATCH_RETAINED_HEADROOM = 0; + uint256 internal constant WIN_LEAF_MATCH_ALTERNATE_HEADROOM = 1_000; function _payloads(uint256 targetPayloadSize) internal pure returns (uint256[] memory sizes) { sizes = new uint256[](2); @@ -118,7 +122,7 @@ contract MaximumInputLeafWinOneFfiTest is InputLeafWinFfiTest { function testMeasureMaximumInputWithOneWinning() public { Measurement memory result = _measureLeafWin("maximum input one wins"); assertEq( - _roundUpToThousand(_minimumReviewedAllocation(result)) + WIN_LEAF_MATCH_RETAINED_HEADROOM, + _roundUpToThousand(_minimumReviewedAllocation(result)) + WIN_LEAF_MATCH_ALTERNATE_HEADROOM, Gas.WIN_LEAF_MATCH ); } diff --git a/prt/contracts/src/tournament/libs/Gas.sol b/prt/contracts/src/tournament/libs/Gas.sol index 4e8b14a5..234485d7 100644 --- a/prt/contracts/src/tournament/libs/Gas.sol +++ b/prt/contracts/src/tournament/libs/Gas.sol @@ -23,5 +23,5 @@ library Gas { uint256 constant WIN_INNER_TOURNAMENT = 313000 + TX; uint256 constant ELIMINATE_INNER_TOURNAMENT = 147000 + TX; uint256 constant SEAL_LEAF_MATCH = 105000 + TX; - uint256 constant WIN_LEAF_MATCH = 4_420_000; + uint256 constant WIN_LEAF_MATCH = 3_885_000; } diff --git a/prt/contracts/test/accounting/RefundReserve.t.sol b/prt/contracts/test/accounting/RefundReserve.t.sol index 80bfb21b..4f006182 100644 --- a/prt/contracts/test/accounting/RefundReserve.t.sol +++ b/prt/contracts/test/accounting/RefundReserve.t.sol @@ -98,20 +98,20 @@ contract RefundReserveTest is Test { assertEq(Bond.REFUND_PRIORITY_FEE_CAP, 10 gwei); uint256 leafTerminal = Bond.terminalAllocation(true); uint256 nonLeafTerminal = Bond.terminalAllocation(false); - assertEq(Gas.WIN_LEAF_MATCH, 4_420_000); - assertEq(leafTerminal, 4_550_000); + assertEq(Gas.WIN_LEAF_MATCH, 3_885_000); + assertEq(leafTerminal, 4_015_000); assertEq(nonLeafTerminal, 701_000); - assertEq(Bond.actionRefundCap(Gas.WIN_LEAF_MATCH), 0.221 ether); + assertEq(Bond.actionRefundCap(Gas.WIN_LEAF_MATCH), 0.19425 ether); assertEq(Bond.matchWorkAllocation(48, false), 6_670_000); assertEq(Bond.matchWorkAllocation(17, false), 2_733_000); - assertEq(Bond.matchWorkAllocation(27, true), 7_852_000); + assertEq(Bond.matchWorkAllocation(27, true), 7_317_000); assertEq(Bond.matchWorkAllocation(55, false), 7_559_000); - assertEq(Bond.matchWorkAllocation(37, true), 9_122_000); + assertEq(Bond.matchWorkAllocation(37, true), 8_587_000); assertEq(Bond.bondValue(48, false), 0.3335 ether); assertEq(Bond.bondValue(17, false), 0.13665 ether); - assertEq(Bond.bondValue(27, true), 0.3926 ether); + assertEq(Bond.bondValue(27, true), 0.36585 ether); assertEq(Bond.bondValue(55, false), 0.37795 ether); - assertEq(Bond.bondValue(37, true), 0.4561 ether); + assertEq(Bond.bondValue(37, true), 0.42935 ether); uint256 invalidZeroLeafWork = leafTerminal - Gas.ADVANCE_MATCH; uint256 invalidZeroLeafBond = invalidZeroLeafWork * Bond.WORK_PRICE_CAP; From acaab47af1f962dd4bbd43ee6fd6306e5135cc2a Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Thu, 27 Aug 2026 17:49:54 -0300 Subject: [PATCH 12/16] docs(reviews): record 2026-08-27 leaf gas recalibration --- .../README.md | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 docs/reviews/2026-08-27-prt-leaf-gas-recalibration/README.md diff --git a/docs/reviews/2026-08-27-prt-leaf-gas-recalibration/README.md b/docs/reviews/2026-08-27-prt-leaf-gas-recalibration/README.md new file mode 100644 index 00000000..7019cccf --- /dev/null +++ b/docs/reviews/2026-08-27-prt-leaf-gas-recalibration/README.md @@ -0,0 +1,82 @@ +# 2026-08-27 PRT leaf gas recalibration + +Recalibration of `Gas.WIN_LEAF_MATCH` after the exact-relationship +maximum-input witnesses began failing: the retained `4_420_000` selection +predated the machine-yield check (53c4c424) and the alpha-9 +rollups-contracts bump (98f355f7), which together made the maximum-input +leaf-proof path cheaper. The reproducible leaf gate's dependency-digest pin +had also not been updated for alpha 9 and rejected every correctly restored +checkout; it was re-pinned first (f3f49968) from a pristine `soldeer.lock` +restore reproduced by wipe-and-reinstall. + +## Environment + +- Accepted candidate: `021b5ae929f9bb7a23ede383d02afe53bd827c0e`, clean + worktree, `just measure-prt-gas` exit 0. +- Forge: official release `1.5.1-v1.5.1` (commit b0a9dd9c, maxperf), now + provided by the development flake as the official release binaries; the + previous nixpkgs source build reported `1.5.1-dev` and is rejected by the + measurement guard. +- Effective config: solc 0.8.30, via-ir, optimizer 200 runs, Prague EVM + (both projects). +- PRT dependencies sha256 + `ef44ca028e8ae45ab0d7a6b183c9db0fded37461db8355456f2b2b876ce57ac3`; + rollups dependencies sha256 + `0390394d7559329a94913a96b298a798c16fb03446600ca746760d5942ae6f4d` + (alpha-9 tree). +- `machine/step` at 23765c88 (v0.15.0); yield machine hash + `9b358eac8ebd2aa2c7ab4c00d098da7fd90906dc571ec83ec16e889fd220e0fb`. +- macOS (Darwin 25.5.0), aarch64. + +## Measurements and selection + +Maximum-input full-stack leaf-win witnesses (the reference path): + +| Witness | Reviewed minimum | Rounded recommendation | +| --- | --- | --- | +| maximum input two wins (selected) | 3,884,067 | 3,885,000 | +| maximum input one wins (alternate) | 3,883,969 | 3,884,000 | + +Selection: `WIN_LEAF_MATCH = 3_885_000`, adopting the maximum rounded +recommendation exactly with zero retained headroom. The two winner +orientations now straddle a 1,000-unit rounding boundary; the selected +two-winning witness asserts the exact recommendation and the one-winning +alternate records its 1,000-unit slack explicitly +(`WIN_LEAF_MATCH_ALTERNATE_HEADROOM`). + +Other retained leaf witnesses (rounded recommendations): representative +input 1,748,000; small input well below; revert 613,000; reset 558,000; +out-of-range 818,000; ordinary step 809,000. Every Tournament-only action +family's recommendation stayed at or below its configured allocation +(largest deltas: advance match 125,000 vs 127,000 configured; timeout win +260,000 vs 262,000), so no other constant moved. + +## Propagation + +- Leaf terminal allocation: 4,550,000 -> 4,015,000; non-leaf terminal + unchanged at 701,000. +- Leaf action refund cap: 0.221 -> 0.19425 ether at the 50 gwei work-price + cap (policy constants unchanged). +- Leaf work reserves and join bonds shrink accordingly, for example + `matchWorkAllocation(27, true)` 7,852,000 -> 7,317,000 (bond 0.3926 -> + 0.36585 ether) and `matchWorkAllocation(37, true)` 9,122,000 -> + 8,587,000 (bond 0.4561 -> 0.42935 ether). Non-leaf rows unchanged. +- Constants-only change: wire ABI and storage layout unchanged; Tournament + bytecode and deployment identity change, so deployment artifacts and + CREATE2-derived addresses must be regenerated before release. + +## Network admission headroom + +Largest retained whole-transaction diagnostic: 3,560,586 units (maximum +input two wins), down from the 2026-07-23 record's 5,359,940. Against +Ethereum Mainnet's EIP-7825 transaction cap of 16,777,216 units this is +21.2%; against the observed 60,000,000 block gas limit, 5.9%. Dated +evidence, not a permanent constant. + +## Validation + +`just measure-prt-gas` (acceptance, exit 0), `just test-prt-gas` (18 + 12 +witnesses), formatting gates, and the disputes, rollups, and workspace +suites run in the same session on the candidate line. Witness assertions +pin the exact selection and the recorded alternate slack; reserve algebra +pins in `RefundReserve.t.sol` were recomputed for the leaf role. From 64a1edd3f708dc271baea4e4a2d847ea54402c1e Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Fri, 28 Aug 2026 16:38:50 -0300 Subject: [PATCH 13/16] fix(node): idle on a failed tournament instead of panicking A failed root is a documented terminal state, not a local contradiction: the ticked Hero path already logs FailedNoWinner and idles, and plan_stage_tournament_result also runs with no Hero (Absent), where the assert turned a restart into a crash loop. Treat isTournamentFailed like not-finished: log at error level and plan nothing. stageTournamentResult's TournamentFailedNoWinner revert remains the write-side guard. --- cartesi-rollups/node/src/epoch_manager/mod.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/cartesi-rollups/node/src/epoch_manager/mod.rs b/cartesi-rollups/node/src/epoch_manager/mod.rs index 15fced1f..72957125 100644 --- a/cartesi-rollups/node/src/epoch_manager/mod.rs +++ b/cartesi-rollups/node/src/epoch_manager/mod.rs @@ -296,10 +296,18 @@ impl EpochManager { .call() .await?; - assert!( - !can_stage.isTournamentFailed, - "Tournament finished without a winner, notify all users!" - ); + // A failed root is a documented terminal state, not a local + // contradiction: the ticked Hero path already logs and idles on + // FailedNoWinner, and this path also runs with no Hero (Absent), + // where crashing would loop on restart. stageTournamentResult's + // TournamentFailedNoWinner revert remains the write-side guard. + if can_stage.isTournamentFailed { + log::error!( + "dispute tournament for epoch {} finished without a winner; settlement is impossible, notify all users!", + can_stage.epochNumber + ); + return Ok(None); + } if !can_stage.isFinished || can_stage.isTournamentResultStaged { trace!("tournament result not ready to be staged"); From bd58becaaa95e32d690ac4971214f9f393ad0a0e Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Fri, 28 Aug 2026 16:38:50 -0300 Subject: [PATCH 14/16] fix(prt): clone the fold's mutable position breadcrumb The segment-start breadcrumb skipped the fold's defensive-cloning convention (eliminable_at already clones through bint): ingestion retained the caller's mutable bint and copy_match aliased fold storage into accessor results. Clone at both seams, with regressions for both mutation vectors. --- prt/client-lua/player/fold.lua | 6 ++++-- prt/client-lua/tests/fold_test.lua | 24 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/prt/client-lua/player/fold.lua b/prt/client-lua/player/fold.lua index cad3be03..b5c84ee3 100644 --- a/prt/client-lua/player/fold.lua +++ b/prt/client-lua/player/fold.lua @@ -114,7 +114,8 @@ local function copy_match(match) advances = match.advances, last_other_parent = match.last_other_parent, last_left_node = match.last_left_node, - last_segment_start_position = match.last_segment_start_position, + last_segment_start_position = + bint(match.last_segment_start_position), inner_tournament = match.inner_tournament, deleted = deleted, } @@ -400,7 +401,8 @@ function Fold:apply(event) match.advances = match.advances + 1 match.last_other_parent = kind.other_parent match.last_left_node = kind.left_node - match.last_segment_start_position = kind.segment_start_position + -- Clone: bint values are mutable, and the caller retains the event. + match.last_segment_start_position = bint(kind.segment_start_position) match.eliminable_at = uint64( kind.eliminable_at, "match elimination block" diff --git a/prt/client-lua/tests/fold_test.lua b/prt/client-lua/tests/fold_test.lua index 7ab5f14b..c8d942b7 100644 --- a/prt/client-lua/tests/fold_test.lua +++ b/prt/client-lua/tests/fold_test.lua @@ -1,5 +1,6 @@ local Fold = require "player.fold" local Test = require "tests.testlib" +local bint = require "utils.bint" (256) local E = Fold.Event @@ -92,6 +93,29 @@ return { Test.equal(#fold:live_matches("root"), 0) end), + Test.case("fold clones the mutable position breadcrumb", function() + local fold = matched_fold() + local position = bint(7) + fold:apply(event( + "root", + 4, + E.match_advanced("a:b", "other", "left", position, 40) + )) + + -- Mutating the retained event value must not reach the fold. + position[1] = 99 + local read = fold:match_by_id_hash("root", "a:b") + Test.truthy(bint.eq(read.last_segment_start_position, 7), + "ingestion must clone the event's position") + + -- Mutating an accessor result must not reach the fold either. + read.last_segment_start_position[1] = 42 + Test.truthy(bint.eq( + fold:match_by_id_hash("root", "a:b").last_segment_start_position, + 7 + ), "copies must not alias fold storage") + end), + Test.case("fold rejects malformed event streams", function() local rows = { { From 42b6e230434497663c94ec8dcb27a715a1c1be4f Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Fri, 28 Aug 2026 16:38:50 -0300 Subject: [PATCH 15/16] fix(node): require winner expiry strictly after the finish instant The observer's winnerExpiresAt shape check only required nonzero for INNER_WINNER, weaker than the stated canonicality rule and the Lua adapter's check. The winner clock's allowance is positive, so a canonical expiry strictly follows finishedAt; enforce it and pin the equality and earlier-expiry rejections. --- .../node/src/tournament/observer.rs | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/cartesi-rollups/node/src/tournament/observer.rs b/cartesi-rollups/node/src/tournament/observer.rs index 05f7a45b..e7c0932f 100644 --- a/cartesi-rollups/node/src/tournament/observer.rs +++ b/cartesi-rollups/node/src/tournament/observer.rs @@ -374,7 +374,7 @@ fn decode_standing( let candidate = decode_candidate_shape(standing_discriminant, wire.hasCandidate, wire.candidate)?; validate_finished_at_shape(standing_discriminant, wire.finishedAt)?; - validate_winner_expires_at_shape(standing_discriminant, wire.winnerExpiresAt)?; + validate_winner_expires_at_shape(standing_discriminant, wire.finishedAt, wire.winnerExpiresAt)?; let standing = match standing_discriminant { 0 => { @@ -712,9 +712,15 @@ fn validate_finished_at_shape(standing: u8, finished_at: u64) -> ObserverResult< } } -fn validate_winner_expires_at_shape(standing: u8, winner_expires_at: u64) -> ObserverResult<()> { +fn validate_winner_expires_at_shape( + standing: u8, + finished_at: u64, + winner_expires_at: u64, +) -> ObserverResult<()> { + // The winner clock's allowance is positive, so a canonical expiry + // strictly follows the finish instant. let valid = match standing { - 4 => winner_expires_at != 0, + 4 => winner_expires_at != 0 && winner_expires_at > finished_at, 0..=3 | 5 | 6 => winner_expires_at == 0, other => return Err(ObserverError::UnknownTournamentStanding(other)), }; @@ -986,23 +992,26 @@ mod tests { #[test] fn standing_winner_expires_at_shape_matches_inner_winner() { for standing in [0, 1, 2, 3, 5, 6] { - assert_eq!(validate_winner_expires_at_shape(standing, 0), Ok(())); + assert_eq!(validate_winner_expires_at_shape(standing, 12, 0), Ok(())); assert_eq!( - validate_winner_expires_at_shape(standing, 42), + validate_winner_expires_at_shape(standing, 12, 42), Err(ObserverError::StandingWinnerExpiresAtShape { standing, winner_expires_at: 42, }) ); } - assert_eq!(validate_winner_expires_at_shape(4, 42), Ok(())); - assert_eq!( - validate_winner_expires_at_shape(4, 0), - Err(ObserverError::StandingWinnerExpiresAtShape { - standing: 4, - winner_expires_at: 0, - }) - ); + assert_eq!(validate_winner_expires_at_shape(4, 12, 42), Ok(())); + for winner_expires_at in [0, 11, 12] { + assert_eq!( + validate_winner_expires_at_shape(4, 12, winner_expires_at), + Err(ObserverError::StandingWinnerExpiresAtShape { + standing: 4, + winner_expires_at, + }), + "expiry must strictly follow the finish instant" + ); + } } #[test] From 8a8be70eaa99a02a9b34b3ac7d8ceaac27d077dd Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Fri, 28 Aug 2026 16:38:50 -0300 Subject: [PATCH 16/16] docs: correct gas-record admission figures and review follow-ups - The calibration record's network-admission section compared the complete-call gas to transaction limits; the right quantity is the Prague transaction estimate (5,078,866 units: 30.27% of the EIP-7825 cap, 8.46% of the observed block limit). Calldata intrinsics sit outside the refundable seam, so the allocation is unaffected. Hash citations refreshed to the post-autosquash history. - prt-refund-accounting.md: leaf terminal maximum 4,550,000 -> 4,015,000 after the recalibration. - epoch-lifecycle.md: the locked-value exposure is every retired tournament's balance (root and inner), not one bond per epoch. - winnerExpiresAt docstrings state the canonical-zero-after-expiry behavior and the finishedAt + clockAllowance recomputation. - ERC-165 docstrings now state exactly what the id fingerprints: declared function signatures only; a changed id proves a new generation, an unchanged id proves nothing, and binding pinning guards return shapes, struct layouts, and events. - word_small documents its deliberate sub-2^63 envelope; the semantic event decoder keeps full uint64 range where history demands it. --- docs/dispute-game.md | 5 ++++- docs/epoch-lifecycle.md | 6 +++-- docs/prt-refund-accounting.md | 2 +- .../README.md | 22 ++++++++++++------- prt/client-lua/player/adapter.lua | 5 +++++ prt/contracts/src/ITournament.sol | 13 +++++++---- prt/contracts/src/tournament/Tournament.sol | 9 ++++---- 7 files changed, 42 insertions(+), 20 deletions(-) diff --git a/docs/dispute-game.md b/docs/dispute-game.md index 7adbcea1..05542459 100644 --- a/docs/dispute-game.md +++ b/docs/dispute-game.md @@ -442,7 +442,10 @@ carries the candidate and its final state, and `ROOT_FAILED` marks a finished root without a winner. Observers of an inner tournament read `INNER_WINNER` from the same view, which carries the winning candidate, its claimed final state, the mapped parent commitment, and the first inclusive instant at which -the winner becomes eliminable, fixed once the tournament finishes. Parents read one typed `innerResult()` from their +the winner becomes eliminable. That instant is reported only while the winner +stands; once expired the field returns to canonical zero, and the instant +remains recomputable as the finish instant plus the winner's frozen clock +allowance. Parents read one typed `innerResult()` from their recorded child instead: `WINNER` maps the inner winner back to a contested parent commitment and carries its remaining carryover allowance as a typed duration, while `ELIMINABLE` covers both a no-winner child and an expired diff --git a/docs/epoch-lifecycle.md b/docs/epoch-lifecycle.md index 44974ca8..762635f2 100644 --- a/docs/epoch-lifecycle.md +++ b/docs/epoch-lifecycle.md @@ -63,9 +63,11 @@ acceptance move no value, and nothing on the consensus path calls must not depend on the tournament payment path, and no recipient code runs inside a settlement transaction. Its cost is an obligation: every node implementation owns driving bond recovery for each retired -tournament as a permanent background duty, or one bond per epoch stays +tournament as a permanent background duty, or every retired +tournament's balance - the root's and each inner tournament's - stays locked with no error reported anywhere. The reference driver walks -unretired sealed epochs; see the node data flow below. +unretired sealed epochs and their inner descendants; see the node data +flow below. ## Node data flow diff --git a/docs/prt-refund-accounting.md b/docs/prt-refund-accounting.md index 81afeade..3fa9a064 100644 --- a/docs/prt-refund-accounting.md +++ b/docs/prt-refund-accounting.md @@ -48,7 +48,7 @@ and sealed-inner paths are legal only in non-leaf tournaments: | Inner elimination | Non-leaf | `Gas.SEAL_INNER_MATCH_AND_CREATE_INNER_TOURNAMENT + Gas.ELIMINATE_INNER_TOURNAMENT` | `Bond.terminalAllocation(isLeafTournament)` factors these paths into direct -timeout and the selected sealed family. The current maxima are 4,550,000 gas +timeout and the selected sealed family. The current maxima are 4,015,000 gas units for leaf tournaments and 701,000 for non-leaf tournaments. The independent accounting tests enumerate every legal path for each role. A new successful terminal path must be added to both production and that role's diff --git a/docs/reviews/2026-08-27-prt-leaf-gas-recalibration/README.md b/docs/reviews/2026-08-27-prt-leaf-gas-recalibration/README.md index 7019cccf..bac5dbb8 100644 --- a/docs/reviews/2026-08-27-prt-leaf-gas-recalibration/README.md +++ b/docs/reviews/2026-08-27-prt-leaf-gas-recalibration/README.md @@ -6,13 +6,15 @@ predated the machine-yield check (53c4c424) and the alpha-9 rollups-contracts bump (98f355f7), which together made the maximum-input leaf-proof path cheaper. The reproducible leaf gate's dependency-digest pin had also not been updated for alpha 9 and rejected every correctly restored -checkout; it was re-pinned first (f3f49968) from a pristine `soldeer.lock` +checkout; it was re-pinned first (6e49aac1) from a pristine `soldeer.lock` restore reproduced by wipe-and-reinstall. ## Environment -- Accepted candidate: `021b5ae929f9bb7a23ede383d02afe53bd827c0e`, clean - worktree, `just measure-prt-gas` exit 0. +- Accepted candidate: `37292672` (`fix!(prt): recalibrate WIN_LEAF_MATCH`), + clean worktree, `just measure-prt-gas` exit 0. The acceptance run executed + on this commit's exact tree under its pre-autosquash hash and was + independently reproduced at the PR head by external review. - Forge: official release `1.5.1-v1.5.1` (commit b0a9dd9c, maxperf), now provided by the development flake as the official release binaries; the previous nixpkgs source build reported `1.5.1-dev` and is rejected by the @@ -67,11 +69,15 @@ family's recommendation stayed at or below its configured allocation ## Network admission headroom -Largest retained whole-transaction diagnostic: 3,560,586 units (maximum -input two wins), down from the 2026-07-23 record's 5,359,940. Against -Ethereum Mainnet's EIP-7825 transaction cap of 16,777,216 units this is -21.2%; against the observed 60,000,000 block gas limit, 5.9%. Dated -evidence, not a permanent constant. +Largest retained whole-transaction diagnostic: 5,078,866 units, the maximum +input two wins Prague transaction estimate (intrinsic 21,000 plus calldata +token pricing on top of the 3,560,586-unit complete call), down from the +2026-07-23 record's 5,359,940. Against Ethereum Mainnet's EIP-7825 +transaction cap of 16,777,216 units this is 30.27%, leaving 11,698,350 +units of per-transaction space; against the observed 60,000,000 block gas +limit, 8.46%, leaving 54,921,134 units. Calldata intrinsics sit outside the +refundable seam, so this section compares network admission only, not the +allocation. Dated evidence, not a permanent constant. ## Validation diff --git a/prt/client-lua/player/adapter.lua b/prt/client-lua/player/adapter.lua index 6f08baa0..dffe890f 100644 --- a/prt/client-lua/player/adapter.lua +++ b/prt/client-lua/player/adapter.lua @@ -143,6 +143,11 @@ local function word_uint(word) return "0x" .. word end +-- Deliberate envelope: a value whose Lua-integer representation would be +-- negative (at or above 2^63 for 64-bit fields) is rejected loudly rather +-- than decoded. Real block instants and configured durations sit orders of +-- magnitude below; the semantic event decoder keeps the full range via bint +-- where history demands it. local function word_small(word, bits, name) local prefix = word:sub(1, 64 - bits // 4) assert(prefix:match("^0*$"), name .. " exceeds uint" .. tostring(bits)) diff --git a/prt/contracts/src/ITournament.sol b/prt/contracts/src/ITournament.sol index e175cbeb..801a8540 100644 --- a/prt/contracts/src/ITournament.sol +++ b/prt/contracts/src/ITournament.sol @@ -14,8 +14,11 @@ import {Tree} from "prt-contracts/types/Tree.sol"; /// @notice Tournament interface /// @dev Deployed Tournament implementations advertise /// `type(ITournament).interfaceId` through ERC-165 `supportsInterface`. -/// Any interface change flips the id, so the answer doubles as an -/// exact-generation gate for consumers. +/// The id fingerprints only the declared function signatures (names and +/// parameter types): return shapes, struct layouts, and events are outside +/// it, so a changed id proves a new generation but an unchanged id does not +/// prove compatibility. Pinning bindings to release artifacts remains the +/// guard for the rest of the wire contract. interface ITournament { // // Types @@ -774,8 +777,10 @@ interface ITournament { /// otherwise reports the exact block-number instant the tournament became /// safe to decide. `winnerExpiresAt` is populated only for `INNER_WINNER`: /// the first inclusive instant at which the winner becomes eliminable, - /// when the standing degrades to `INNER_ELIMINABLE_WINNER_EXPIRED`. It is - /// fixed once the tournament finishes. + /// when the standing degrades to `INNER_ELIMINABLE_WINNER_EXPIRED`. Its + /// value is stable while the `INNER_WINNER` standing lasts and returns to + /// canonical zero once it expires; the instant remains recomputable as + /// `finishedAt` plus the winner's frozen `clockAllowance`. function tournamentStanding() external view diff --git a/prt/contracts/src/tournament/Tournament.sol b/prt/contracts/src/tournament/Tournament.sol index 9b0e0ba2..d64f8e3e 100644 --- a/prt/contracts/src/tournament/Tournament.sol +++ b/prt/contracts/src/tournament/Tournament.sol @@ -1324,10 +1324,11 @@ contract Tournament is ITournament, ERC165 { return newInnerTournamentCount; } - /// @notice ERC-165 advertisement of this deployment generation's exact - /// tournament interface. - /// @dev Any `ITournament` change flips the id, so the answer doubles as - /// an exact-generation gate for consumers. + /// @notice ERC-165 advertisement of this deployment generation's + /// tournament function surface. + /// @dev The id covers declared function signatures only; return shapes, + /// struct layouts, and events are outside it. See the `ITournament` + /// docstring for the resulting gating semantics. function supportsInterface(bytes4 interfaceId) public view