Commit 0d221da6 authored by clabby's avatar clabby Committed by GitHub

feat(ctb): Allow for checkpointing in claim resolution (#10248)

* feat(ctb): Allow for checkpointing in claim resolution

Introduces checkpointing to the `resolveClaim` function in the
`FaultDisputeGame`, allowing for the pagination of subgame resolution.

fix

* review

* feat(challenger): Resolution checkpointing support (#10253)

* feat(challenger): Resolution checkpointing support

Adds support for resolution checkpointing

* op-challenger: Use a simple maximum number of child claims to resolve per call.

---------
Co-authored-by: default avatarAdrian Sutton <adrian@oplabs.co>

---------
Co-authored-by: default avatarAdrian Sutton <adrian@oplabs.co>
parent caf41c55
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -18,6 +18,9 @@ import (
"github.com/ethereum/go-ethereum/common"
)
// The maximum number of children that will be processed during a call to `resolveClaim`
var maxChildChecks = big.NewInt(512)
var (
methodMaxClockDuration = "maxClockDuration"
methodMaxGameDepth = "maxGameDepth"
......@@ -408,7 +411,7 @@ func (f *FaultDisputeGameContract) ResolveClaimTx(claimIdx uint64) (txmgr.TxCand
}
func (f *FaultDisputeGameContract) resolveClaimCall(claimIdx uint64) *batching.ContractCall {
return f.contract.Call(methodResolveClaim, new(big.Int).SetUint64(claimIdx))
return f.contract.Call(methodResolveClaim, new(big.Int).SetUint64(claimIdx), maxChildChecks)
}
func (f *FaultDisputeGameContract) CallResolve(ctx context.Context) (gameTypes.GameStatus, error) {
......
......@@ -250,14 +250,14 @@ func TestGetBalance(t *testing.T) {
func TestCallResolveClaim(t *testing.T) {
stubRpc, game := setupFaultDisputeGameTest(t)
stubRpc.SetResponse(fdgAddr, methodResolveClaim, rpcblock.Latest, []interface{}{big.NewInt(123)}, nil)
stubRpc.SetResponse(fdgAddr, methodResolveClaim, rpcblock.Latest, []interface{}{big.NewInt(123), maxChildChecks}, nil)
err := game.CallResolveClaim(context.Background(), 123)
require.NoError(t, err)
}
func TestResolveClaimTxTest(t *testing.T) {
stubRpc, game := setupFaultDisputeGameTest(t)
stubRpc.SetResponse(fdgAddr, methodResolveClaim, rpcblock.Latest, []interface{}{big.NewInt(123)}, nil)
stubRpc.SetResponse(fdgAddr, methodResolveClaim, rpcblock.Latest, []interface{}{big.NewInt(123), maxChildChecks}, nil)
tx, err := game.ResolveClaimTx(123)
require.NoError(t, err)
stubRpc.VerifyTxCandidate(tx)
......
......@@ -569,7 +569,7 @@ func (s *CrossLayerUser) ResolveClaim(t Testing, l2TxHash common.Hash) common.Ha
require.Nil(t, err)
time.Sleep(time.Duration(expiry) * time.Second)
resolveClaimTx, err := game.ResolveClaim(&s.L1.txOpts, common.Big0)
resolveClaimTx, err := game.ResolveClaim(&s.L1.txOpts, common.Big0, common.Big0)
require.Nil(t, err)
err = s.L1.env.EthCl.SendTransaction(t.Ctx(), resolveClaimTx)
......
......@@ -334,7 +334,7 @@ func (g *FaultGameHelper) StepFails(claimIdx int64, isAttack bool, stateData []b
// ResolveClaim resolves a single subgame
func (g *FaultGameHelper) ResolveClaim(ctx context.Context, claimIdx int64) {
tx, err := g.game.ResolveClaim(g.opts, big.NewInt(claimIdx))
tx, err := g.game.ResolveClaim(g.opts, big.NewInt(claimIdx), common.Big0)
g.require.NoError(err, "ResolveClaim transaction did not send")
_, err = wait.ForReceiptOK(ctx, g.client, tx.Hash())
g.require.NoError(err, "ResolveClaim transaction was not OK")
......
......@@ -620,7 +620,7 @@ func (g *OutputGameHelper) StepFails(claimIdx int64, isAttack bool, stateData []
// ResolveClaim resolves a single subgame
func (g *OutputGameHelper) ResolveClaim(ctx context.Context, claimIdx int64) {
tx, err := g.game.ResolveClaim(g.opts, big.NewInt(claimIdx))
tx, err := g.game.ResolveClaim(g.opts, big.NewInt(claimIdx), common.Big0)
g.require.NoError(err, "ResolveClaim transaction did not send")
_, err = wait.ForReceiptOK(ctx, g.client, tx.Hash())
g.require.NoError(err, "ResolveClaim transaction was not OK")
......
......@@ -205,7 +205,7 @@ func FinalizeWithdrawal(t *testing.T, cfg SystemConfig, l1Client *ethclient.Clie
require.Nil(t, err)
time.Sleep(time.Duration(expiry) * time.Second)
resolveClaimTx, err := proxy.ResolveClaim(opts, common.Big0)
resolveClaimTx, err := proxy.ResolveClaim(opts, common.Big0, common.Big0)
require.Nil(t, err)
resolveClaimReceipt, err = wait.ForReceiptOK(ctx, l1Client, resolveClaimTx.Hash())
......
......@@ -116,8 +116,8 @@
"sourceCodeHash": "0xc4dbd17217b63f8117f56f78c213e57dda304fee7577fe296e1d804ebe049542"
},
"src/dispute/FaultDisputeGame.sol": {
"initCodeHash": "0x8b8be450739ffdc236e5cbad7d59140d5c1f80cfc096c75260fb7224701fa3f3",
"sourceCodeHash": "0xe5bcdc2d310c46445a1f420db76225e0b5446671ba1c9a2f0b73e2a522faf967"
"initCodeHash": "0x614fc47be249e9e2278acfff13f4167c48db77bee9dd188e0514f67fbe6d2cd2",
"sourceCodeHash": "0xf7d3b1188f08c0bcb089db41f45622a08d71bccdcaf0479ebc71f65178f75c21"
},
"src/dispute/weth/DelayedWETH.sol": {
"initCodeHash": "0x7b6ec89eaec09e369426e73161a9c6932223bb1f974377190c3f6f552995da35",
......
......@@ -360,6 +360,25 @@
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "_claimIndex",
"type": "uint256"
}
],
"name": "getNumToResolve",
"outputs": [
{
"internalType": "uint256",
"name": "numRemainingChildren_",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
......@@ -474,6 +493,40 @@
"stateMutability": "payable",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"name": "resolutionCheckpoints",
"outputs": [
{
"internalType": "bool",
"name": "initialCheckpointComplete",
"type": "bool"
},
{
"internalType": "uint32",
"name": "subgameIndex",
"type": "uint32"
},
{
"internalType": "Position",
"name": "leftmostPosition",
"type": "uint128"
},
{
"internalType": "address",
"name": "counteredBy",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "resolve",
......@@ -493,6 +546,11 @@
"internalType": "uint256",
"name": "_claimIndex",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "_numToResolve",
"type": "uint256"
}
],
"name": "resolveClaim",
......
......@@ -383,6 +383,25 @@
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "_claimIndex",
"type": "uint256"
}
],
"name": "getNumToResolve",
"outputs": [
{
"internalType": "uint256",
"name": "numRemainingChildren_",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
......@@ -510,6 +529,40 @@
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"name": "resolutionCheckpoints",
"outputs": [
{
"internalType": "bool",
"name": "initialCheckpointComplete",
"type": "bool"
},
{
"internalType": "uint32",
"name": "subgameIndex",
"type": "uint32"
},
{
"internalType": "Position",
"name": "leftmostPosition",
"type": "uint128"
},
{
"internalType": "address",
"name": "counteredBy",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "resolve",
......@@ -529,6 +582,11 @@
"internalType": "uint256",
"name": "_claimIndex",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "_numToResolve",
"type": "uint256"
}
],
"name": "resolveClaim",
......
......@@ -62,11 +62,18 @@
"slot": "5",
"type": "mapping(uint256 => bool)"
},
{
"bytes": "32",
"label": "resolutionCheckpoints",
"offset": 0,
"slot": "6",
"type": "mapping(uint256 => struct IFaultDisputeGame.ResolutionCheckpoint)"
},
{
"bytes": "64",
"label": "startingOutputRoot",
"offset": 0,
"slot": "6",
"slot": "7",
"type": "struct OutputRoot"
}
]
\ No newline at end of file
......@@ -62,11 +62,18 @@
"slot": "5",
"type": "mapping(uint256 => bool)"
},
{
"bytes": "32",
"label": "resolutionCheckpoints",
"offset": 0,
"slot": "6",
"type": "mapping(uint256 => struct IFaultDisputeGame.ResolutionCheckpoint)"
},
{
"bytes": "64",
"label": "startingOutputRoot",
"offset": 0,
"slot": "6",
"slot": "7",
"type": "struct OutputRoot"
}
]
\ No newline at end of file
......@@ -62,8 +62,8 @@ contract FaultDisputeGame is IFaultDisputeGame, Clone, ISemver {
Position internal constant ROOT_POSITION = Position.wrap(1);
/// @notice Semantic version.
/// @custom:semver 0.17.0
string public constant version = "0.17.0";
/// @custom:semver 0.18.0
string public constant version = "0.18.0";
/// @notice The starting timestamp of the game
Timestamp public createdAt;
......@@ -89,9 +89,12 @@ contract FaultDisputeGame is IFaultDisputeGame, Clone, ISemver {
/// @notice A mapping of subgames rooted at a claim index to other claim indices in the subgame.
mapping(uint256 => uint256[]) public subgames;
/// @notice An interneal mapping of resolved subgames rooted at a claim index.
/// @notice A mapping of resolved subgames rooted at a claim index.
mapping(uint256 => bool) public resolvedSubgames;
/// @notice A mapping of claim indices to resolution checkpoints.
mapping(uint256 => ResolutionCheckpoint) public resolutionCheckpoints;
/// @notice The latest finalized output root, serving as the anchor for output bisection.
OutputRoot public startingOutputRoot;
......@@ -436,6 +439,15 @@ contract FaultDisputeGame is IFaultDisputeGame, Clone, ISemver {
}
}
/// @inheritdoc IFaultDisputeGame
function getNumToResolve(uint256 _claimIndex) public view returns (uint256 numRemainingChildren_) {
ResolutionCheckpoint storage checkpoint = resolutionCheckpoints[_claimIndex];
uint256[] storage challengeIndices = subgames[_claimIndex];
uint256 challengeIndicesLen = challengeIndices.length;
numRemainingChildren_ = challengeIndicesLen - checkpoint.subgameIndex;
}
/// @inheritdoc IFaultDisputeGame
function l2BlockNumber() public pure returns (uint256 l2BlockNumber_) {
l2BlockNumber_ = _getArgUint256(0x54);
......@@ -475,7 +487,7 @@ contract FaultDisputeGame is IFaultDisputeGame, Clone, ISemver {
}
/// @inheritdoc IFaultDisputeGame
function resolveClaim(uint256 _claimIndex) external {
function resolveClaim(uint256 _claimIndex, uint256 _numToResolve) external {
// INVARIANT: Resolution cannot occur unless the game is currently in progress.
if (status != GameStatus.IN_PROGRESS) revert GameNotInProgress();
......@@ -507,10 +519,22 @@ contract FaultDisputeGame is IFaultDisputeGame, Clone, ISemver {
return;
}
// Fetch the resolution checkpoint from storage.
ResolutionCheckpoint memory checkpoint = resolutionCheckpoints[_claimIndex];
// If the checkpoint does not currently exist, initialize the current left most position as max u128.
if (!checkpoint.initialCheckpointComplete) {
checkpoint.leftmostPosition = Position.wrap(type(uint128).max);
checkpoint.initialCheckpointComplete = true;
// If `_numToResolve == 0`, assume that we can check all child subgames in this one callframe.
if (_numToResolve == 0) _numToResolve = challengeIndicesLen;
}
// Assume parent is honest until proven otherwise
address countered = address(0);
Position leftmostCounter = Position.wrap(type(uint128).max);
for (uint256 i = 0; i < challengeIndicesLen; ++i) {
uint256 lastToResolve = checkpoint.subgameIndex + _numToResolve;
uint256 finalCursor = lastToResolve > challengeIndicesLen ? challengeIndicesLen : lastToResolve;
for (uint256 i = checkpoint.subgameIndex; i < finalCursor; i++) {
uint256 challengeIndex = challengeIndices[i];
// INVARIANT: Cannot resolve a subgame containing an unresolved claim
......@@ -524,15 +548,23 @@ contract FaultDisputeGame is IFaultDisputeGame, Clone, ISemver {
// from countering invalid subgame roots via an invalid defense position. As such positions
// cannot be correctly countered.
// Note that correctly positioned defense, but invalid claimes can still be successfully countered.
if (claim.counteredBy == address(0) && leftmostCounter.raw() > claim.position.raw()) {
countered = claim.claimant;
leftmostCounter = claim.position;
if (claim.counteredBy == address(0) && checkpoint.leftmostPosition.raw() > claim.position.raw()) {
checkpoint.counteredBy = claim.claimant;
checkpoint.leftmostPosition = claim.position;
}
}
// If the parent was not successfully countered, pay out the parent's bond to the claimant.
// If the parent was successfully countered, pay out the parent's bond to the challenger.
_distributeBond(countered == address(0) ? subgameRootClaim.claimant : countered, subgameRootClaim);
// Increase the checkpoint's cursor position by the number of children that were checked.
checkpoint.subgameIndex = uint32(finalCursor);
// Persist the checkpoint and allow for continuing in a separate transaction, if resolution is not already
// complete.
resolutionCheckpoints[_claimIndex] = checkpoint;
// If all children have been traversed in the above loop, the subgame may be resolved. Otherwise, persist the
// checkpoint and allow for continuation in a separate transaction.
if (checkpoint.subgameIndex == challengeIndicesLen) {
address countered = checkpoint.counteredBy;
// Once a subgame is resolved, we percolate the result up the DAG so subsequent calls to
// resolveClaim will not need to traverse this subgame.
......@@ -540,6 +572,11 @@ contract FaultDisputeGame is IFaultDisputeGame, Clone, ISemver {
// Mark the subgame as resolved.
resolvedSubgames[_claimIndex] = true;
// If the parent was not successfully countered, pay out the parent's bond to the claimant.
// If the parent was successfully countered, pay out the parent's bond to the challenger.
_distributeBond(countered == address(0) ? subgameRootClaim.claimant : countered, subgameRootClaim);
}
}
/// @inheritdoc IDisputeGame
......
......@@ -19,6 +19,14 @@ interface IFaultDisputeGame is IDisputeGame {
Clock clock;
}
/// @notice The `ResolutionCheckpoint` struct represents the data associated with an in-progress claim resolution.
struct ResolutionCheckpoint {
bool initialCheckpointComplete;
uint32 subgameIndex;
Position leftmostPosition;
address counteredBy;
}
/// @notice Emitted when a new claim is added to the DAG by `claimant`
/// @param parentIndex The index within the `claimData` array of the parent claim
/// @param claim The claim being added
......@@ -54,13 +62,24 @@ interface IFaultDisputeGame is IDisputeGame {
/// @param _partOffset The offset of the data to post.
function addLocalData(uint256 _ident, uint256 _execLeafIdx, uint256 _partOffset) external;
/// @notice Resolves the subgame rooted at the given claim index.
/// @notice Resolves the subgame rooted at the given claim index. `_numToResolve` specifies how many children of
/// the subgame will be checked in this call. If `_numToResolve` is less than the number of children, an
/// internal cursor will be updated and this function may be called again to complete resolution of the
/// subgame.
/// @dev This function must be called bottom-up in the DAG
/// A subgame is a tree of claims that has a maximum depth of 1.
/// A subgame root claims is valid if, and only if, all of its child claims are invalid.
/// At the deepest level in the DAG, a claim is invalid if there's a successful step against it.
/// @param _claimIndex The index of the subgame root claim to resolve.
function resolveClaim(uint256 _claimIndex) external;
/// @param _numToResolve The number of subgames to resolve in this call. If the input is `0`, and this is the first
/// page, this function will attempt to check all of the subgame's children at once.
function resolveClaim(uint256 _claimIndex, uint256 _numToResolve) external;
/// @notice Returns the number of children that still need to be resolved in order to fully resolve a subgame rooted
/// at `_claimIndex`.
/// @param _claimIndex The subgame root claim's index within `claimData`.
/// @return numRemainingChildren_ The number of children that still need to be checked to resolve the subgame.
function getNumToResolve(uint256 _claimIndex) external view returns (uint256 numRemainingChildren_);
/// @notice The l2BlockNumber of the disputed output root in the `L2OutputOracle`.
function l2BlockNumber() external view returns (uint256 l2BlockNumber_);
......
......@@ -631,7 +631,7 @@ contract OptimismPortal2_FinalizeWithdrawal_Test is CommonTest {
});
// Warp and resolve the dispute game.
game.resolveClaim(0);
game.resolveClaim(0, 0);
game.resolve();
vm.warp(block.timestamp + optimismPortal2.proofMaturityDelaySeconds() + 1 seconds);
......@@ -659,7 +659,7 @@ contract OptimismPortal2_FinalizeWithdrawal_Test is CommonTest {
});
// Warp and resolve the dispute game.
game.resolveClaim(0);
game.resolveClaim(0, 0);
game.resolve();
vm.warp(block.timestamp + optimismPortal2.proofMaturityDelaySeconds() + 1 seconds);
......@@ -709,7 +709,7 @@ contract OptimismPortal2_FinalizeWithdrawal_Test is CommonTest {
});
// Warp and resolve the original dispute game.
game.resolveClaim(0);
game.resolveClaim(0, 0);
game.resolve();
vm.warp(block.timestamp + optimismPortal2.proofMaturityDelaySeconds() + 1 seconds);
......@@ -836,7 +836,7 @@ contract OptimismPortal2_FinalizeWithdrawal_Test is CommonTest {
});
// Resolve the dispute game.
game.resolveClaim(0);
game.resolveClaim(0, 0);
game.resolve();
vm.warp(block.timestamp + optimismPortal2.proofMaturityDelaySeconds() + 1);
......@@ -860,7 +860,7 @@ contract OptimismPortal2_FinalizeWithdrawal_Test is CommonTest {
});
// Resolve the dispute game.
game.resolveClaim(0);
game.resolveClaim(0, 0);
game.resolve();
vm.warp(block.timestamp + optimismPortal2.proofMaturityDelaySeconds() + 1);
......@@ -908,7 +908,7 @@ contract OptimismPortal2_FinalizeWithdrawal_Test is CommonTest {
});
// Resolve the dispute game.
game.resolveClaim(0);
game.resolveClaim(0, 0);
game.resolve();
vm.warp(block.timestamp + optimismPortal2.proofMaturityDelaySeconds() + 1);
......@@ -950,7 +950,7 @@ contract OptimismPortal2_FinalizeWithdrawal_Test is CommonTest {
optimismPortal2.proveWithdrawalTransaction(_testTx, _proposedGameIndex, outputRootProof, withdrawalProof);
// Resolve the dispute game.
game.resolveClaim(0);
game.resolveClaim(0, 0);
game.resolve();
vm.warp(block.timestamp + optimismPortal2.proofMaturityDelaySeconds() + 1);
......@@ -1025,7 +1025,7 @@ contract OptimismPortal2_FinalizeWithdrawal_Test is CommonTest {
assertTrue(_game.rootClaim().raw() != bytes32(0));
// Resolve the dispute game
game.resolveClaim(0);
game.resolveClaim(0, 0);
game.resolve();
// Warp past the finalization period
......@@ -1049,7 +1049,7 @@ contract OptimismPortal2_FinalizeWithdrawal_Test is CommonTest {
});
// Resolve the dispute game.
game.resolveClaim(0);
game.resolveClaim(0, 0);
game.resolve();
vm.prank(optimismPortal2.guardian());
......@@ -1077,7 +1077,7 @@ contract OptimismPortal2_FinalizeWithdrawal_Test is CommonTest {
vm.warp(block.timestamp + optimismPortal2.proofMaturityDelaySeconds() + 1);
// Resolve the dispute game.
game.resolveClaim(0);
game.resolveClaim(0, 0);
game.resolve();
// Attempt to finalize the withdrawal directly after the game resolves. This should fail.
......@@ -1106,7 +1106,7 @@ contract OptimismPortal2_FinalizeWithdrawal_Test is CommonTest {
vm.warp(block.timestamp + optimismPortal2.proofMaturityDelaySeconds() + 1);
// Resolve the dispute game.
game.resolveClaim(0);
game.resolveClaim(0, 0);
game.resolve();
// Change the respected game type in the portal.
......@@ -1133,7 +1133,7 @@ contract OptimismPortal2_FinalizeWithdrawal_Test is CommonTest {
vm.warp(block.timestamp + optimismPortal2.proofMaturityDelaySeconds() + 1);
// Resolve the dispute game.
game.resolveClaim(0);
game.resolveClaim(0, 0);
game.resolve();
// Change the respected game type in the portal.
......@@ -1172,7 +1172,7 @@ contract OptimismPortal2_FinalizeWithdrawal_Test is CommonTest {
// Finalize the dispute game and attempt to finalize the withdrawal again. This should also fail, since the
// air gap dispute game delay has not elapsed.
game.resolveClaim(0);
game.resolveClaim(0, 0);
game.resolve();
vm.warp(block.timestamp + optimismPortal2.disputeGameFinalityDelaySeconds());
vm.expectRevert("OptimismPortal: output proposal in air-gap");
......
......@@ -536,7 +536,8 @@ contract Specification_Test is CommonTest {
_addSpec({ _name: "FaultDisputeGame", _sel: _getSel("move(uint256,bytes32,bool)") });
_addSpec({ _name: "FaultDisputeGame", _sel: _getSel("proposer()") });
_addSpec({ _name: "FaultDisputeGame", _sel: _getSel("resolve()") });
_addSpec({ _name: "FaultDisputeGame", _sel: _getSel("resolveClaim(uint256)") });
_addSpec({ _name: "FaultDisputeGame", _sel: _getSel("getNumToResolve(uint256)") });
_addSpec({ _name: "FaultDisputeGame", _sel: _getSel("resolveClaim(uint256,uint256)") });
_addSpec({ _name: "FaultDisputeGame", _sel: _getSel("resolvedAt()") });
_addSpec({ _name: "FaultDisputeGame", _sel: _getSel("resolvedSubgames(uint256)") });
_addSpec({ _name: "FaultDisputeGame", _sel: _getSel("rootClaim()") });
......
......@@ -36,7 +36,7 @@ contract FaultDisputeGame_Solvency_Invariant is FaultDisputeGame_Init {
(,,, uint256 rootBond,,,) = gameProxy.claimData(0);
for (uint256 i = gameProxy.claimDataLen(); i > 0; i--) {
(bool success,) = address(gameProxy).call(abi.encodeCall(gameProxy.resolveClaim, (i - 1)));
(bool success,) = address(gameProxy).call(abi.encodeCall(gameProxy.resolveClaim, (i - 1, 0)));
assertTrue(success);
}
gameProxy.resolve();
......
......@@ -128,7 +128,7 @@ contract OptimismPortal2_Invariant_Harness is CommonTest {
// Warp beyond the finalization period for the dispute game and resolve it.
vm.warp(block.timestamp + (game.maxClockDuration().raw() * 2) + 1 seconds);
game.resolveClaim(0);
game.resolveClaim(0, 0);
game.resolve();
// Fund the portal so that we can withdraw ETH.
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment