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"}' 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 f3e82aa0..f8757cf9 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 @@ -951,12 +961,28 @@ 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(), + winnerExpiresAt: Time.ZERO_INSTANT }); 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. @@ -1341,13 +1367,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/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/cartesi-rollups/node/src/epoch_manager/mod.rs b/cartesi-rollups/node/src/epoch_manager/mod.rs index 2d55ab5b..72957125 100644 --- a/cartesi-rollups/node/src/epoch_manager/mod.rs +++ b/cartesi-rollups/node/src/epoch_manager/mod.rs @@ -296,6 +296,19 @@ impl EpochManager { .call() .await?; + // 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"); return Ok(None); diff --git a/cartesi-rollups/node/src/tournament/observer.rs b/cartesi-rollups/node/src/tournament/observer.rs index c51871db..e7c0932f 100644 --- a/cartesi-rollups/node/src/tournament/observer.rs +++ b/cartesi-rollups/node/src/tournament/observer.rs @@ -100,6 +100,13 @@ 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("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")] @@ -133,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 are canonicality-checked and then discarded because events own -/// commitment placement. +/// 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, @@ -339,6 +347,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, @@ -363,6 +373,8 @@ 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)?; + validate_winner_expires_at_shape(standing_discriminant, wire.finishedAt, wire.winnerExpiresAt)?; let standing = match standing_discriminant { 0 => { @@ -423,7 +435,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 @@ -682,6 +696,44 @@ 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 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 && winner_expires_at > finished_at, + 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, @@ -753,6 +805,8 @@ mod tests { height: 4, level, kind, + startInstant: 100, + allowance: 20, } } @@ -768,6 +822,8 @@ mod tests { candidate: candidate.map_or(B256::ZERO, Into::into), finalState: B256::ZERO, parentCommitment: B256::ZERO, + finishedAt: u64::from(standing >= 2), + winnerExpiresAt: if standing == 4 { 130 } else { 0 }, } } @@ -909,11 +965,61 @@ 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_winner_expires_at_shape_matches_inner_winner() { + for standing in [0, 1, 2, 3, 5, 6] { + assert_eq!(validate_winner_expires_at_shape(standing, 12, 0), Ok(())); + assert_eq!( + validate_winner_expires_at_shape(standing, 12, 42), + Err(ObserverError::StandingWinnerExpiresAtShape { + standing, + winner_expires_at: 42, + }) + ); + } + 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] 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(), @@ -936,6 +1042,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/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/docs/dispute-game.md b/docs/dispute-game.md index ccaf0ee3..05542459 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 @@ -437,13 +439,30 @@ 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, the mapped parent commitment, and the first inclusive instant at which +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 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 +`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/docs/epoch-lifecycle.md b/docs/epoch-lifecycle.md index 7ec26014..762635f2 100644 --- a/docs/epoch-lifecycle.md +++ b/docs/epoch-lifecycle.md @@ -47,6 +47,28 @@ 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 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 and their inner descendants; see the node data +flow below. + ## Node data flow Three worker threads share one SQLite database (see 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 new file mode 100644 index 00000000..bac5dbb8 --- /dev/null +++ b/docs/reviews/2026-08-27-prt-leaf-gas-recalibration/README.md @@ -0,0 +1,88 @@ +# 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 (6e49aac1) from a pristine `soldeer.lock` +restore reproduced by wipe-and-reinstall. + +## Environment + +- 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 + 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: 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 + +`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. diff --git a/prt/client-lua/player/adapter.lua b/prt/client-lua/player/adapter.lua index 9cfa3c64..dffe890f 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))", @@ -139,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)) @@ -158,12 +167,18 @@ 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") 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,10 +186,12 @@ 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 - local words = abi_words(raw, 6, 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"), @@ -182,6 +199,22 @@ 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"), + winner_expires_at = + word_small(words[8], 64, name .. ".winnerExpiresAt"), + } + 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 @@ -248,6 +281,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 @@ -311,6 +346,53 @@ 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 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, @@ -319,6 +401,8 @@ local function decode_standing( wire ) local candidate = candidate_shape(wire) + require_finished_at_shape(wire) + require_winner_expires_at_shape(wire) local expected_candidate = fold:candidate( tournament_fold.address ) @@ -364,7 +448,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) @@ -574,16 +663,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, @@ -606,6 +698,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/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/fold.lua b/prt/client-lua/player/fold.lua index a5690b1b..b5c84ee3 100644 --- a/prt/client-lua/player/fold.lua +++ b/prt/client-lua/player/fold.lua @@ -114,6 +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 = + bint(match.last_segment_start_position), inner_tournament = match.inner_tournament, deleted = deleted, } @@ -201,12 +203,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 +385,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 +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 + -- 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/player/reader.lua b/prt/client-lua/player/reader.lua index ecc0a9bc..e88ee715 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) @@ -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))" + 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+),%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/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/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 bc1f16fa..a9cd00be 100644 --- a/prt/client-lua/tests/adapter_test.lua +++ b/prt/client-lua/tests/adapter_test.lua @@ -32,11 +32,21 @@ 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 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 + 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, @@ -44,6 +54,8 @@ 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, + winner_expires_at = winner_expires_at, } end @@ -130,6 +142,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 ) )) @@ -219,6 +232,8 @@ return { hash_word(digest(2)), hash_word(digest(3)), hash_word(Hash.zero), + uint_word(42), + uint_word(0), } ) Test.equal(standing_wire.standing, 2) @@ -226,6 +241,31 @@ 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) + Test.equal(standing_wire.winner_expires_at, 0) + + 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, @@ -301,6 +341,38 @@ return { hash_word(Hash.zero), hash_word(Hash.zero), hash_word(Hash.zero), + uint_word(0), + uint_word(0), + } + ) + end) + + Test.error_like("expected 8", 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), + uint_word(0), } ) end) @@ -312,11 +384,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 @@ -328,7 +400,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() @@ -345,6 +417,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, @@ -407,6 +481,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 eight-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,uint64))" + ) + Test.equal(#arguments, 0) + return { + string.format( + "(2, false, true, %s, %s, %s, 42, 0)", + 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 = { { @@ -496,6 +619,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() @@ -698,6 +827,7 @@ return { tournamentStanding = standing(4, { has_candidate = true, candidate = child_candidate, + final_state = digest(99), parent_commitment = one, }), } @@ -709,4 +839,93 @@ 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) + 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/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/fold_test.lua b/prt/client-lua/tests/fold_test.lua index 9932ac47..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 @@ -45,7 +46,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")), @@ -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 = { { @@ -133,7 +157,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 +180,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 +340,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/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 f813d48b..87b47c57 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), }, } @@ -152,11 +154,21 @@ 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 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 + 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, @@ -164,6 +176,8 @@ 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, + winner_expires_at = winner_expires_at, } end @@ -224,7 +238,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 { @@ -234,7 +248,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 { @@ -244,7 +258,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 ), }, }, @@ -407,7 +421,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)", @@ -443,7 +457,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 04c007ee..801a8540 100644 --- a/prt/contracts/src/ITournament.sol +++ b/prt/contracts/src/ITournament.sol @@ -12,6 +12,13 @@ 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`. +/// 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 @@ -160,6 +167,8 @@ interface ITournament { uint64 height; uint64 level; TournamentKind kind; + Time.Instant startInstant; + Time.Duration allowance; } struct TournamentStandingView { @@ -169,6 +178,27 @@ interface ITournament { Tree.Node candidate; Machine.Hash finalState; Tree.Node parentCommitment; + Time.Instant finishedAt; + Time.Instant winnerExpiresAt; + } + + /// @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. @@ -215,19 +245,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 ); @@ -509,6 +543,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. @@ -715,9 +753,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 @@ -728,13 +771,30 @@ 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 - /// `INNER_WINNER`. + /// 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. `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`. 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 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 c652f147..d64f8e3e 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; @@ -263,6 +264,7 @@ contract Tournament is ITournament { matchIdHash, _matchState.otherParent, _matchState.leftNode, + _matchState.runningLeafPosition, MatchClocks.eliminableAt(clockOne, clockTwo) ); } @@ -919,7 +921,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 }); } @@ -937,6 +941,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) { @@ -955,8 +960,32 @@ 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); + standing.winnerExpiresAt = + resultAt.add(clocks[candidate].pausedAllowance()); + } + } + + 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); } } @@ -1295,6 +1324,21 @@ contract Tournament is ITournament { return newInnerTournamentCount; } + /// @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 + override + returns (bool) + { + return interfaceId == type(ITournament).interfaceId + || super.supportsInterface(interfaceId); + } + function _ensureTournamentIsNotFinished() private view { TournamentArguments memory args = _tournamentArgs(); require(!_isFinished(args), TournamentIsFinished()); @@ -1329,9 +1373,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/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/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/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 + ) + ); + } +} diff --git a/prt/contracts/test/TournamentObserver.t.sol b/prt/contracts/test/TournamentObserver.t.sol index ed340449..626881c1 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, @@ -266,7 +270,9 @@ contract TournamentObserverTest is Test { hasCandidate: true, candidate: candidate, finalState: Machine.ZERO_STATE, - parentCommitment: Tree.ZERO_NODE + parentCommitment: Tree.ZERO_NODE, + finishedAt: Time.ZERO_INSTANT, + winnerExpiresAt: Time.ZERO_INSTANT }) ); } @@ -284,7 +290,9 @@ 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, + winnerExpiresAt: Time.ZERO_INSTANT }) ); } @@ -303,7 +311,9 @@ contract TournamentObserverTest is Test { hasCandidate: true, candidate: candidate, finalState: Machine.ZERO_STATE, - parentCommitment: Tree.ZERO_NODE + parentCommitment: Tree.ZERO_NODE, + finishedAt: Time.ZERO_INSTANT, + winnerExpiresAt: Time.ZERO_INSTANT }) ); } @@ -323,7 +333,9 @@ contract TournamentObserverTest is Test { hasCandidate: true, candidate: first, finalState: Machine.ZERO_STATE, - parentCommitment: Tree.ZERO_NODE + parentCommitment: Tree.ZERO_NODE, + finishedAt: Time.ZERO_INSTANT, + winnerExpiresAt: Time.ZERO_INSTANT }) ); @@ -336,7 +348,9 @@ 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, + winnerExpiresAt: Time.ZERO_INSTANT }) ); } @@ -357,7 +371,9 @@ contract TournamentObserverTest is Test { hasCandidate: true, candidate: candidate, finalState: finalState, - parentCommitment: Tree.ZERO_NODE + parentCommitment: Tree.ZERO_NODE, + finishedAt: _instant(120), + winnerExpiresAt: Time.ZERO_INSTANT }) ); @@ -370,7 +386,9 @@ 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), + winnerExpiresAt: Time.ZERO_INSTANT }) ); } @@ -394,9 +412,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 { @@ -412,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({ @@ -423,7 +450,9 @@ contract TournamentObserverTest is Test { hasCandidate: true, candidate: candidate, finalState: Machine.ZERO_STATE, - parentCommitment: Tree.ZERO_NODE + parentCommitment: Tree.ZERO_NODE, + finishedAt: _instant(125), + winnerExpiresAt: Time.ZERO_INSTANT }) ); } @@ -444,7 +473,9 @@ 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), + winnerExpiresAt: Time.ZERO_INSTANT }) ); } @@ -862,11 +893,51 @@ 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 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, + Machine.Hash finalState, Tree.Node parentCommitment ) internal view { _assertStanding( @@ -876,8 +947,10 @@ contract TournamentObserverTest is Test { acceptsJoins: false, hasCandidate: true, candidate: candidate, - finalState: Machine.ZERO_STATE, - parentCommitment: parentCommitment + finalState: finalState, + parentCommitment: parentCommitment, + finishedAt: _instant(125), + winnerExpiresAt: _instant(135) }) ); } 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; diff --git a/prt/contracts/test/fixtures/SmallSingleLevelTournament.t.sol b/prt/contracts/test/fixtures/SmallSingleLevelTournament.t.sol index 08ac6ba9..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); @@ -216,20 +237,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)); 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