diff --git a/packages/contracts-bedrock/interfaces/dispute/zk/IKailuaGame.sol b/packages/contracts-bedrock/interfaces/dispute/zk/IKailuaGame.sol new file mode 100644 index 00000000000..251c74e546e --- /dev/null +++ b/packages/contracts-bedrock/interfaces/dispute/zk/IKailuaGame.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import { IKailuaTournament } from "interfaces/dispute/zk/IKailuaTournament.sol"; +import { Claim, Duration, Hash, GameStatus, GameType, Timestamp } from "src/dispute/lib/Types.sol"; + +interface IKailuaGame is IKailuaTournament { + function GENESIS_TIME_STAMP() external view returns (uint64); + function L2_BLOCK_TIME() external view returns (uint64); + function MAX_CLOCK_DURATION() external view returns (Duration); + function duplicationCounter() external pure returns (uint64 duplicationCounter_); + function parentGameIndex() external pure returns (uint64 parentGameIndex_); +} diff --git a/packages/contracts-bedrock/interfaces/dispute/zk/IKailuaTournament.sol b/packages/contracts-bedrock/interfaces/dispute/zk/IKailuaTournament.sol new file mode 100644 index 00000000000..ab4c1960202 --- /dev/null +++ b/packages/contracts-bedrock/interfaces/dispute/zk/IKailuaTournament.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import { IOptimismPortal2 } from "interfaces/L1/IOptimismPortal2.sol"; +import { Claim, Duration, GameStatus, GameType, Hash, Timestamp } from "src/dispute/lib/Types.sol"; +import { IDisputeGame } from "interfaces/dispute/IDisputeGame.sol"; +import { IDisputeGameFactory } from "interfaces/dispute/IDisputeGameFactory.sol"; +import { IKailuaVerifier } from "interfaces/dispute/zk/IKailuaVerifier.sol"; +import { ISemver } from "interfaces/universal/ISemver.sol"; +import { IAnchorStateRegistry } from "interfaces/dispute/IAnchorStateRegistry.sol"; + +interface IKailuaTournament is IDisputeGame, ISemver { + /// @notice Denotes the proven status of the game + enum ProofStatus { + NONE, + FAULT, + VALIDITY + } + + /// @notice Emitted when a proof is submitted. + event Proven(bytes32 indexed signature, ProofStatus indexed status); + + function DISPUTE_GAME_FACTORY() external view returns (IDisputeGameFactory); + function GAME_TYPE() external view returns (GameType); + function KAILUA_TREASURY() external view returns (IKailuaTournament); + function KAILUA_VERIFIER() external view returns (IKailuaVerifier); + function OPTIMISM_PORTAL() external view returns (IOptimismPortal2); + function OUTPUT_BLOCK_SPAN() external view returns (uint64); + function PROPOSAL_BLOBS() external view returns (uint64); + function PROPOSAL_OUTPUT_COUNT() external view returns (uint64); + function anchorStateRegistry() external view returns (IAnchorStateRegistry registry_); + function appendChild() external; + function blobsHash() external view returns (bytes32 blobsHash_); + function childCount() external view returns (uint256 count_); + function children(uint256) external view returns (IKailuaTournament); + function contenderDuplicates(uint256) external view returns (uint64); + function contenderIndex() external view returns (uint64); + function gameIndex() external view returns (uint256); + function getChallengerDuration(uint64 asOfTimestamp) external view returns (Duration duration_); + function initialize() external payable; + function isViableSignature(bytes32 childSignature) external view returns (bool isViableSignature_); + function minCreationTime() external view returns (Timestamp minCreationTime_); + function opponentIndex() external view returns (uint64); + function parentGame() external view returns (IKailuaTournament parentGame_); + function proofStatus(bytes32) external view returns (ProofStatus); + function proposalBlobHashes(uint256) external view returns (Hash); + function proposer() external view returns (address proposer_); + function proveOutputFault( + address[2] memory prHs, + uint64[2] memory co, + bytes memory encodedSeal, + bytes32[2] memory ac, + uint256 proposedOutputFe, + bytes[][2] memory kzgCommitmentsProofs + ) + external; + function proveTrailFault( + address payoutRecipient, + uint64[2] memory co, + uint256 proposedOutputFe, + bytes memory blobCommitment, + bytes memory kzgProof + ) + external; + function proveValidity( + address payoutRecipient, + address l1HeadSource, + uint64 childIndex, + bytes memory encodedSeal + ) + external; + function provenAt(bytes32) external view returns (Timestamp); + function prover(bytes32) external view returns (address); + function pruneChildren(uint256 stepLimit) external returns (IKailuaTournament); + function signature() external view returns (bytes32 signature_); + function validChildSignature() external view returns (bytes32); + function verifyIntermediateOutput( + uint64 outputNumber, + uint256 outputFe, + bytes memory blobCommitment, + bytes memory kzgProof + ) + external + returns (bool success); + function version() external view returns (string memory); +} diff --git a/packages/contracts-bedrock/interfaces/dispute/zk/IKailuaTreasury.sol b/packages/contracts-bedrock/interfaces/dispute/zk/IKailuaTreasury.sol new file mode 100644 index 00000000000..0e766bcf6de --- /dev/null +++ b/packages/contracts-bedrock/interfaces/dispute/zk/IKailuaTreasury.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import { IKailuaTournament } from "interfaces/dispute/zk/IKailuaTournament.sol"; +import { Claim, Duration, GameStatus, GameType, Timestamp } from "src/dispute/lib/Types.sol"; + +interface IKailuaTreasury is IKailuaTournament { + /// @notice Emitted when the participation bond is updated + /// @param amount The new required bond amount + event BondUpdated(uint256 amount); + + function ELIMINATION_SPLIT_DENOM() external view returns (uint256); + function ELIMINATION_SPLIT_PROVER_NUM() external view returns (uint256); + function ELIMINATION_SPLIT_WINNER_NUM() external view returns (uint256); + function L2_BLOCK_NUMBER() external view returns (uint64); + function ROOT_CLAIM() external view returns (Claim); + function assignVanguard(address _vanguard, Duration _vanguardAdvantage) external; + function claimEliminationRewards() external; + function claimProposerBond() external; + function eliminate(address _child, address prover) external; + function eliminationRewards(address) external view returns (uint256); + function eliminationRound(address) external view returns (uint256); + function isProposing() external view returns (bool); + function lastProposal(address) external view returns (IKailuaTournament); + function lastResolved() external view returns (address); + function paidBonds(address) external view returns (uint256); + function participationBond() external view returns (uint256); + function propose(Claim _rootClaim, bytes memory _extraData) external payable returns (IKailuaTournament tournament); + function proposerOf(address) external view returns (address); + function setParticipationBond(uint256 amount) external; + function treasuryAddress() external pure returns (IKailuaTreasury treasuryAddress_); + function updateLastResolved() external; + function vanguard() external view returns (address); + function vanguardAdvantage() external view returns (Duration); +} diff --git a/packages/contracts-bedrock/interfaces/dispute/zk/IKailuaVerifier.sol b/packages/contracts-bedrock/interfaces/dispute/zk/IKailuaVerifier.sol new file mode 100644 index 00000000000..b8eead4096e --- /dev/null +++ b/packages/contracts-bedrock/interfaces/dispute/zk/IKailuaVerifier.sol @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import { IKailuaTournament } from "interfaces/dispute/zk/IKailuaTournament.sol"; +import { + AlreadyEliminated, + BadTarget, + BondTransferFailed, + ClockNotExpired, + IncorrectBondAmount, + NoCreditToClaim, + NotProven, + ProvenFaulty +} from "src/dispute/lib/Errors.sol"; +import { Duration } from "src/dispute/lib/Types.sol"; +import { IRiscZeroVerifier } from "interfaces/dispute/zk/IRiscZeroVerifier.sol"; +import { ISemver } from "interfaces/universal/ISemver.sol"; + +interface IKailuaVerifier is ISemver { + function FPVM_IMAGE_ID() external view returns (bytes32); + function PERMIT_DURATION() external view returns (Duration); + function RISC_ZERO_VERIFIER() external view returns (IRiscZeroVerifier); + function ROLLUP_CONFIG_HASH() external view returns (bytes32); + function acquireFaultProofPermit( + IKailuaTournament proposalParent, + bytes32 proposalSignature, + uint64 numExpiredPermits, + address payoutRecipient + ) + external + payable + returns (uint256 totalPermitsIssued_); + function countExpiredPermits( + bytes32 proposalKey, + uint64 numExpiredPermits, + uint64 timestamp + ) + external + view + returns (uint64, uint256, uint64); + function faultProofPermitBeneficiary( + IKailuaTournament proposalParent, + bytes32 proposalSignature + ) + external + view + returns (address); + function faultProofPermitBond(address treasury) external view returns (uint256 bond); + function faultProofPermitKey( + IKailuaTournament proposalParent, + bytes32 proposalSignature + ) + external + pure + returns (bytes32); + function faultProofPermitProvenAt( + IKailuaTournament proposalParent, + bytes32 proposalSignature + ) + external + view + returns (uint64); + function faultProofPermits( + bytes32, + uint256 + ) + external + view + returns (uint256 aggregateCollateral, address recipient, uint64 timestamp, bool released); + function releaseFaultProofPermit( + IKailuaTournament proposalParent, + bytes32 proposalSignature, + uint64 numExpiredPermits, + uint64 permitIndex + ) + external; + function verify( + address payoutRecipient, + bytes32 preconditionHash, + bytes32 l1Head, + bytes32 agreedL2OutputRoot, + bytes32 claimedL2OutputRoot, + uint64 claimedL2BlockNumber, + bytes memory encodedSeal + ) + external + view; + function version() external view returns (string memory); +} diff --git a/packages/contracts-bedrock/interfaces/dispute/zk/IRiscZeroVerifier.sol b/packages/contracts-bedrock/interfaces/dispute/zk/IRiscZeroVerifier.sol new file mode 100644 index 00000000000..11a0bb0d768 --- /dev/null +++ b/packages/contracts-bedrock/interfaces/dispute/zk/IRiscZeroVerifier.sol @@ -0,0 +1,50 @@ +// Copyright 2025 RISC Zero, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +pragma solidity ^0.8.9; + +/// @notice A receipt attesting to a claim using the RISC Zero proof system. +/// @dev A receipt contains two parts: a seal and a claim. +/// +/// The seal is a zero-knowledge proof attesting to knowledge of a witness for the claim. The claim +/// is a set of public outputs, and for zkVM execution is the hash of a `ReceiptClaim` struct. +/// +/// IMPORTANT: The `claimDigest` field must be a hash computed by the caller for verification to +/// have meaningful guarantees. Treat this similar to verifying an ECDSA signature, in that hashing +/// is a key operation in verification. The most common way to calculate this hash is to use the +/// `ReceiptClaimLib.ok(imageId, journalDigest).digest()` for successful executions. +struct Receipt { + bytes seal; + bytes32 claimDigest; +} + +/// @notice Verifier interface for RISC Zero receipts of execution. +interface IRiscZeroVerifier { + /// @notice Verify that the given seal is a valid RISC Zero proof of execution with the + /// given image ID and journal digest. Reverts on failure. + /// @dev This method additionally ensures that the input hash is all-zeros (i.e. no + /// committed input), the exit code is (Halted, 0), and there are no assumptions (i.e. the + /// receipt is unconditional). + /// @param seal The encoded cryptographic proof (i.e. SNARK). + /// @param imageId The identifier for the guest program. + /// @param journalDigest The SHA-256 digest of the journal bytes. + function verify(bytes calldata seal, bytes32 imageId, bytes32 journalDigest) external view; + + /// @notice Verify that the given receipt is a valid RISC Zero receipt, ensuring the `seal` is + /// valid a cryptographic proof of the execution with the given `claim`. Reverts on failure. + /// @param receipt The receipt to be verified. + function verifyIntegrity(Receipt calldata receipt) external view; +} diff --git a/packages/contracts-bedrock/src/dispute/lib/Errors.sol b/packages/contracts-bedrock/src/dispute/lib/Errors.sol index 2f5ce0dd777..430c78f7397 100644 --- a/packages/contracts-bedrock/src/dispute/lib/Errors.sol +++ b/packages/contracts-bedrock/src/dispute/lib/Errors.sol @@ -180,3 +180,55 @@ error InvalidProposalStatus(); /// @notice Thrown when the game is initialized by an incorrect factory. error IncorrectDisputeGameFactory(); + +//////////////////////////////////////////////////////////////// +// `Kailua` Errors // +//////////////////////////////////////////////////////////////// + +/// @notice Thrown when a blacklisted address attempts to interact with the game. +error Blacklisted(address source, address expected); + +/// @notice Thrown when a child from an unknown source appends itself to a tournament +error UnknownGame(); + +/// @notice Thrown when eliminating an already removed child +error AlreadyEliminated(); + +/// @notice Thrown when a proof is submitted for an already proven game +error AlreadyProven(); + +/// @notice Thrown when a resolution is attempted for an unproven claim +error NotProven(); + +/// @notice Thrown when resolving a faulty proposal +error ProvenFaulty(); + +/// @notice Thrown when pruning is attempted with no children +error NotProposed(); + +/// @notice Thrown when proving is attempted with two agreeing outputs +error NoConflict(); + +/// @notice Thrown when proposing before the minimum creation time +error ProposalGapRemaining(uint256 currentTime, uint256 minCreationTime); + +/// @notice Thrown when a blob hash is missing +error BlobHashMissing(uint256 index, uint256 count); + +/// @notice Occurs when the duplication counter is wrong +error InvalidDuplicationCounter(); + +/// @notice Occurs when the anchored game block number is different +/// @param anchored The L2 block number of the anchored game +/// @param initialized This game's l2 block number +error BlockNumberMismatch(uint256 anchored, uint256 initialized); + +/// @notice Occurs when a proposer attempts to extend the chain before the vanguard +/// @param parentGame The address of the parent proposal being extended +error VanguardError(address parentGame); + +/// @notice Thrown when a non-factory owner calls an owner-only function. +error NotFactoryOwner(); + +/// @notice Error for when a deposit or withdrawal is to a bad target. +error BadTarget(); diff --git a/packages/contracts-bedrock/src/dispute/lib/KailuaLib.sol b/packages/contracts-bedrock/src/dispute/lib/KailuaLib.sol new file mode 100644 index 00000000000..4171b397cc7 --- /dev/null +++ b/packages/contracts-bedrock/src/dispute/lib/KailuaLib.sol @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import { BondTransferFailed } from "src/dispute/lib/Errors.sol"; + +library KailuaLib { + /// @notice Transfers ETH from the contract's balance to the recipient + function pay(uint256 amount, address recipient) internal { + (bool success,) = recipient.call{value: amount}(hex""); + if (!success) revert BondTransferFailed(); + } + + /// @notice The KZG commitment version + bytes32 internal constant KZG_COMMITMENT_VERSION = + bytes32(0x0100000000000000000000000000000000000000000000000000000000000000); + + /// @notice The modular exponentiation precompile + address internal constant MOD_EXP = address(0x05); + + /// @notice The point evaluation precompile + address internal constant KZG = address(0x0a); + + /// @notice The expected result from the point evaluation precompile + bytes32 internal constant KZG_RESULT = keccak256(abi.encodePacked(FIELD_ELEMENTS_PER_BLOB, BLS_MODULUS)); + + /// @notice Scalar field modulus of BLS12-381 + uint256 internal constant BLS_MODULUS = + 52435875175126190479447740508185965837690552500527637822603658699938581184513; + + /// @notice The base root of unity for indexing blob field elements + uint256 internal constant ROOT_OF_UNITY = + 39033254847818212395286706435128746857159659164139250548781411570340225835782; + + /// @notice The po2 for the number of field elements in a single blob + uint256 internal constant FIELD_ELEMENTS_PER_BLOB_PO2 = 12; + + /// @notice The number of field elements in a single blob + uint256 internal constant FIELD_ELEMENTS_PER_BLOB = uint64(1 << FIELD_ELEMENTS_PER_BLOB_PO2); + + /// @notice The index of the blob containing the FE at the provided offset + function blobIndex(uint256 outputOffset) internal pure returns (uint256 index) { + index = outputOffset / FIELD_ELEMENTS_PER_BLOB; + } + + /// @notice The index of the FE at the provided offset in the blob that contains it + function fieldElementIndex(uint256 outputOffset) internal pure returns (uint32 position) { + position = uint32(outputOffset % FIELD_ELEMENTS_PER_BLOB); + } + + /// @notice The versioned KZG hash of the provided blob commitment + function versionedKZGHash(bytes calldata blobCommitment) internal pure returns (bytes32 hash) { + require(blobCommitment.length == 48); + hash = ((sha256(blobCommitment) << 8) >> 8) | KZG_COMMITMENT_VERSION; + } + + /// @notice The mapped FE corresponding to the input hash + function hashToFe(bytes32 hash) internal pure returns (uint256 fe) { + fe = uint256(hash) % BLS_MODULUS; + } + + /// @notice Returns true iff the proof shows that the FE is part of the blob at the provided position + function verifyKZGBlobProof( + bytes32 versionedBlobHash, + uint32 index, + uint256 value, + bytes calldata blobCommitment, + bytes calldata proof + ) internal returns (bool success) { + uint256 rootOfUnity = modExp(reverseBits(index)); + // Byte range Name Description + // [0:32] versioned_hash Reference to a blob in the execution layer. + // [32:64] x x-coordinate at which the blob is being evaluated. + // [64:96] y y-coordinate at which the blob is being evaluated. + // [96:144] commitment Commitment to the blob being evaluated. + // [144:192] proof Proof associated with the commitment. + bytes memory kzgCallData = abi.encodePacked(versionedBlobHash, rootOfUnity, value, blobCommitment, proof); + // The precompile will reject non-canonical field elements (i.e. value must be less than BLS_MODULUS). + (bool _success, bytes memory kzgResult) = KZG.call(kzgCallData); + // Validate the precompile response + require(keccak256(kzgResult) == KZG_RESULT); + // Return the result + success = _success; + } + + /// @notice Calls the modular exponentiation precompile with a fixed base and modulus + function modExp(uint256 exponent) internal returns (uint256 result) { + bytes memory modExpData = + abi.encodePacked(uint256(32), uint256(32), uint256(32), ROOT_OF_UNITY, exponent, BLS_MODULUS); + (bool success, bytes memory mexpResult) = MOD_EXP.call(modExpData); + require(success); + result = uint256(bytes32(mexpResult)); + } + + /// @notice Reverses the bits of the input index + function reverseBits(uint32 index) internal pure returns (uint256 result) { + for (uint256 i = 0; i < FIELD_ELEMENTS_PER_BLOB_PO2; i++) { + result <<= 1; + result |= ((1 << i) & index) >> i; + } + } +} diff --git a/packages/contracts-bedrock/src/dispute/lib/Types.sol b/packages/contracts-bedrock/src/dispute/lib/Types.sol index eb9438e2b5b..25f9ca817fd 100644 --- a/packages/contracts-bedrock/src/dispute/lib/Types.sol +++ b/packages/contracts-bedrock/src/dispute/lib/Types.sol @@ -142,3 +142,8 @@ struct AggregationOutputs { bytes32 rangeVkeyCommitment; address proverAddress; } + +//////////////////////////////////////////////////////////////// +// `Kailua` Types // +//////////////////////////////////////////////////////////////// + diff --git a/packages/contracts-bedrock/src/dispute/zk/KailuaGame.sol b/packages/contracts-bedrock/src/dispute/zk/KailuaGame.sol new file mode 100644 index 00000000000..ad59ef092fa --- /dev/null +++ b/packages/contracts-bedrock/src/dispute/zk/KailuaGame.sol @@ -0,0 +1,244 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import { IInitializable } from "interfaces/dispute/IInitializable.sol"; +import { IDisputeGame } from "interfaces/dispute/IDisputeGame.sol"; +import { IKailuaTreasury } from "interfaces/dispute/zk/IKailuaTreasury.sol"; +import { KailuaLib } from "src/dispute/lib/KailuaLib.sol"; +import { KailuaTournament } from "src/dispute/zk/KailuaTournament.sol"; +import { KailuaTreasury } from "src/dispute/zk/KailuaTreasury.sol"; +import { Duration, GameStatus, Hash, Timestamp } from "src/dispute/lib/Types.sol"; +import { + GameNotInProgress, + BadExtraData, + BlockNumberMismatch, + NotProven, + ClockNotExpired, + ProvenFaulty, + OutOfOrderResolution, + ProposalGapRemaining, + InvalidParent, + BlobHashMissing, + InvalidDuplicationCounter +} from "src/dispute/lib/Errors.sol"; + +contract KailuaGame is KailuaTournament { + // ------------------------------ + // Immutable configuration + // ------------------------------ + + /// @notice The duration after which the proposal is accepted + Duration public immutable MAX_CLOCK_DURATION; + + /// @notice The timestamp of the genesis l2 block + uint64 public immutable GENESIS_TIME_STAMP; + + /// @notice The time between l2 blocks + uint64 public immutable L2_BLOCK_TIME; + + constructor( + KailuaTreasury _kailuaTreasury, + uint64 _genesisTimeStamp, + uint64 _l2BlockTime, + Duration _maxClockDuration + ) + KailuaTournament( + IKailuaTreasury(address(_kailuaTreasury)), + _kailuaTreasury.KAILUA_VERIFIER(), + _kailuaTreasury.PROPOSAL_OUTPUT_COUNT(), + _kailuaTreasury.OUTPUT_BLOCK_SPAN(), + _kailuaTreasury.GAME_TYPE(), + _kailuaTreasury.OPTIMISM_PORTAL() + ) + { + GENESIS_TIME_STAMP = _genesisTimeStamp; + L2_BLOCK_TIME = _l2BlockTime; + MAX_CLOCK_DURATION = _maxClockDuration; + } + + // ------------------------------ + // IInitializable implementation + // ------------------------------ + + /// @inheritdoc IInitializable + function initialize() external payable override { + super.initializeInternal(); + + // Revert if the calldata size is not the expected length. + // + // This is to prevent adding extra or omitting bytes from to `extraData` that result in a different game UUID + // in the factory, but are not used by the game, which would allow for multiple dispute games for the same + // output proposal to be created. + // + // Expected length: 0x72 + // - 0x04 selector 0x00 0x04 + // - 0x14 creator address 0x04 0x18 + // - 0x20 root claim 0x18 0x38 + // - 0x20 l1 head 0x38 0x58 + // - 0x18 extraData: 0x58 0x70 + // + 0x08 l2SequenceNumber 0x58 0x60 + // + 0x08 parentGameIndex 0x60 0x68 + // + 0x08 duplicationCounter 0x68 0x70 + // - 0x02 CWIA bytes 0x70 0x72 + if (msg.data.length != 0x72) { + revert BadExtraData(); + } + + // Only allow monotonic duplication counter + uint64 duplicationCounter_ = duplicationCounter(); + if (duplicationCounter_ > 0) { + bytes memory extra = abi.encodePacked(msg.data[0x58:0x68], duplicationCounter_ - 1); + (IDisputeGame previousDuplicate,) = DISPUTE_GAME_FACTORY.games(GAME_TYPE, rootClaim(), extra); + if (address(previousDuplicate) == address(0x0)) { + revert InvalidDuplicationCounter(); + } + } + + // Do not initialize a game that does not cover the required number of l2 blocks + KailuaTournament parentGame_ = parentGame(); + if (l2SequenceNumber() != parentGame_.l2SequenceNumber() + PROPOSAL_OUTPUT_COUNT * OUTPUT_BLOCK_SPAN) { + revert BlockNumberMismatch(parentGame_.l2SequenceNumber(), l2SequenceNumber()); + } + + // Store the intermediate output blob hashes + for (uint256 i = 0; i < PROPOSAL_BLOBS; i++) { + bytes32 hash = blobhash(i); + if (hash == 0x0) { + revert BlobHashMissing(i, PROPOSAL_BLOBS); + } + proposalBlobHashes.push(Hash.wrap(hash)); + } + + // Verify that parent game is known by the treasury + if (KAILUA_TREASURY.proposerOf(address(parentGame_)) == address(0x0)) { + revert InvalidParent(); + } + + // If a proof was submitted, do not allow bad proposals to be created + if (!parentGame_.isViableSignature(signature())) { + revert ProvenFaulty(); + } + + // Register this new game in the parent game's contract + parentGame_.appendChild(); + + // Do not permit proposals if l2 block time is ahead of the l1 block time + if (block.timestamp < minCreationTime().raw()) { + revert ProposalGapRemaining(block.timestamp, minCreationTime().raw()); + } + } + + // ------------------------------ + // IDisputeGame implementation + // ------------------------------ + + /// @inheritdoc IDisputeGame + function extraData() external pure returns (bytes memory extraData_) { + // The extra data starts at the second word within the cwia calldata and + // is 24 bytes long. + extraData_ = _getArgBytes(0x54, 0x18); + } + + /// @inheritdoc IDisputeGame + function resolve() external returns (GameStatus status_) { + // INVARIANT: Resolution cannot occur unless the game is currently in progress. + if (status != GameStatus.IN_PROGRESS) { + revert GameNotInProgress(); + } + + // INVARIANT: Optimistic resolution cannot occur unless parent game is resolved. + KailuaTournament parentGame_ = parentGame(); + if (parentGame_.status() != GameStatus.DEFENDER_WINS) { + revert OutOfOrderResolution(); + } + + // INVARIANT: Cannot resolve unless proven valid or the clock has expired + if (parentGame_.validChildSignature() != 0) { + if (signature() != parentGame_.validChildSignature()) { + revert ProvenFaulty(); + } + } else if (getChallengerDuration(uint64(block.timestamp)).raw() > 0) { + revert ClockNotExpired(); + } + + // INVARIANT: Can only resolve the last remaining child + if (parentGame_.pruneChildren(parentGame_.childCount() * 2) != this) { + revert NotProven(); + } + + // Mark resolution timestamp + resolvedAt = Timestamp.wrap(uint64(block.timestamp)); + + // Update the status and emit the resolved event, note that we're performing a storage update here. + emit Resolved(status = status_ = GameStatus.DEFENDER_WINS); + + // Update lastResolved + KAILUA_TREASURY.updateLastResolved(); + } + + // ------------------------------ + // Immutable instance data + // ------------------------------ + + /// @notice The index of the parent game in the `DisputeGameFactory`. + function parentGameIndex() public pure returns (uint64 parentGameIndex_) { + parentGameIndex_ = _getArgUint64(0x5C); + } + + /// @notice The number of duplicate proposals preceeding this one. + function duplicationCounter() public pure returns (uint64 duplicationCounter_) { + duplicationCounter_ = _getArgUint64(0x64); + } + + /// @inheritdoc KailuaTournament + function parentGame() public view override returns (KailuaTournament parentGame_) { + (,, IDisputeGame parentDisputeGame) = DISPUTE_GAME_FACTORY.gameAtIndex(parentGameIndex()); + + // Interpret parent game as another instance of this game type + parentGame_ = KailuaTournament(address(parentDisputeGame)); + } + + // ------------------------------ + // Fault proving + // ------------------------------ + + /// @inheritdoc KailuaTournament + function verifyIntermediateOutput( + uint64 outputNumber, + uint256 outputFe, + bytes calldata blobCommitment, + bytes calldata kzgProof + ) + external + override + returns (bool success) + { + uint256 blobIndex = KailuaLib.blobIndex(outputNumber); + uint32 blobPosition = KailuaLib.fieldElementIndex(outputNumber); + bytes32 proposalBlobHash = KailuaLib.versionedKZGHash(blobCommitment); + // Note: Only known blobs can be used to validate an intermediate output + if (proposalBlobHash != proposalBlobHashes[blobIndex].raw()) { + success = false; + } else { + success = KailuaLib.verifyKZGBlobProof(proposalBlobHash, blobPosition, outputFe, blobCommitment, kzgProof); + } + } + + /// @inheritdoc KailuaTournament + function getChallengerDuration(uint64 asOfTimestamp) public view override returns (Duration duration_) { + // INVARIANT: The game must be in progress to query the remaining time to respond to a given claim. + if (status != GameStatus.IN_PROGRESS) { + revert GameNotInProgress(); + } + + // Compute the duration elapsed of the potential challenger's clock. + uint64 elapsed = asOfTimestamp - createdAt.raw(); + uint64 maximum = MAX_CLOCK_DURATION.raw(); + duration_ = elapsed >= maximum ? Duration.wrap(0) : Duration.wrap(maximum - elapsed); + } + + /// @inheritdoc KailuaTournament + function minCreationTime() public view override returns (Timestamp minCreationTime_) { + minCreationTime_ = Timestamp.wrap(uint64(GENESIS_TIME_STAMP + l2SequenceNumber() * L2_BLOCK_TIME)); + } +} diff --git a/packages/contracts-bedrock/src/dispute/zk/KailuaTournament.sol b/packages/contracts-bedrock/src/dispute/zk/KailuaTournament.sol new file mode 100644 index 00000000000..758915e1687 --- /dev/null +++ b/packages/contracts-bedrock/src/dispute/zk/KailuaTournament.sol @@ -0,0 +1,696 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import { IAnchorStateRegistry } from "interfaces/dispute/IAnchorStateRegistry.sol"; +import { IDisputeGame } from "interfaces/dispute/IDisputeGame.sol"; +import { IDisputeGameFactory } from "interfaces/dispute/IDisputeGameFactory.sol"; +import { IKailuaTournament } from "interfaces/dispute/zk/IKailuaTournament.sol"; +import { IKailuaTreasury } from "interfaces/dispute/zk/IKailuaTreasury.sol"; +import { IKailuaVerifier } from "interfaces/dispute/zk/IKailuaVerifier.sol"; +import { IOptimismPortal2 } from "interfaces/L1/IOptimismPortal2.sol"; +import { ISemver } from "interfaces/universal/ISemver.sol"; +import { Clone } from "@solady/utils/Clone.sol"; +import { Claim, Duration, GameStatus, GameType, Hash, Timestamp } from "src/dispute/lib/Types.sol"; +import { KailuaLib } from "src/dispute/lib/KailuaLib.sol"; +import { + AlreadyInitialized, + Blacklisted, + InvalidParent, + ClaimAlreadyResolved, + NotProposed, + GameNotInProgress, + InvalidDisputedClaimIndex, + NoConflict, + UnknownGame, + AlreadyProven, + GameNotResolved, + NotProven +} from "src/dispute/lib/Errors.sol"; + +abstract contract KailuaTournament is Clone, IDisputeGame, ISemver { + /// @notice Semantic version. + /// @custom:semver 1.2.0 + string public constant version = "1.2.0"; + + /// @notice Denotes the proven status of the game + enum ProofStatus { + NONE, + FAULT, + VALIDITY + } + + /// @notice Emitted when a proof is submitted. + event Proven(bytes32 indexed signature, ProofStatus indexed status); + + // ------------------------------ + // Immutable configuration + // ------------------------------ + + /// @notice The Kailua Treasury Implementation contract address + IKailuaTreasury public immutable KAILUA_TREASURY; + + /// @notice The Kailua Verifier contract + IKailuaVerifier public immutable KAILUA_VERIFIER; + + /// @notice The number of outputs a proposal must publish + uint64 public immutable PROPOSAL_OUTPUT_COUNT; + + /// @notice The number of blocks each output must cover + uint64 public immutable OUTPUT_BLOCK_SPAN; + + /// @notice The number of blobs a claim must provide + uint64 public immutable PROPOSAL_BLOBS; + + /// @notice The game type ID + GameType public immutable GAME_TYPE; + + /// @notice The OptimismPortal2 instance + IOptimismPortal2 public immutable OPTIMISM_PORTAL; + + /// @notice The DisputeGameFactory instance + IDisputeGameFactory public immutable DISPUTE_GAME_FACTORY; + + constructor( + IKailuaTreasury _kailuaTreasury, + IKailuaVerifier _kailuaVerifier, + uint64 _proposalOutputCount, + uint64 _outputBlockSpan, + GameType _gameType, + IOptimismPortal2 _optimismPortal + ) { + KAILUA_TREASURY = _kailuaTreasury; + KAILUA_VERIFIER = _kailuaVerifier; + PROPOSAL_OUTPUT_COUNT = _proposalOutputCount; + OUTPUT_BLOCK_SPAN = _outputBlockSpan; + // discard published root commitment in calldata + _proposalOutputCount--; + PROPOSAL_BLOBS = (_proposalOutputCount / uint64(KailuaLib.FIELD_ELEMENTS_PER_BLOB)) + + ((_proposalOutputCount % uint64(KailuaLib.FIELD_ELEMENTS_PER_BLOB)) == 0 ? 0 : 1); + GAME_TYPE = _gameType; + OPTIMISM_PORTAL = _optimismPortal; + DISPUTE_GAME_FACTORY = OPTIMISM_PORTAL.disputeGameFactory(); + } + + function initializeInternal() internal { + // INVARIANT: The game must not have already been initialized. + if (createdAt.raw() > 0) revert AlreadyInitialized(); + + // Allow only the treasury to create new games + if (gameCreator() != address(KAILUA_TREASURY)) { + revert Blacklisted(gameCreator(), address(KAILUA_TREASURY)); + } + + // Set the game's starting timestamp + createdAt = Timestamp.wrap(uint64(block.timestamp)); + + // Set the game's index in the factory + gameIndex = DISPUTE_GAME_FACTORY.gameCount(); + + // Read respected status + wasRespectedGameTypeWhenCreated = OPTIMISM_PORTAL.respectedGameType().raw() == GAME_TYPE.raw(); + } + + // ------------------------------ + // Game State + // ------------------------------ + + /// @notice The blob hashes used to create the game + Hash[] public proposalBlobHashes; + + /// @notice The game's index in the factory + uint256 public gameIndex; + + /// @notice The address of the prover of a proposal signature + mapping(bytes32 => address) public prover; + + /// @notice The timestamp of when the first proof for a proposal signature was made + mapping(bytes32 => Timestamp) public provenAt; + + /// @notice The current proof status of a proposal signature + mapping(bytes32 => ProofStatus) public proofStatus; + + /// @notice The proposals extending this proposal + KailuaTournament[] public children; + + /// @notice The first surviving contender + uint64 public contenderIndex; + + /// @notice Duplicates of the last surviving contender proposal + uint64[] public contenderDuplicates; + + /// @notice The next unprocessed opponent + uint64 public opponentIndex; + + /// @notice The signature of the child accepted through a validity proof + bytes32 public validChildSignature; + + /// @notice Returns the hash of the output claim and all blob hashes associated with this proposal + function signature() public view returns (bytes32 signature_) { + // note: the absence of the l1Head in the signature implies that + // proofs will eventually demonstrate derivation + signature_ = sha256(abi.encodePacked(rootClaim().raw(), proposalBlobHashes)); + } + + /// @notice Returns whether a child can be considered valid + function isViableSignature(bytes32 childSignature) public view returns (bool isViableSignature_) { + if (validChildSignature != 0) { + isViableSignature_ = childSignature == validChildSignature; + } else { + isViableSignature_ = proofStatus[childSignature] != ProofStatus.FAULT; + } + } + + /// @notice Returns the address of the prover of the specified signature or the prover of the valid signature + function getPayoutRecipient(bytes32 childSignature) internal view returns (address payoutRecipient) { + // The successful exclusive permit owner receives the payout. + payoutRecipient = KAILUA_VERIFIER.faultProofPermitBeneficiary(IKailuaTournament(address(this)), childSignature); + // If none exists, then the successful fault prover is the recipient. + if (payoutRecipient == address(0x0)) { + payoutRecipient = prover[childSignature]; + } + // Otherwise, the successful validity prover receives the payout. + if (payoutRecipient == address(0x0)) { + payoutRecipient = prover[validChildSignature]; + } + // Otherwise the child signature is viable and there is no recipient. + } + + /// @notice Returns true iff the child proposal was eliminated + function isChildEliminated(KailuaTournament child) internal view returns (bool) { + address _proposer = KAILUA_TREASURY.proposerOf(address(child)); + uint256 eliminationRound = KAILUA_TREASURY.eliminationRound(_proposer); + if (eliminationRound == 0 || eliminationRound > child.gameIndex()) { + // This proposer has not been eliminated as of their proposal at gameIndex + return false; + } + return true; + } + + /// @notice Returns the number of children + function childCount() external view returns (uint256 count_) { + count_ = children.length; + } + + /// @notice Registers a new proposal that extends this one + function appendChild() external { + // INVARIANT: The calling contract is a newly deployed contract by the dispute game factory + if (!KAILUA_TREASURY.isProposing()) { + revert UnknownGame(); + } + + // INVARIANT: The calling KailuaGame contract is not referring to itself as a parent + if (msg.sender == address(this)) { + revert InvalidParent(); + } + + // INVARIANT: No longer accept proposals after resolution + if (contenderIndex < children.length && children[contenderIndex].status() == GameStatus.DEFENDER_WINS) { + revert ClaimAlreadyResolved(); + } + + // Append new child to children list + children.push(KailuaTournament(msg.sender)); + } + + /// @notice Returns the amount of time left for challenges as of the input timestamp. + function getChallengerDuration(uint64 asOfTimestamp) public view virtual returns (Duration duration_); + + /// @notice Returns the earliest time at which this proposal could have been created + function minCreationTime() public view virtual returns (Timestamp minCreationTime_); + + /// @notice Returns the parent game contract. + function parentGame() public view virtual returns (KailuaTournament parentGame_); + + /// @notice Returns the proposer address + function proposer() public view returns (address proposer_) { + proposer_ = KAILUA_TREASURY.proposerOf(address(this)); + } + + /// @notice Verifies that an intermediate output was part of the proposal + function verifyIntermediateOutput( + uint64 outputNumber, + uint256 outputFe, + bytes calldata blobCommitment, + bytes calldata kzgProof + ) + external + virtual + returns (bool success); + + /// @notice Updates the provability of a child signature if not already set + function updateProofStatus(address payoutRecipient, bytes32 childSignature, ProofStatus outcome) internal { + // INVARIANT: Proofs can only be submitted once + if (proofStatus[childSignature] != ProofStatus.NONE) { + revert AlreadyProven(); + } + + // Update proof status + proofStatus[childSignature] = outcome; + + // Announce proof status + emit Proven(childSignature, outcome); + + // Set the game's prover address + prover[childSignature] = payoutRecipient; + + // Set the game's proving timestamp + provenAt[childSignature] = Timestamp.wrap(uint64(block.timestamp)); + } + + // ------------------------------ + // IDisputeGame implementation + // ------------------------------ + + /// @inheritdoc IDisputeGame + Timestamp public createdAt; + + /// @inheritdoc IDisputeGame + Timestamp public resolvedAt; + + /// @inheritdoc IDisputeGame + GameStatus public status; + + /// @inheritdoc IDisputeGame + function gameType() external view returns (GameType gameType_) { + gameType_ = GAME_TYPE; + } + + /// @inheritdoc IDisputeGame + function gameCreator() public pure returns (address creator_) { + creator_ = _getArgAddress(0x00); + } + + /// @inheritdoc IDisputeGame + function rootClaimByChainId(uint256) public pure returns (Claim rootClaim_) { + // todo: support chain id specific proposals + rootClaim_ = rootClaim(); + } + + /// @inheritdoc IDisputeGame + function rootClaim() public pure returns (Claim rootClaim_) { + rootClaim_ = Claim.wrap(_getArgBytes32(0x14)); + } + + /// @inheritdoc IDisputeGame + function l1Head() public pure returns (Hash l1Head_) { + l1Head_ = Hash.wrap(_getArgBytes32(0x34)); + } + + /// @notice The l2SequenceNumber of the claim's output root. + function l2SequenceNumber() public pure returns (uint256 l2SequenceNumber_) { + l2SequenceNumber_ = uint256(_getArgUint64(0x54)); + } + + /// @inheritdoc IDisputeGame + function gameData() external view returns (GameType gameType_, Claim rootClaim_, bytes memory extraData_) { + gameType_ = GAME_TYPE; + rootClaim_ = this.rootClaim(); + extraData_ = this.extraData(); + } + + /// @notice True iff the Kailua GameType was respected by OptimismPortal at time of creation + bool public wasRespectedGameTypeWhenCreated; + + /// @notice This is a workaround for withdrawal compatibility under op-contracts v5.0.0 + function anchorStateRegistry() external view returns (IAnchorStateRegistry registry_) { + registry_ = IAnchorStateRegistry(msg.sender); + } + + // ------------------------------ + // Tournament + // ------------------------------ + + /// @notice Eliminates children until at least one remains + function pruneChildren(uint256 stepLimit) external returns (KailuaTournament) { + // INVARIANT: Only finalized proposals may prune tournaments + if (status != GameStatus.DEFENDER_WINS) { + revert GameNotResolved(); + } + + // INVARIANT: No tournament to play without at least one child + if (children.length == 0) { + revert NotProposed(); + } + + // Resume from prior surviving contender + uint64 u = contenderIndex; + // Resume from prior unprocessed opponent + uint64 v = opponentIndex; + // Abort if out of bounds + if (u == children.length) { + return KailuaTournament(address(0x0)); + } + // Advance v if needed + if (v <= u) { + // INVARIANT: contenderDuplicates is empty + v = u + 1; + } + + // Note: u < children.length + // Fetch contender details + KailuaTournament contender = children[u]; + bytes32 contenderSignature = contender.signature(); + + // Ensure survivor decision finality after resolution + if (contender.status() == GameStatus.DEFENDER_WINS) { + return contender; + } + + // If the contender is invalid then we eliminate it and find the next viable contender using the opponent + // pointer. This search could terminate early if the elimination limit is reached. + // If the contender is valid and its proposer is not eliminated, this is skipped. + if (!isViableSignature(contenderSignature) || isChildEliminated(contender)) { + // INVARIANT: If branch entered through isChildEliminated condition, contenderDuplicates is empty + + // Eliminate duplicates + address payoutRecipient = getPayoutRecipient(contenderSignature); + for (uint256 i = contenderDuplicates.length; i > 0 && stepLimit > 0; (i--, stepLimit--)) { + KailuaTournament duplicate = children[contenderDuplicates[i - 1]]; + if (!isChildEliminated(duplicate)) { + KAILUA_TREASURY.eliminate(address(duplicate), payoutRecipient); + } + contenderDuplicates.pop(); + } + + // Abort if elimination allowance exhausted before eliminating all duplicate contenders + if (stepLimit == 0) { + return KailuaTournament(address(0x0)); + } + + // Eliminate contender + if (!isChildEliminated(contender)) { + KAILUA_TREASURY.eliminate(address(contender), payoutRecipient); + } + stepLimit--; + + // Find next viable contender + // INVARIANT: v > max(u, contenderDuplicates); + u = v; + for (; u < children.length && stepLimit > 0; (u++, stepLimit--)) { + // Skip if previously eliminated + contender = children[u]; + if (isChildEliminated(contender)) { + continue; + } + // Eliminate if faulty + contenderSignature = contender.signature(); + if (!isViableSignature(contenderSignature)) { + // eliminate the unviable contender + KAILUA_TREASURY.eliminate(address(contender), getPayoutRecipient(contenderSignature)); + continue; + } + // Select u as next viable contender + break; + } + // Store contender + contenderIndex = u; + // Select the next possible opponent + v = u + 1; + } + + // Eliminate faulty opponents if we've landed on a viable contender + if (u < children.length && isViableSignature(children[u].signature())) { + // Iterate over opponents to eliminate them + for (; v < children.length && stepLimit > 0; (v++, stepLimit--)) { + KailuaTournament opponent = children[v]; + // If the contender hasn't been challenged for as long as the timeout, declare them winner + if (contender.getChallengerDuration(opponent.createdAt().raw()).raw() == 0) { + // Note: This implies eliminationLimit > 0 + break; + } + // If the opponent proposer is eliminated, skip + if (isChildEliminated(opponent)) { + continue; + } + // Append contender duplicate + bytes32 opponentSignature = opponent.signature(); + if (opponentSignature == contenderSignature) { + contenderDuplicates.push(v); + continue; + } + // If there is insufficient proof data, abort + // Validity: The contender is the proven child, the opponent must be incorrect + // Fault: The contender is not proven faulty, the opponent may (not) be. + if (isViableSignature(opponentSignature)) { + revert NotProven(); + } + // eliminate the opponent with the unviable proposal + KAILUA_TREASURY.eliminate(address(opponent), getPayoutRecipient(opponentSignature)); + } + + // INVARIANT: v > u && contender == children[u] + // Record incremental opponent elimination progress + opponentIndex = v; + + // Return the sole survivor if no more matches can be played + if (v == children.length || stepLimit > 0) { + return contender; + } + } + + // No survivor yet + return KailuaTournament(address(0x0)); + } + + // ------------------------------ + // Validity proving + // ------------------------------ + + /// @notice Returns the hash of all blob hashes associated with this proposal + function blobsHash() public view returns (bytes32 blobsHash_) { + blobsHash_ = sha256(abi.encodePacked(proposalBlobHashes)); + } + + /// @notice Proves that a proposal is valid + function proveValidity( + address payoutRecipient, + address l1HeadSource, + uint64 childIndex, + bytes calldata encodedSeal + ) + external + { + KailuaTournament childContract = children[childIndex]; + // INVARIANT: Can only prove validity of unresolved proposals + if (childContract.status() != GameStatus.IN_PROGRESS) { + revert GameNotInProgress(); + } + + // Store validity proof data (deleted on revert) + validChildSignature = childContract.signature(); + + // INVARIANT: No longer accept proofs after resolution + if (contenderIndex < children.length && children[contenderIndex].status() == GameStatus.DEFENDER_WINS) { + revert ClaimAlreadyResolved(); + } + + // Calculate the expected precondition hash if blob data is necessary for proposal + bytes32 preconditionHash = bytes32(0x0); + if (PROPOSAL_OUTPUT_COUNT > 1) { + preconditionHash = sha256( + abi.encodePacked( + uint64(l2SequenceNumber()), + uint64(PROPOSAL_OUTPUT_COUNT), + uint64(OUTPUT_BLOCK_SPAN), + childContract.blobsHash() + ) + ); + } + + // update proof status + prove( + l1HeadSource, + payoutRecipient, + preconditionHash, + rootClaim().raw(), + childContract.rootClaim().raw(), + PROPOSAL_OUTPUT_COUNT, + encodedSeal, + validChildSignature, + ProofStatus.VALIDITY + ); + } + + // ------------------------------ + // Fault proving + // ------------------------------ + + /// @notice Proves that a proposal committed to an incorrect transition + function proveOutputFault( + // [ payoutRecipient, l1HeadSource ] + address[2] calldata prHs, + // [ childIndex, outputOffset ] + uint64[2] calldata co, + bytes calldata encodedSeal, + // [ acceptedOutputHash, computedOutputHash ] + bytes32[2] memory ac, + uint256 proposedOutputFe, + bytes[][2] calldata kzgCommitmentsProofs + ) + external + { + KailuaTournament childContract = children[co[0]]; + // INVARIANT: Proofs cannot be submitted unless the child is playing. + if (childContract.status() != GameStatus.IN_PROGRESS) { + revert GameNotInProgress(); + } + + // INVARIANT: No longer accept proofs after resolution + if (contenderIndex < children.length && children[contenderIndex].status() == GameStatus.DEFENDER_WINS) { + revert ClaimAlreadyResolved(); + } + + // INVARIANT: Proofs can only pertain to intermediate outputs + if (co[1] >= PROPOSAL_OUTPUT_COUNT) { + revert InvalidDisputedClaimIndex(); + } + + // Validate the common output root. + if (co[1] == 0) { + // Note: acceptedOutputHash cannot be a reduced fe because the comparison below will fail + // The safe output is the parent game's output when proving the first output + require(ac[0] == rootClaim().raw(), "bad acceptedOutput"); + } else { + // Note: acceptedOutputHash cannot be a reduced fe because the journal would not be provable + // Prove common output publication + require( + childContract.verifyIntermediateOutput( + co[1] - 1, KailuaLib.hashToFe(ac[0]), kzgCommitmentsProofs[0][0], kzgCommitmentsProofs[1][0] + ), + "bad acceptedOutput kzg" + ); + } + + // Validate the claimed output root. + if (co[1] == PROPOSAL_OUTPUT_COUNT - 1) { + // INVARIANT: Proofs can only show disparities + if (ac[1] == childContract.rootClaim().raw()) { + revert NoConflict(); + } + } else { + // Note: proposedOutputFe must be a canonical point or point eval precompile call will fail + // Prove divergent output publication + require( + childContract.verifyIntermediateOutput( + co[1], + proposedOutputFe, + kzgCommitmentsProofs[0][kzgCommitmentsProofs[0].length - 1], + kzgCommitmentsProofs[1][kzgCommitmentsProofs[1].length - 1] + ), + "bad proposedOutput kzg" + ); + // INVARIANT: Proofs can only show disparities + if (KailuaLib.hashToFe(ac[1]) == proposedOutputFe) { + revert NoConflict(); + } + } + + // update proof status + prove( + prHs[1], + prHs[0], + bytes32(0), + ac[0], + ac[1], + co[1] + 1, + encodedSeal, + childContract.signature(), + ProofStatus.FAULT + ); + } + + /// @notice Proves that a proposal contains invalid intermediate data + function proveTrailFault( + address payoutRecipient, + uint64[2] calldata co, + uint256 proposedOutputFe, + bytes calldata blobCommitment, + bytes calldata kzgProof + ) + external + { + KailuaTournament childContract = children[co[0]]; + // INVARIANT: Proofs cannot be submitted unless the children are playing. + if (childContract.status() != GameStatus.IN_PROGRESS) { + revert GameNotInProgress(); + } + + // INVARIANT: No longer accept proofs after resolution + if (contenderIndex < children.length && children[contenderIndex].status() == GameStatus.DEFENDER_WINS) { + revert ClaimAlreadyResolved(); + } + + // INVARIANT: Proofs can only pertain to trail data + if (co[1] < PROPOSAL_OUTPUT_COUNT) { + revert InvalidDisputedClaimIndex(); + } + + // We expect all trail data to be zeroed + if (proposedOutputFe == 0) { + revert NoConflict(); + } + + // Because the root claim is considered the last published output, we shift the provided output offset down by + // one to correctly point to the target trailing zero output + // INVARIANT: The divergence occurs in the last blob + uint64 feOffset = co[1] - 1; + if (KailuaLib.blobIndex(feOffset) != PROPOSAL_BLOBS - 1) { + revert InvalidDisputedClaimIndex(); + } + + // Validate the claimed output root publications + // Note: proposedOutputFe must be a canonical field element or point eval precompile call will fail + require( + childContract.verifyIntermediateOutput(feOffset, proposedOutputFe, blobCommitment, kzgProof), + "bad proposedOutput kzg" + ); + + // Update dispute status based on trailing data + updateProofStatus(payoutRecipient, childContract.signature(), ProofStatus.FAULT); + } + + // ------------------------------ + // ZK Proving + // ------------------------------ + + /// @notice Verifies a ZK proof and updates the proof status according to the provided outcome if the proof is valid + function prove( + address l1HeadSource, + address payoutRecipient, + bytes32 preconditionHash, + bytes32 acceptedOutputHash, + bytes32 computedOutputHash, + uint64 outputCount, + bytes calldata encodedSeal, + bytes32 childSignature, + ProofStatus outcome + ) + internal + { + // Validate the l1Head source + if (KAILUA_TREASURY.proposerOf(l1HeadSource) == address(0x0)) { + revert UnknownGame(); + } + + // Revert on proof verification failure + KAILUA_VERIFIER.verify( + // The address of the recipient of the payout for this proof + payoutRecipient, + // The blob equivalence precondition hash + preconditionHash, + // The L1 head hash containing the safe L2 chain data that may reproduce the L2 head hash. + KailuaTournament(l1HeadSource).l1Head().raw(), + // The accepted output + acceptedOutputHash, + // The proposed output + computedOutputHash, + // The claim block number + uint64(l2SequenceNumber() + outputCount * OUTPUT_BLOCK_SPAN), + // The cryptographic proof + encodedSeal + ); + + // Mark the child as proven + updateProofStatus(payoutRecipient, childSignature, outcome); + } +} diff --git a/packages/contracts-bedrock/src/dispute/zk/KailuaTreasury.sol b/packages/contracts-bedrock/src/dispute/zk/KailuaTreasury.sol new file mode 100644 index 00000000000..6e3852c6734 --- /dev/null +++ b/packages/contracts-bedrock/src/dispute/zk/KailuaTreasury.sol @@ -0,0 +1,405 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import { KailuaLib } from "src/dispute/lib/KailuaLib.sol"; +import { KailuaTournament } from "src/dispute/zk/KailuaTournament.sol"; +import { IInitializable } from "interfaces/dispute/IInitializable.sol"; +import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import { Claim, Duration, GameStatus, GameType, Timestamp } from "src/dispute/lib/Types.sol"; +import { IDisputeGame } from "interfaces/dispute/IDisputeGame.sol"; +import { IKailuaTreasury } from "interfaces/dispute/zk/IKailuaTreasury.sol"; +import { IKailuaVerifier } from "interfaces/dispute/zk/IKailuaVerifier.sol"; +import { IOptimismPortal2 } from "interfaces/L1/IOptimismPortal2.sol"; +import { + Blacklisted, + NotProposed, + GameNotInProgress, + GameNotResolved, + VanguardError, + IncorrectBondAmount, + BadAuth, + NoCreditToClaim, + AlreadyEliminated, + NotFactoryOwner, + BadExtraData, + BlockNumberMismatch, + UnexpectedRootClaim +} from "src/dispute/lib/Errors.sol"; + +contract KailuaTreasury is KailuaTournament { + /// @notice Emitted when the participation bond is updated + /// @param amount The new required bond amount + event BondUpdated(uint256 amount); + + // ------------------------------ + // Immutable configuration + // ------------------------------ + + /// @notice The initial root claim for the deployment + Claim public immutable ROOT_CLAIM; + + /// @notice The L2 block number of the initial root claim for the deployment + uint64 public immutable L2_BLOCK_NUMBER; + + constructor( + IKailuaVerifier _kailuaVerifier, + uint64 _proposalOutputCount, + uint64 _outputBlockSpan, + GameType _gameType, + IOptimismPortal2 _optimismPortal, + Claim _rootClaim, + uint64 _l2SequenceNumber + ) + KailuaTournament( + IKailuaTreasury(address(this)), + _kailuaVerifier, + _proposalOutputCount, + _outputBlockSpan, + _gameType, + _optimismPortal + ) + { + ROOT_CLAIM = _rootClaim; + L2_BLOCK_NUMBER = _l2SequenceNumber; + } + + // ------------------------------ + // IInitializable implementation + // ------------------------------ + + /// @inheritdoc IInitializable + function initialize() external payable override { + super.initializeInternal(); + + // Revert if the calldata size is not the expected length. + // + // This is to prevent adding extra or omitting bytes from to `extraData` that result in a different game UUID + // in the factory, but are not used by the game, which would allow for multiple dispute games for the same + // output proposal to be created. + // + // Expected length: 0x76 + // - 0x04 selector 0x00 0x04 + // - 0x14 creator address 0x04 0x18 + // - 0x20 root claim 0x18 0x38 + // - 0x20 l1 head 0x38 0x58 + // - 0x1c extraData: 0x58 0x74 + // + 0x08 l2SequenceNumber 0x58 0x60 + // + 0x14 kailuaTreasuryAddress 0x60 0x74 + // - 0x02 CWIA bytes 0x74 0x76 + if (msg.data.length != 0x76) { + revert BadExtraData(); + } + + // Accept only the initialized root claim + if (rootClaim().raw() != ROOT_CLAIM.raw()) { + revert UnexpectedRootClaim(rootClaim()); + } + + // Accept only the initialized l2 block number + if (l2SequenceNumber() != L2_BLOCK_NUMBER) { + revert BlockNumberMismatch(l2SequenceNumber(), L2_BLOCK_NUMBER); + } + + // Accept only the address of the deployment treasury + if (treasuryAddress() != KAILUA_TREASURY) { + revert BadExtraData(); + } + } + + /// @notice Returns the treasury address used in initialization + function treasuryAddress() public pure returns (IKailuaTreasury treasuryAddress_) { + treasuryAddress_ = IKailuaTreasury(_getArgAddress(0x5c)); + } + + // ------------------------------ + // IDisputeGame implementation + // ------------------------------ + + /// @inheritdoc IDisputeGame + function extraData() external pure returns (bytes memory extraData_) { + // The extra data starts at the second word within the cwia calldata and + // is 32 bytes long. + extraData_ = _getArgBytes(0x54, 0x1c); + } + + /// @inheritdoc IDisputeGame + function resolve() external onlyFactoryOwner returns (GameStatus status_) { + // INVARIANT: Resolution cannot occur unless the game is currently in progress. + if (status != GameStatus.IN_PROGRESS) { + revert GameNotInProgress(); + } + + // Update the status and emit the resolved event, note that we're performing a storage update here. + emit Resolved(status = status_ = GameStatus.DEFENDER_WINS); + + // Mark resolution timestamp + resolvedAt = Timestamp.wrap(uint64(block.timestamp)); + + // Update lastResolved + KAILUA_TREASURY.updateLastResolved(); + } + + // ------------------------------ + // Fault proving + // ------------------------------ + + /// @inheritdoc KailuaTournament + function verifyIntermediateOutput( + uint64, + uint256, + bytes calldata, + bytes calldata + ) + external + pure + override + returns (bool success) + { + // No known blobs to reference + } + + /// @inheritdoc KailuaTournament + function getChallengerDuration(uint64) public pure override returns (Duration duration_) { + // No challenge period + } + + /// @inheritdoc KailuaTournament + function minCreationTime() public view override returns (Timestamp minCreationTime_) { + minCreationTime_ = createdAt; + } + + /// @inheritdoc KailuaTournament + function parentGame() public view override returns (KailuaTournament parentGame_) { + parentGame_ = this; + } + + // ------------------------------ + // IKailuaTreasury implementation + // ------------------------------ + + /// @notice Returns the game index at which proposer was proven faulty + mapping(address => uint256) public eliminationRound; + + /// @notice Returns the proposer of a game + mapping(address => address) public proposerOf; + + /// @notice Eliminates a child's proposer and allocates their bond to the prover + function eliminate(address _child, address prover) external { + KailuaTournament child = KailuaTournament(_child); + + // INVARIANT: Only the child's parent may call this + KailuaTournament parent = child.parentGame(); + if (msg.sender != address(parent)) { + revert Blacklisted(msg.sender, address(parent)); + } + + // INVARIANT: Only known proposals may be eliminated + address eliminated = proposerOf[address(child)]; + if (eliminated == address(0x0)) { + revert NotProposed(); + } + + // INVARIANT: Cannot double-eliminate players + if (eliminationRound[eliminated] > 0) { + revert AlreadyEliminated(); + } + + // Record elimination round + eliminationRound[eliminated] = child.gameIndex(); + + uint256 bond = paidBonds[eliminated]; + paidBonds[eliminated] = 0; + + // Split the slashed bond into prover / winner / burn. + uint256 proverShare = (bond * ELIMINATION_SPLIT_PROVER_NUM) / ELIMINATION_SPLIT_DENOM; + uint256 winnerShare = (bond * ELIMINATION_SPLIT_WINNER_NUM) / ELIMINATION_SPLIT_DENOM; + uint256 burnShare = bond - proverShare - winnerShare; + + eliminationRewards[prover] += proverShare; + winnerSharesByParent[parent] += winnerShare; + // Burn by sending it to the zero address. + // The zero address has no code, so this external call cannot reenter. + KailuaLib.pay(burnShare, address(0)); + } + + /// @notice Returns true iff a proposal is currently being submitted + bool public isProposing; + + /// @notice Returns the last resolved proposal contract address + address public lastResolved; + + /// @notice Updates the last resolved contract address to that of the caller + function updateLastResolved() external { + address proposer = proposerOf[msg.sender]; + + // INVARIANT: Only known proposal contracts may call this function + if (proposer == address(0x0)) { + revert NotProposed(); + } + + KailuaTournament parent = KailuaTournament(msg.sender).parentGame(); + eliminationRewards[proposer] += winnerSharesByParent[parent]; + winnerSharesByParent[parent] = 0; + + lastResolved = msg.sender; + } + + // ------------------------------ + // Treasury + // ------------------------------ + + /// @notice Fixed split of a slashed participation bond between prover, winner, and burn. + uint256 public constant ELIMINATION_SPLIT_DENOM = 3; + uint256 public constant ELIMINATION_SPLIT_PROVER_NUM = 1; + uint256 public constant ELIMINATION_SPLIT_WINNER_NUM = 1; + + /// @notice The locked collateral required for proposal submission + uint256 public participationBond; + + /// @notice The locked collateral still paid by proposers for participation + mapping(address => uint256) public paidBonds; + + /// @notice The total share of elimination bonds accumulated for the eventual tournament winner. + /// @dev Keyed by the parent game (tournament) contract. + mapping(KailuaTournament => uint256) private winnerSharesByParent; + + /// @notice The unpaid rewards from eliminated invalid proposals + mapping(address => uint256) public eliminationRewards; + + /// @notice The last proposal made by each proposer + mapping(address => KailuaTournament) public lastProposal; + + /// @notice The leading proposer that can extend the proposal tree + address public vanguard; + + /// @notice The duration for which the vanguard may lead + Duration public vanguardAdvantage; + + /// @notice Boolean flag to prevent re-entrant calls + bool internal isLocked; + + modifier nonReentrant() { + _nonReentrantBefore(); + _; + _nonReentrantAfter(); + } + + function _nonReentrantBefore() internal { + require(!isLocked); + isLocked = true; + } + + function _nonReentrantAfter() internal { + isLocked = false; + } + + modifier onlyFactoryOwner() { + _onlyFactoryOwner(); + _; + } + + function _onlyFactoryOwner() internal view { + OwnableUpgradeable factoryContract = OwnableUpgradeable(address(DISPUTE_GAME_FACTORY)); + if (msg.sender != factoryContract.owner()) revert NotFactoryOwner(); + } + + /// @notice Pays the elimination rewards the sender has accrued + function claimEliminationRewards() public nonReentrant { + uint256 payout = eliminationRewards[msg.sender]; + eliminationRewards[msg.sender] = 0; + + if (payout > 0) { + KailuaLib.pay(payout, msg.sender); + } + } + + /// @notice Pays the proposer back its bond + function claimProposerBond() public nonReentrant { + // INVARIANT: Can only claim back bond if not eliminated + if (eliminationRound[msg.sender] != 0) { + revert AlreadyEliminated(); + } + + // INVARIANT: Can only claim bond back if no pending proposals are left + KailuaTournament previousGame = lastProposal[msg.sender]; + if (address(previousGame) != address(0x0)) { + KailuaTournament lastTournament = previousGame.parentGame(); + if (lastTournament.children(lastTournament.contenderIndex()).status() != GameStatus.DEFENDER_WINS) { + revert GameNotResolved(); + } + } + + uint256 payout = paidBonds[msg.sender]; + // INVARIANT: Can only claim bond if it is paid + if (payout == 0) { + revert NoCreditToClaim(); + } + + // Pay out and clear bond + paidBonds[msg.sender] = 0; + KailuaLib.pay(payout, msg.sender); + } + + /// @notice Updates the required bond for new proposals + function setParticipationBond(uint256 amount) external onlyFactoryOwner { + participationBond = amount; + emit BondUpdated(amount); + } + + /// @notice Updates the vanguard address and advantage duration + function assignVanguard(address _vanguard, Duration _vanguardAdvantage) external onlyFactoryOwner { + vanguard = _vanguard; + vanguardAdvantage = _vanguardAdvantage; + } + + /// @notice Checks the proposer's bonded amount and creates a new proposal through the factory + function propose( + Claim _rootClaim, + bytes calldata _extraData + ) + external + payable + returns (KailuaTournament tournament) + { + // Check proposer honesty + if (eliminationRound[msg.sender] > 0) { + revert BadAuth(); + } + // Update proposer bond + if (msg.value > 0) { + paidBonds[msg.sender] += msg.value; + } + // Check proposer bond + if (paidBonds[msg.sender] < participationBond) { + revert IncorrectBondAmount(); + } + // Create proposal + isProposing = true; + tournament = KailuaTournament(address(DISPUTE_GAME_FACTORY.create(GAME_TYPE, _rootClaim, _extraData))); + isProposing = false; + // Check proposal progression + KailuaTournament previousGame = lastProposal[msg.sender]; + if (address(previousGame) != address(0x0)) { + // INVARIANT: Proposers may only extend the proposal set incrementally + if (previousGame.l2SequenceNumber() >= tournament.l2SequenceNumber()) { + revert BlockNumberMismatch(previousGame.l2SequenceNumber(), tournament.l2SequenceNumber()); + } + } + // Check whether the proposer must follow a vanguard if one is set + if (vanguard != address(0x0) && vanguard != msg.sender) { + // The proposer may only counter the vanguard during the advantage time + KailuaTournament proposalParent = tournament.parentGame(); + if (proposalParent.childCount() == 1) { + // Count the advantage clock since proposal was possible + uint64 elapsedAdvantage = uint64(block.timestamp - tournament.minCreationTime().raw()); + if (elapsedAdvantage < vanguardAdvantage.raw()) { + revert VanguardError(address(proposalParent)); + } + } + } + // Record proposer + proposerOf[address(tournament)] = msg.sender; + // Record proposal + lastProposal[msg.sender] = tournament; + } +} diff --git a/packages/contracts-bedrock/src/dispute/zk/KailuaVerifier.sol b/packages/contracts-bedrock/src/dispute/zk/KailuaVerifier.sol new file mode 100644 index 00000000000..9f4b690e3ca --- /dev/null +++ b/packages/contracts-bedrock/src/dispute/zk/KailuaVerifier.sol @@ -0,0 +1,284 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import { IKailuaTournament } from "interfaces/dispute/zk/IKailuaTournament.sol"; +import { IKailuaTreasury } from "interfaces/dispute/zk/IKailuaTreasury.sol"; +import { IRiscZeroVerifier } from "interfaces/dispute/zk/IRiscZeroVerifier.sol"; +import { ISemver } from "interfaces/universal/ISemver.sol"; +import { Duration } from "src/dispute/lib/Types.sol"; +import { KailuaLib } from "src/dispute/lib/KailuaLib.sol"; +import { + ClockNotExpired, + IncorrectBondAmount, + NoCreditToClaim, + AlreadyEliminated, + NotProven, + ProvenFaulty, + BadTarget +} from "src/dispute/lib/Errors.sol"; + +contract KailuaVerifier is ISemver { + /// @notice Semantic version. + /// @custom:semver 1.2.0 + string public constant version = "1.2.0"; + + /// @notice The RISC Zero verifier contract + IRiscZeroVerifier public immutable RISC_ZERO_VERIFIER; + + /// @notice The RISC Zero image id of the fault proof program + bytes32 public immutable FPVM_IMAGE_ID; + + /// @notice The hash of the game configuration + bytes32 public immutable ROLLUP_CONFIG_HASH; + + /// @notice The duration after which a permit expires + Duration public immutable PERMIT_DURATION; + + constructor(IRiscZeroVerifier _verifierContract, bytes32 _imageId, bytes32 _configHash, Duration _permitDuration) { + RISC_ZERO_VERIFIER = _verifierContract; + FPVM_IMAGE_ID = _imageId; + ROLLUP_CONFIG_HASH = _configHash; + PERMIT_DURATION = _permitDuration; + } + + /// @notice Maps parent-child to their fault proving permits + mapping(bytes32 => FaultProofPermit[]) public faultProofPermits; + + /// @notice Describes a permit for fault proving + /// @custom:field recipient Address of the permit recipient + /// @custom:field aggregateCollateral Total collateral locked as of permit + /// @custom:field timestamp Timestamp of permit issuance + /// @custom:field released Flag for whether the collateral locked for this permit + struct FaultProofPermit { + uint256 aggregateCollateral; + address recipient; + uint64 timestamp; + bool released; + } + + /// @notice Returns the key for indexing fault proving permits + function faultProofPermitKey( + IKailuaTournament proposalParent, + bytes32 proposalSignature + ) + public + pure + returns (bytes32) + { + return sha256(abi.encodePacked(address(proposalParent), proposalSignature)); + } + + /// @notice Returns the earliest timestamp at which a fault proof permit can be released + function faultProofPermitProvenAt( + IKailuaTournament proposalParent, + bytes32 proposalSignature + ) + public + view + returns (uint64) + { + // INVARIANT: A validity proof for the same signature does not satisfy a fault proof permit. + bytes32 validChildSignature = proposalParent.validChildSignature(); + if (proposalSignature == validChildSignature) { + return 0; + } + // Fetch both fault and validity proof timestamps + uint64 faultProofTimestamp = proposalParent.provenAt(proposalSignature).raw(); + uint64 validityProofTimestamp = proposalParent.provenAt(validChildSignature).raw(); + // Return the smaller timestamp if both proofs are present + if (faultProofTimestamp > 0 && validityProofTimestamp > 0) { + return faultProofTimestamp < validityProofTimestamp ? faultProofTimestamp : validityProofTimestamp; + } + // Return the larger timestamp otherwise + return faultProofTimestamp > validityProofTimestamp ? faultProofTimestamp : validityProofTimestamp; + } + + /// @notice Returns the exclusive beneficiary of a fault proof reward + function faultProofPermitBeneficiary( + IKailuaTournament proposalParent, + bytes32 proposalSignature + ) + public + view + returns (address) + { + // If the signature is still viable, there is no sole fault proof beneficiary + if (proposalParent.isViableSignature(proposalSignature)) { + return address(0x0); + } + // If there wasn't exactly one permit, then proving was not exclusive to one party + FaultProofPermit[] storage proposalPermits = + faultProofPermits[faultProofPermitKey(proposalParent, proposalSignature)]; + if (proposalPermits.length != 1) { + return address(0x0); + } + // If there was no proof or the permit was expired as of proof submission, disqualify the beneficiary + uint64 provingTime = faultProofPermitProvenAt(proposalParent, proposalSignature); + if (provingTime == 0 || proposalPermits[0].timestamp + PERMIT_DURATION.raw() < provingTime) { + return address(0x0); + } + // Return the successful sole beneficiary of the locked fault proof reward + return proposalPermits[0].recipient; + } + + /// @notice Given a reference timestamp, returns the number of expired permits, their total collateral, and the + /// number of active permits + function countExpiredPermits( + bytes32 proposalKey, + uint64 numExpiredPermits, + uint64 timestamp + ) + public + view + returns (uint64, uint256, uint64) + { + FaultProofPermit[] storage proposalPermits = faultProofPermits[proposalKey]; + uint256 expiredCollateral = 0; + uint64 totalPermits = uint64(proposalPermits.length); + // Increment numExpiredPermits if possible + for (; numExpiredPermits < totalPermits; numExpiredPermits++) { + if (proposalPermits[numExpiredPermits].timestamp + PERMIT_DURATION.raw() >= timestamp) { + break; + } + } + // Validate expiry + if (numExpiredPermits > 0) { + // If numExpiredPermits is invalid, revert + if (proposalPermits[numExpiredPermits - 1].timestamp + PERMIT_DURATION.raw() >= timestamp) { + revert BadTarget(); + } + // Set expired collateral + expiredCollateral = proposalPermits[numExpiredPermits - 1].aggregateCollateral; + } + return (numExpiredPermits, expiredCollateral, totalPermits - numExpiredPermits); + } + + /// @notice Returns the collateral required to acquire a fault proof permit + function faultProofPermitBond(IKailuaTreasury treasury) public view returns (uint256 bond) { + bond = (treasury.participationBond() * 2 * treasury.ELIMINATION_SPLIT_PROVER_NUM()) + / treasury.ELIMINATION_SPLIT_DENOM(); + } + + /// @notice Locks the right to submit a fault proof for a given proposal signature + /// @dev Do not call this function to acquire locks for faults that will not lead to elimination. + function acquireFaultProofPermit( + IKailuaTournament proposalParent, + bytes32 proposalSignature, + uint64 numExpiredPermits, + address payoutRecipient + ) + external + payable + returns (uint256 totalPermitsIssued_) + { + // INVARIANT: The child signature is still viable so no proof is submitted for/against it + if (!proposalParent.isViableSignature(proposalSignature)) { + revert ProvenFaulty(); + } + // INVARIANT: The collateral submitted for the permit covers two times the proving reward + IKailuaTreasury treasury = IKailuaTreasury(address(proposalParent.KAILUA_TREASURY())); + if (msg.value < faultProofPermitBond(treasury)) { + revert IncorrectBondAmount(); + } + // INVARIANT: There are exactly numExpiredPermits expired permits as of block.timestamp + bytes32 proposalKey = faultProofPermitKey(proposalParent, proposalSignature); + (numExpiredPermits,,) = countExpiredPermits(proposalKey, numExpiredPermits, uint64(block.timestamp)); + // INVARIANT: There is at least one permit available + FaultProofPermit[] storage proposalPermits = faultProofPermits[proposalKey]; + totalPermitsIssued_ = proposalPermits.length; + if (totalPermitsIssued_ > 2 * numExpiredPermits) { + revert ClockNotExpired(); + } + // Calculate the aggregate collateral value + uint256 aggregateCollateral = msg.value; + if (totalPermitsIssued_ > 0) { + aggregateCollateral += proposalPermits[totalPermitsIssued_ - 1].aggregateCollateral; + } + // Assign a new permit + proposalPermits.push( + FaultProofPermit({ + aggregateCollateral: aggregateCollateral, + recipient: payoutRecipient, + timestamp: uint64(block.timestamp), + released: false + }) + ); + } + + /// @notice Claims the total payout for a permit + function releaseFaultProofPermit( + IKailuaTournament proposalParent, + bytes32 proposalSignature, + uint64 numExpiredPermits, + uint64 permitIndex + ) + external + { + // INVARIANT: The child signature is proven faulty + if (proposalParent.isViableSignature(proposalSignature)) { + revert NotProven(); + } + // INVARIANT: There are exactly numExpiredPermits expired permits as of proof submission + uint64 proofTimestamp = faultProofPermitProvenAt(proposalParent, proposalSignature); + bytes32 permitKey = faultProofPermitKey(proposalParent, proposalSignature); + (, uint256 expiredCollateral, uint64 numActivePermits) = + countExpiredPermits(permitKey, numExpiredPermits, proofTimestamp); + // INVARIANT: The permit is not already released + FaultProofPermit storage permit = faultProofPermits[permitKey][permitIndex]; + if (permit.released) { + revert NoCreditToClaim(); + } + // INVARIANT: The permit is not expired as of proof submission + if (permit.timestamp + PERMIT_DURATION.raw() < proofTimestamp) { + revert AlreadyEliminated(); + } + // Calculate total payout + uint256 payout = expiredCollateral / numActivePermits; + // Add in recipient's own deposited collateral + if (permitIndex > 0) { + payout += permit.aggregateCollateral - faultProofPermits[permitKey][permitIndex - 1].aggregateCollateral; + } else { + payout += permit.aggregateCollateral; + } + // Pay out recipient + permit.released = true; + KailuaLib.pay(payout, payable(permit.recipient)); + } + + /// @notice Verifies a ZK proof + function verify( + address payoutRecipient, + bytes32 preconditionHash, + bytes32 l1Head, + bytes32 agreedL2OutputRoot, + bytes32 claimedL2OutputRoot, + uint64 claimedL2BlockNumber, + bytes calldata encodedSeal + ) + external + view + { + // Construct the expected journal + bytes memory journal = abi.encodePacked( + // The address of the recipient of the payout for this proof + payoutRecipient, + // The blob equivalence precondition hash + preconditionHash, + // The L1 head hash containing the safe L2 chain data that may reproduce the L2 head hash. + l1Head, + // The accepted output + agreedL2OutputRoot, + // The proposed output + claimedL2OutputRoot, + // The claim block number + claimedL2BlockNumber, + // The rollup configuration hash + ROLLUP_CONFIG_HASH, + // The FPVM Image ID + FPVM_IMAGE_ID + ); + + // Revert on proof verification failure + RISC_ZERO_VERIFIER.verify(encodedSeal, FPVM_IMAGE_ID, sha256(journal)); + } +}