The goal here is to describe the expected behaviour of a DisputeGame.sol smart contract in a way that facilitates the security analysis found below. This is important to Optimism; it is also important for us, as we plan to write a formal verification of the dispute protocol.
The explicit description also hopefully serves as an implementation guide.
Originally, we believed Arbitrum implemented a different protocol, where "machine state roots" were laid out as the leaves of a Merkle tree in advance. More recently, we realized that Arbitrum implemented the same protocol, with some additional Arbitrum-specific logic. So, this document may also serve as an informal specification of the core logic of Arbitrum's bisection protocol.
Suppose Alice & Bob wish to use Layer 1 to dispute some abstract state transition S -> S', but verifying the transition S -> S' is very expensive. A DisputeGame enables this by breaking S->S' into a sequence of smaller state transitions S_0 -> S_1, S_1 ->S_2, ..., S_{n-1} -> S_n, where
S_0 = SS_n = S'- each
S_i -> S_{i+1}transition is possible to be verified on-chain.
It then "dissects" the sequence of smaller state transitions, coercing Alice or Bob to commit to a transition S_i -> S_{i+1} that can be directly invalidated on-chain.
The DisputeGame contract may be used in multiple places of the overall Optimism dispute protocol; therefore, it will be engineered to support at least the following cases:
- Given an execution trace generated by a single transaction from a starting state root, dispute the validity of the resulting state root by narrowing down to a sequential pair of machine states
M_i, M_{i+1}, and directly validating or invalidating theM_i -> M_{i+1}transition through aSingleStepVerifier. - Given a state root
R, a list of transactions[tx1, tx2, ..., txn], and a final state rootR', construct an array of state rootsrwherer[0] = R, andr[k]is constructed by applyingtx_kto state rootr[k-1]. Dispute the transition fromr[0]tor[n]by pointing to a single (allegedly) invalid transitionr[k] -> r[k+1], and running the dispute game a second time to determine the validity of this transition.
We can imagine some function progress which acts as a "source of truth" that defines a "correct sequence of bytes32 values":
- in case 1, the
SingleStepVerifierdefines a correct sequence of "machine state fingerprints", terminating when the transaction is fully executed. - in case 2, the correct sequence of state root transitions is defined by
- setting
r[0] = R' - initializing some machine state with state root
r[k-1], transactiontx_k, and running theSingleStepVerifieruntil a terminal state is reached with state rootr_final, settingr[k] = r_final
- setting
The following description is restricted in scope to correctness requirements, and ignores liveness requirements, assuming that the sequencer & challenger each act in a timely manner. The specific incentives & timeouts are not considered in this document.
DisputeGame.sol makes the following assumptions about "boundary conditions":
- Alice has a fixed array
aof bytes32 values. - Bob has a fixed array
bof bytes32 values. a[0] == b[0]anda[m] != b[n], wherem = length(a)andn = length(b)m <= n(if this is not true, switch Alice ←→ Bob)
# Alice & Bob's "local views" might be:
a = [0, 1, 2.1, 3, 4, 5.1, 6, 7.1]
b = [0, 1, 2, 3, 4, 5 , 6, 7, 8, 9, 10]
# in this case, m == 8 and n == 11The arrays a and b may or may not be correct, as defined by the relevant "source of truth" progress function. (Assumption 4 implies that at most one of a and b are correct. DisputeGame.sol relies on another component of the rollup to verify assumption 4.)
Alice might "change her mind" during the progress of the game, but this doesn't really matter: we don't care about the values of a that are not revealed on-chain, only the values of a that are revealed. Once values of a are revealed, Alice has committed to them on-chain, and there's no going back.
So, we might as well pretend that a[i] is fixed in advanced to be the value that Alice would reveal if asked to reveal a[i].
The fact that a[0] == b[0] is crucial — it implies that Alice and Bob are starting in the same state.
Alice and Bob take turns revealing a[i] and b[i] for prescribed indices, "dissecting" the two arrays a and b, continuing until either:
- one party forfeits
- there are two sequential values
x[i], x[i+1]revealed (xis eitheraorb) wherex[i] -> x[i+1]can be invalidated on-chain. We call this outcome "proving fraud".
We outline a DisputeGame.sol specification as well as an off-chain protocol with the following property:
- if Alice is correct, then
- either Alice moves last and Bob forfeits
- or Bob moves last, and Alice can prove fraud
- if Bob is correct, then
- either Bob moves last, and Alice forfeits
- or Alice moves last, and Bob can prove fraud
For the rest of this document, division is integer division, rounded towards 0. (All numbers are positive.)
We assume some unspecified value k, the "splitting factor". (This is not specified, because we will need empirical tests to understand eg. the gas cost of various choices of k.)
Define index(i,k,low, high) = low + i * (high - low) / k
Two values, numSteps and values, are provided to the constructor.
valuesis validated to be of lengthk+1numStepsis validated to be at least1.- A storage variable
lastMoveris set to be Alice. (Alice is taking the first move during initialization.) valuesis recorded in storage.- A storage value
consensusIndexis initialized at0. - A storage value
disputedIndexis initialized atnumSteps. - A storage value
statusis set torunning
enum ChallengeStatus {
InProgress,
DisputeFinalized,
Forfeited
}
contract ChallengeManager {
bytes32 root;
uint256 k;
uint256 disputedindex;
uint256 consensusIndex;
string lastMover;
ChallengeStatus status;
constructor(
bytes32[] memory _values,
uint256 _numSteps,
string memory _lastMover,
uint256 _k
) {
state values = _values;
require(_k > 1, 'The splitting factor must be above 1');
require(_numSteps >= 1, 'There must be at least one element');
require(_values.length == _k + 1, 'There must be k+1 values');
disputedindex = _numSteps;
lastMover = _lastMover;
k = _k;
consensusIndex = 0;
status = ChallengeStatus.InProgress;
}
}Offchain protocol: Alice provides "evenly spaced" values of a through the values variable. Explicitly, she provides values = a[index(i,k,0,numSteps)] for i = 0,...,k.
Sample behaviour:
Suppose the (local) views are:
a = [0, 1, 2.1, 3, 4, 5.1, 6, 7.1]
b = [0, 1, 2, 3, 4, 5 , 6, 7, 8, 9, 10]
# Initialization; Alice commits to numSteps = 8, reveals a[0], a[3] and a[7]
# This data is now viewable on L1
a = [ 0, _, _, 3, _, _, _, 7.1 ]
b = [ _, _, _, _, _, _, _, _ ]
lo hi
lo = consensusIndex
hi = disputedIndex
values = [0,3,7.1]
lastMover = AliceAlice and Bob alternate providing values. Since Alice was the last value provider during initialization, it's Bob's move. Bob moves by calling a function split:
split(i: number, newValues: bytes32[], mover)On-chain behaviour:
split has the following behaviour:
- It checks that
newValuesis an array of lengthk - It checks that
i < k - It checks that
newValues[k-1] != values[i+1] - It checks that
disputedIndex - consensusIndex > k - It checks that
moveris one of[Alice, Bob], andmover != lastMover - It sets
consensusIndex = index(i, k, consensusIndex, disputedIndex) - It sets
disputedIndex = index(i+1, k, consensusIndex, disputedIndex) - It sets
values = [values[i], ...newValues] - It sets
lastMover = mover
Off-chain protocol: The current mover
- iterates through
i = 0,...,kto find the lastisuch thatvalues[i] == x[index(i, k, consensusIndex, disputedIndex)], wherexis their "local view". - sets
newConsensusIndex = index(i, k, consensusIndex, disputedIndex) - sets
newDisputedIndex = index(i+1, k, consensusIndex, disputedIndex) - sets
newValues = x[index(j, k, newConsensusIndex, newDisputedIndex) for j = 1,...,k]) - calls
DisputeGame.split(i, newValues)
Sample behaviour:
Suppose the (local) views are:
a = [0, 1, 2.1, 3, 4, 5.1, 6, 7.1]
b = [0, 1, 2, 3, 4, 5 , 6, 7, 8, 9, 10]
# Initialization
a = [ 0, _, _, 3, _, _, _, 7.1 ]
b = [ _, _, _, _, _, _, _, _ ]
lo hi
# Bob agrees with a[3] but disagrees with a[7], and calls split(1, [5,7])
a = [ 0, _, _, 3, _, _, _, 7.1 ]
b = [ _, _, _, 3, _, 5, _, 7 ]
lo hi
# now, the values in storage are
consensusIndex = 3
disputedIndex = 7
values = [3,5,7]
lastMover = Bob
# Alice agrees with b[3] but disagrees with b[5], and calls split(0, [4,5.1])
a = [ 0, _, _, 3, 4, 5.1, _, 7.1 ]
b = [ _, _, _, 3, _, 5, _, 7 ]
lo hi
# now, the values in storage are
consensusIndex = 3
disputedIndex = 7
values = [3,4,5.1]
lastMover = AliceWhen disputedIndex - consensusIndex <= k, no party can call split. Instead, they must finish the game by either "forfeiting" or "pointing fraud".
In this case, Bob knows that 4 -> 5.1 is an incorrect transition
a = [ 0, _, _, 3, 4, 5.1, _, 7.1 ]
b = [ _, _, _, 3, _, 5, _, 7 ]
lo hi
consensusIndex = 3
disputedIndex = 7
values = [3,4,5.1]
lastMover = AliceSo, Bob would finish the game by calling claimFraud(1).
claimFraud(i: number)On-chain behaviour:
claimFraud(i) has the following behaviour:
- it validates that
index(i+1, k, consensusIndex, disputedIndex) = index(i, k, consensusIndex, disputedIndex) + 1 - it sets the game as "terminated" in a "fraud claimed" state.
The exact behaviour is unspecified, but an external contract should be able to read:
- who the
lastMoverwas - that the game terminated in a "fraud claimed" state
An external contract can then use a SingleStepVerifier to prove fraud for use case (1), or launch a second dispute game for use case (2).
Off-chain protocol: If a mover knows they can prove fraud about the values[i] -> values[i+1] transition for some i, they call claimFraud(i).
Suppose the game instead ended up in this state:
a = [ _, _, _, 3, _, 5.1, _, 7.1 ]
b = [ 0, _, _, 3, 4, 5, _, 7 ]
lo hi
consensusIndex = 3
disputedIndex = 5
values = [3,4,5]
lastMover = BobOn-chain behaviour:
In this case, Alice is the next mover, but she knows she won't be able to prove fraud. She should be able to finish the game by calling forfeit.
forfeit();Perhaps Alice should be able to forfeit at any point! Therefore, forfeit should have the following behaviour:
- it sets the game as "terminated" in a "fraud claimed" state
But, to simplify the security analysis, we assume it has the following behaviour:
- it validates that
disputedIndex <= consensusIndex + k - it sets the game as "terminated" in a "fraud claimed" state
Off-chain protocol: If a mover cannot split, and recognizes that they will lose, they call forfeit.
For now, the interface is defined in Typescript, although the interface will later be converted to Solidity.
class ChallengeManager {
public k: number;
public consensusIndex: number = 0;
constructor(
public values: Bytes32[],
public disputedIndex: number, // Set by the first mover
public mover: string
) {}
split(i: number, values: Bytes32[]): void {}
claimFraud(i: number): void;
}We would like to show that given a dispute where Alice and Bob disagree:
- When a player is taking a turn, the game contains a state with which a player agrees and a state with which the player disagrees.
- The game terminates (quickly).
- On termination, the dispute is correctly resolved.
Claim: When the off-chain protocol is followed, the game (almost) maintains the following invariant:
a[consensusIndex] == b[consensusIndex] and a[disputedIndex] != b[disputedIndex]
Proof:
The game is initialized in a state where a[consensusIndex] == b[consensusIndex].
It's possible that a[disputedIndex] == b[disputedIndex] at the start of the game. Ignore this possibility!
forfeit and claimFraud do not alter storage values other than the "termination status".
Consider split(i, newValues) .
When Bob is taking a turn, is it possible Bob is unable to find a value with which Bob agrees?
- On
split, consensus value is updated.values[0]is replaced witholdValues[i] - For the second turn (first
splitcall): Bob must agree with at leastoldValues[0]by definition of the game. - For the third turn or later turns (second or subsequent
splitcalls): On the turn before, Alice setoldValues[0]tooldValues'[i], whereoldValues'were supplied by Bob on his turn. So Bob must agree with at leastoldValues[0] - So the invariant
a[consensusIndex] == b[consensusIndex]is maintained on every turn.
When Bob is taking a turn, is it possible that Bob is unable to find a value with which Bob disagrees?
- On
split, disputed value is updated.values[k]is replaced withnewValues[k-1]. The updated value must differ from a value previously stored (by definition of the game). - For the second turn (first
splitcall: Bob must disagree with at leastoldValues[k]by definition of the game. - For the third turn or later turns (second or subsequent
splitcalls): On the turn before, Alice setoldValues[k]toxwherex != oldValues'[i].oldValues'were supplied by Bob on his turn. So Bob must disagree with at leastoldValues[k]. - The invariant
a[disputedIndex] != b[disputedIndex]is maintained on throughout the game.
Claim: The game terminates in log_k(m) rounds, where m is the total number of states.
Proof:
- The game starts with
consensusIndex == 0anddisputedIndex == m. - For a turn
t,consensusIndexis updated toconsensusIndex = oldConsensusIndex + m*i/k^t - For a turn
t,disputedIndexis updated toconsensusIndex = oldConsensusIndex + m*(i+1)/k^t - So after a turn,
disputedIndex - consensusIndex = m/k^t - The game terminates when
disputedIndex - consensusIndex = m/k^t ≤ k- Solving for
t,log_k(m) ≤ t+1
- Solving for
Claim:
- If Alice was correct
- if Bob was the last mover, then Alice has proven fraud
- if Alice was the last mover, then Bob cannot prove fraud
- If Bob was correct
- if Alice was the last mover, then Bob has proven fraud
- if Bob was the last mover, then Alice cannot prove fraud
Proof:
The game starts with a set of states provided by Alice [a_0, a_s, a_2*s ..., a_(k-1)*s, a_n]. If there is a sequence of valid transitions between a_0 and a_n that includes the intermediate states, then:
- If the game terminates, there are only valid transitions between states, and Alice is the last mover.
- On Bob's turn:
- The states are updated to
[a_i, b_i+s', b_i+2*s', ... b_i+k*s'] - the consensus state is updated to one of Alice's, correct states.
- the disputed state is updated to an incorrect state.
b_i+k*s'must not equal toa_i+k*s' - intermediates states are supplied. The intermediate states are either correct states that follow the consensus state or include an incorrect state.
- If the game terminates, then the states include an invalid transition, and Bob is the last mover.
- The states are updated to
- On Alice's next turn:
- The states are updated to
[b_i', a_i'+s", a_i'+2*s", ... a_i'+k*s"] - Alice sets the consensus state to the last correct, intermediate state that Bob supplied. Bob's state after the consensus state must be invalid. Alice sets the correct, disputed state. Alice sets intermediate states to valid states that lead to the correct, disputed state.
- If the game terminates, there are only valid transitions between states, and Alice is the last mover.
- The states are updated to
- Alice's and Bob's next moves follow the same logic.
The other option is that the game starts with a set of states provided by Alice [a_0, a_s, a_2*s ..., a_(k-1)*s, a_n] where there is no sequence of valid transitions between a_0 and a_n that includes the intermediate states. The proof for this case is very similar to the proof above. The game status is equivalent to Bob's turn in the proof above with Alice and Bob's roles reversed.
Since storage is expensive, DisputeGame.sol should not store values. Instead, the contract should store a commitment to values, such that the next mover can prove to the contract:
values[i] = v_0values[i+1] = v_1
One option is for the contract to construct a merkle tree whose leaves have values taken from values, and storing the root of this tree.
The interface would then look like the following:
enum ChallengeStatus {
InProgress,
FraudDetected,
Forfeited
}
struct WitnessProof {
bytes32 witness;
uint256 index;
bytes32[] nodes;
}
contract DisputeManager {
bytes32 root;
uint256 immutable splitFactor;
uint256 disputedIndex;
uint256 consensusIndex;
string lastMover;
ChallengeStatus public currentStatus;
uint256 public fraudIndex;
constructor(
bytes32[] memory _values,
uint256 _numSteps,
string _lastMover,
uint256 _splitFactor
) {
root = MerkleUtils.generateRoot(_values);
}
function forfeit(Mover _mover) external
function claimFraud(uint256 index, Mover _mover) external
function split(
WitnessProof calldata _consensusProof,
bytes32[] calldata _hashes,
WitnessProof calldata _disputedProof,
Mover _mover
) externalsplit and claimFraud should validate each Witness against the stored merkleRoot, which serves as a proof that values[witness.index] == witness.value.