Commit 5a9c68a2 authored by Adrian Sutton's avatar Adrian Sutton Committed by GitHub

op-challenger: Add actor based tests for game solver (#9637)

Includes rules to confirm that every move performed by the challenger would succeed as a call to contracts
Verifies that the game resolves correctly and the challenger is paid for every move it makes.
parent 85f230ce
package solver
import (
"testing"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/test"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/types"
"github.com/ethereum/go-ethereum/common"
)
type actor interface {
Apply(t *testing.T, game types.Game, correctTrace types.TraceProvider) (types.Game, bool)
}
type actorFn func(t *testing.T, game types.Game, correctTrace types.TraceProvider) (types.Game, bool)
func (a actorFn) Apply(t *testing.T, game types.Game, correctTrace types.TraceProvider) (types.Game, bool) {
return a(t, game, correctTrace)
}
type builderFn func(builder *test.GameBuilder) bool
func (a builderFn) Apply(t *testing.T, game types.Game, correctTrace types.TraceProvider) (types.Game, bool) {
builder := test.NewGameBuilderFromGame(t, correctTrace, game)
done := a(builder)
return builder.Game, done
}
func combineActors(actors ...actor) actor {
return actorFn(func(t *testing.T, game types.Game, correctTrace types.TraceProvider) (types.Game, bool) {
done := true
for _, actor := range actors {
newGame, actorDone := actor.Apply(t, game, correctTrace)
game = newGame
done = done && actorDone
}
return game, done
})
}
var doNothingActor builderFn = func(builder *test.GameBuilder) bool {
return true
}
var correctAttackLastClaim = respondLastClaim(func(seq *test.GameBuilderSeq) {
seq.Attack()
})
var correctDefendLastClaim = respondLastClaim(func(seq *test.GameBuilderSeq) {
if seq.IsRoot() {
// Must attack the root
seq.Attack()
} else {
seq.Defend()
}
})
var incorrectAttackLastClaim = respondLastClaim(func(seq *test.GameBuilderSeq) {
seq.Attack(test.WithValue(common.Hash{0xaa}))
})
var incorrectDefendLastClaim = respondLastClaim(func(seq *test.GameBuilderSeq) {
if seq.IsRoot() {
// Must attack the root
seq.Attack(test.WithValue(common.Hash{0xdd}))
} else {
seq.Defend(test.WithValue(common.Hash{0xdd}))
}
})
var attackEverythingCorrect = respondAllClaims(func(seq *test.GameBuilderSeq) {
seq.Attack()
})
var defendEverythingCorrect = respondAllClaims(func(seq *test.GameBuilderSeq) {
if seq.IsRoot() {
// Must attack root
seq.Attack()
} else {
seq.Defend()
}
})
var attackEverythingIncorrect = respondAllClaims(func(seq *test.GameBuilderSeq) {
seq.Attack(test.WithValue(common.Hash{0xaa}))
})
var defendEverythingIncorrect = respondAllClaims(func(seq *test.GameBuilderSeq) {
if seq.IsRoot() {
// Must attack root
seq.Attack(test.WithValue(common.Hash{0xbb}))
} else {
seq.Defend(test.WithValue(common.Hash{0xbb}))
}
})
var exhaustive = respondAllClaims(func(seq *test.GameBuilderSeq) {
seq.Attack()
seq.Attack(test.WithValue(common.Hash{0xaa}))
if !seq.IsRoot() {
seq.Defend()
seq.Defend(test.WithValue(common.Hash{0xdd}))
}
})
func respondLastClaim(respond func(seq *test.GameBuilderSeq)) builderFn {
return func(builder *test.GameBuilder) bool {
seq := seqFromLastClaim(builder)
if seq.IsMaxDepth() {
// Can't counter the leaf claim
return true
}
respond(seq)
return false
}
}
func respondAllClaims(respond func(seq *test.GameBuilderSeq)) builderFn {
return func(builder *test.GameBuilder) bool {
startingCount := len(builder.Game.Claims())
for _, claim := range builder.Game.Claims() {
if claim.Depth() == builder.Game.MaxDepth() {
continue
}
respond(builder.SeqFrom(claim))
}
finalCount := len(builder.Game.Claims())
return finalCount == startingCount
}
}
func seqFromLastClaim(builder *test.GameBuilder) *test.GameBuilderSeq {
claims := builder.Game.Claims()
claim := claims[len(claims)-1]
return builder.SeqFrom(claim)
}
package solver
import (
"testing"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/types"
gameTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types"
"github.com/ethereum-optimism/optimism/op-dispute-mon/mon/resolution"
"github.com/ethereum-optimism/optimism/op-dispute-mon/mon/transform"
disputeTypes "github.com/ethereum-optimism/optimism/op-dispute-mon/mon/types"
"github.com/ethereum/go-ethereum/common"
"github.com/stretchr/testify/require"
)
func verifyGameRules(t *testing.T, game types.Game, rootClaimCorrect bool) {
actualResult, claimTree, resolvedGame := gameResult(game)
t.Log("Resolved game:")
logClaims(t, resolvedGame)
verifyExpectedGameResult(t, rootClaimCorrect, actualResult)
verifyNoChallengerClaimsWereSuccessfullyCountered(t, resolvedGame)
verifyChallengerAlwaysWinsParentBond(t, resolvedGame)
verifyChallengerNeverCountersAClaimTwice(t, claimTree)
}
// verifyExpectedGameResult verifies that valid output roots are successfully defended and invalid roots are challenged
// Rationale: Ensures the game always and only allows valid output roots to be finalized.
func verifyExpectedGameResult(t *testing.T, rootClaimCorrect bool, actualResult gameTypes.GameStatus) {
expectedResult := gameTypes.GameStatusChallengerWon
if rootClaimCorrect {
expectedResult = gameTypes.GameStatusDefenderWon
}
require.Equalf(t, expectedResult, actualResult, "Game should resolve correctly expected %v but was %v", expectedResult, actualResult)
}
// verifyNoChallengerClaimsWereSuccessfullyCountered verifies the challenger didn't lose any of its bonds
// Note that this also forbids the challenger losing a bond to itself since it shouldn't challenge its own claims
// Rationale: If honest actors lose their bond, it indicates that incentive compatibility is broken because honest actors
// lose money.
func verifyNoChallengerClaimsWereSuccessfullyCountered(t *testing.T, resolvedGame types.Game) {
for _, claim := range resolvedGame.Claims() {
if claim.Claimant != challengerAddr {
continue
}
if claim.CounteredBy != (common.Address{}) {
t.Fatalf("Challenger posted claim %v but it was countered by someone else:\n%v", claim.ContractIndex, printClaim(claim, resolvedGame))
}
}
}
// verifyChallengerAlwaysWinsParentBond verifies that the challenger is always allocated the bond of any parent claim it
// counters.
// Rationale: If an honest action does not win the bond for countering a claim, incentive compatibility is broken because
// honest actors are not being paid to perform their job (or the challenger is posting unnecessary claims)
func verifyChallengerAlwaysWinsParentBond(t *testing.T, resolvedGame types.Game) {
for _, claim := range resolvedGame.Claims() {
if claim.Claimant != challengerAddr {
continue
}
parent, err := resolvedGame.GetParent(claim)
require.NoErrorf(t, err, "Failed to get parent of claim %v", claim.ContractIndex)
require.Equal(t, challengerAddr, parent.CounteredBy,
"Expected claim %v to have challenger as its claimant because of counter claim %v", parent.ContractIndex, claim.ContractIndex)
}
}
// verifyChallengerNeverCountersAClaimTwice verifies that the challenger never posts more than one counter to a claim
// Rationale: The parent claim bond is only intended to cover costs of a single counter claim so incentive compatibility
// is broken if the challenger needs to post multiple claims. Or if the claim wasn't required, the challenger is just
// wasting money posting unnecessary claims.
func verifyChallengerNeverCountersAClaimTwice(t *testing.T, tree *disputeTypes.BidirectionalTree) {
for _, claim := range tree.Claims {
challengerCounterCount := 0
for _, child := range claim.Children {
if child.Claim.Claimant != challengerAddr {
continue
}
challengerCounterCount++
}
require.LessOrEqualf(t, challengerCounterCount, 1, "Found multiple honest counters to claim %v", claim.Claim.ContractIndex)
}
}
func gameResult(game types.Game) (gameTypes.GameStatus, *disputeTypes.BidirectionalTree, types.Game) {
tree := transform.CreateBidirectionalTree(game.Claims())
result := resolution.Resolve(tree)
resolvedClaims := make([]types.Claim, 0, len(tree.Claims))
for _, claim := range tree.Claims {
resolvedClaims = append(resolvedClaims, *claim.Claim)
}
return result, tree, types.NewGameState(resolvedClaims, game.MaxDepth())
}
func logClaims(t *testing.T, game types.Game) {
for _, claim := range game.Claims() {
t.Log(printClaim(claim, game))
}
}
...@@ -10,9 +10,6 @@ import ( ...@@ -10,9 +10,6 @@ import (
faulttest "github.com/ethereum-optimism/optimism/op-challenger/game/fault/test" faulttest "github.com/ethereum-optimism/optimism/op-challenger/game/fault/test"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/trace" "github.com/ethereum-optimism/optimism/op-challenger/game/fault/trace"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/types" "github.com/ethereum-optimism/optimism/op-challenger/game/fault/types"
gameTypes "github.com/ethereum-optimism/optimism/op-challenger/game/types"
"github.com/ethereum-optimism/optimism/op-dispute-mon/mon/resolution"
"github.com/ethereum-optimism/optimism/op-dispute-mon/mon/transform"
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
...@@ -199,31 +196,14 @@ func TestCalculateNextActions(t *testing.T) { ...@@ -199,31 +196,14 @@ func TestCalculateNextActions(t *testing.T) {
for _, test := range tests { for _, test := range tests {
test := test test := test
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
switch test.runCondition { enforceRunConditions(t, test.runCondition)
case RunAlways:
case RunFreeloadersCountered:
if !expectFreeloaderCounters {
t.Skip("Freeloader countering not enabled")
}
case RunFreeloadersNotCountered:
if expectFreeloaderCounters {
t.Skip("Freeloader countering enabled")
}
}
builder := claimBuilder.GameBuilder(faulttest.WithInvalidValue(!test.rootClaimCorrect)) builder := claimBuilder.GameBuilder(faulttest.WithInvalidValue(!test.rootClaimCorrect))
test.setupGame(builder) test.setupGame(builder)
game := builder.Game game := builder.Game
logClaims(t, game) logClaims(t, game)
solver := NewGameSolver(maxDepth, trace.NewSimpleTraceAccessor(claimBuilder.CorrectTraceProvider())) solver := NewGameSolver(maxDepth, trace.NewSimpleTraceAccessor(claimBuilder.CorrectTraceProvider()))
actions, err := solver.CalculateNextActions(context.Background(), game) postState, actions := runStep(t, solver, game, claimBuilder.CorrectTraceProvider())
require.NoError(t, err)
for i, action := range actions {
t.Logf("Move %v: Type: %v, ParentIdx: %v, Attack: %v, Value: %v, PreState: %v, ProofData: %v",
i, action.Type, action.ParentIdx, action.IsAttack, action.Value, hex.EncodeToString(action.PreState), hex.EncodeToString(action.ProofData))
// Check that every move the solver returns meets the generic validation rules
require.NoError(t, checkRules(game, action), "Attempting to perform invalid action")
}
for i, action := range builder.ExpectedActions { for i, action := range builder.ExpectedActions {
t.Logf("Expect %v: Type: %v, ParentIdx: %v, Attack: %v, Value: %v, PreState: %v, ProofData: %v", t.Logf("Expect %v: Type: %v, ParentIdx: %v, Attack: %v, Value: %v, PreState: %v, ProofData: %v",
i, action.Type, action.ParentIdx, action.IsAttack, action.Value, hex.EncodeToString(action.PreState), hex.EncodeToString(action.ProofData)) i, action.Type, action.ParentIdx, action.IsAttack, action.Value, hex.EncodeToString(action.PreState), hex.EncodeToString(action.ProofData))
...@@ -231,24 +211,126 @@ func TestCalculateNextActions(t *testing.T) { ...@@ -231,24 +211,126 @@ func TestCalculateNextActions(t *testing.T) {
} }
require.Len(t, actions, len(builder.ExpectedActions), "Incorrect number of actions") require.Len(t, actions, len(builder.ExpectedActions), "Incorrect number of actions")
challengerAddr := common.Address{0xaa, 0xbb, 0xcc, 0xdd} verifyGameRules(t, postState, test.rootClaimCorrect)
postState := applyActions(game, challengerAddr, actions)
t.Log("Post game state:")
logClaims(t, postState)
actualResult := gameResult(postState)
expectedResult := gameTypes.GameStatusChallengerWon
if test.rootClaimCorrect {
expectedResult = gameTypes.GameStatusDefenderWon
}
require.Equalf(t, expectedResult, actualResult, "Game should resolve correctly expected %v but was %v", expectedResult, actualResult)
}) })
} }
} }
func logClaims(t *testing.T, game types.Game) { func runStep(t *testing.T, solver *GameSolver, game types.Game, correctTraceProvider types.TraceProvider) (types.Game, []types.Action) {
for i, claim := range game.Claims() { actions, err := solver.CalculateNextActions(context.Background(), game)
t.Logf("Claim %v: Pos: %v TraceIdx: %v Depth: %v IndexAtDepth: %v ParentIdx: %v Value: %v Claimant: %v CounteredBy: %v", require.NoError(t, err)
i, claim.Position.ToGIndex(), claim.Position.TraceIndex(game.MaxDepth()), claim.Position.Depth(), claim.Position.IndexAtDepth(), claim.ParentContractIndex, claim.Value, claim.Claimant, claim.CounteredBy)
postState := applyActions(game, challengerAddr, actions)
t.Log("Post state:")
logClaims(t, postState)
for i, action := range actions {
t.Logf("Move %v: Type: %v, ParentIdx: %v, Attack: %v, Value: %v, PreState: %v, ProofData: %v",
i, action.Type, action.ParentIdx, action.IsAttack, action.Value, hex.EncodeToString(action.PreState), hex.EncodeToString(action.ProofData))
// Check that every move the solver returns meets the generic validation rules
require.NoError(t, checkRules(game, action, correctTraceProvider), "Attempting to perform invalid action")
}
return postState, actions
}
func TestMultipleRounds(t *testing.T) {
t.Parallel()
tests := []struct {
name string
actor actor
runCondition RunCondition
}{
{
name: "SingleRoot",
actor: doNothingActor,
},
{
name: "LinearAttackCorrect",
actor: correctAttackLastClaim,
},
{
name: "LinearDefendCorrect",
actor: correctDefendLastClaim,
},
{
name: "LinearAttackIncorrect",
actor: incorrectAttackLastClaim,
},
{
name: "LinearDefendInorrect",
actor: incorrectDefendLastClaim,
},
{
name: "LinearDefendIncorrectDefendCorrect",
actor: combineActors(incorrectDefendLastClaim, correctDefendLastClaim),
},
{
name: "LinearAttackIncorrectDefendCorrect",
actor: combineActors(incorrectAttackLastClaim, correctDefendLastClaim),
},
{
name: "LinearDefendIncorrectDefendIncorrect",
actor: combineActors(incorrectDefendLastClaim, incorrectDefendLastClaim),
},
{
name: "LinearAttackIncorrectDefendIncorrect",
actor: combineActors(incorrectAttackLastClaim, incorrectDefendLastClaim),
},
{
name: "AttackEverythingCorrect",
actor: attackEverythingCorrect,
runCondition: RunFreeloadersCountered,
},
{
name: "DefendEverythingCorrect",
actor: defendEverythingCorrect,
},
{
name: "AttackEverythingIncorrect",
actor: attackEverythingIncorrect,
},
{
name: "DefendEverythingIncorrect",
actor: defendEverythingIncorrect,
},
{
name: "Exhaustive",
actor: exhaustive,
// TODO(client-pod#611): We attempt to step even though the prestate is invalid
// The step call would fail to estimate gas so not even send, but the challenger shouldn't try
runCondition: RunFreeloadersCountered,
},
}
for _, test := range tests {
test := test
for _, rootClaimCorrect := range []bool{true, false} {
rootClaimCorrect := rootClaimCorrect
t.Run(fmt.Sprintf("%v-%v", test.name, rootClaimCorrect), func(t *testing.T) {
t.Parallel()
enforceRunConditions(t, test.runCondition)
maxDepth := types.Depth(6)
startingL2BlockNumber := big.NewInt(50)
claimBuilder := faulttest.NewAlphabetClaimBuilder(t, startingL2BlockNumber, maxDepth)
builder := claimBuilder.GameBuilder(faulttest.WithInvalidValue(!rootClaimCorrect))
game := builder.Game
logClaims(t, game)
correctTrace := claimBuilder.CorrectTraceProvider()
solver := NewGameSolver(maxDepth, trace.NewSimpleTraceAccessor(correctTrace))
roundNum := 0
done := false
for !done {
t.Logf("------ ROUND %v ------", roundNum)
game, _ = runStep(t, solver, game, correctTrace)
verifyGameRules(t, game, rootClaimCorrect)
game, done = test.actor.Apply(t, game, correctTrace)
roundNum++
}
})
}
} }
} }
...@@ -284,7 +366,16 @@ func applyActions(game types.Game, claimant common.Address, actions []types.Acti ...@@ -284,7 +366,16 @@ func applyActions(game types.Game, claimant common.Address, actions []types.Acti
return types.NewGameState(claims, game.MaxDepth()) return types.NewGameState(claims, game.MaxDepth())
} }
func gameResult(game types.Game) gameTypes.GameStatus { func enforceRunConditions(t *testing.T, runCondition RunCondition) {
tree := transform.CreateBidirectionalTree(game.Claims()) switch runCondition {
return resolution.Resolve(tree) case RunAlways:
case RunFreeloadersCountered:
if !expectFreeloaderCounters {
t.Skip("Freeloader countering not enabled")
}
case RunFreeloadersNotCountered:
if expectFreeloaderCounters {
t.Skip("Freeloader countering enabled")
}
}
} }
package solver package solver
import ( import (
"bytes"
"context"
"errors" "errors"
"fmt" "fmt"
"math/big"
"slices"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/types" "github.com/ethereum-optimism/optimism/op-challenger/game/fault/types"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
) )
type actionRule func(game types.Game, action types.Action) error var challengerAddr = common.Address(bytes.Repeat([]byte{0xaa}, 20))
type actionRule func(game types.Game, action types.Action, correctTrace types.TraceProvider) error
var rules = []actionRule{ var rules = []actionRule{
parentMustExist, parentMustExist,
onlyStepAtMaxDepth, onlyStepAtMaxDepth,
onlyMoveBeforeMaxDepth, onlyMoveBeforeMaxDepth,
doNotDuplicateExistingMoves, doNotDuplicateExistingMoves,
doNotStepAlreadyCounteredClaims,
doNotDefendRootClaim, doNotDefendRootClaim,
avoidPoisonedPrestate,
detectPoisonedStepPrestate,
detectFailedStep,
doNotCounterSelf,
}
func printClaim(claim types.Claim, game types.Game) string {
return fmt.Sprintf("Claim %v: Pos: %v TraceIdx: %v Depth: %v IndexAtDepth: %v ParentIdx: %v Value: %v Claimant: %v CounteredBy: %v",
claim.ContractIndex, claim.Position.ToGIndex(), claim.Position.TraceIndex(game.MaxDepth()), claim.Position.Depth(), claim.Position.IndexAtDepth(), claim.ParentContractIndex, claim.Value, claim.Claimant, claim.CounteredBy)
} }
func checkRules(game types.Game, action types.Action) error { func checkRules(game types.Game, action types.Action, correctTrace types.TraceProvider) error {
var errs []error var errs []error
for _, rule := range rules { for _, rule := range rules {
errs = append(errs, rule(game, action)) errs = append(errs, rule(game, action, correctTrace))
} }
return errors.Join(errs...) return errors.Join(errs...)
} }
func parentMustExist(game types.Game, action types.Action) error { // parentMustExist checks that every action performed has a valid parent claim
// Rationale: The action would be rejected by the contracts
func parentMustExist(game types.Game, action types.Action, _ types.TraceProvider) error {
if len(game.Claims()) <= action.ParentIdx || action.ParentIdx < 0 { if len(game.Claims()) <= action.ParentIdx || action.ParentIdx < 0 {
return fmt.Errorf("parent claim %v does not exist in game with %v claims", action.ParentIdx, len(game.Claims())) return fmt.Errorf("parent claim %v does not exist in game with %v claims", action.ParentIdx, len(game.Claims()))
} }
return nil return nil
} }
func onlyStepAtMaxDepth(game types.Game, action types.Action) error { // onlyStepAtMaxDepth verifies that step actions are only performed against leaf claims
// Rationale: The action would be rejected by the contracts
func onlyStepAtMaxDepth(game types.Game, action types.Action, _ types.TraceProvider) error {
if action.Type == types.ActionTypeStep { if action.Type == types.ActionTypeStep {
return nil return nil
} }
...@@ -44,7 +66,9 @@ func onlyStepAtMaxDepth(game types.Game, action types.Action) error { ...@@ -44,7 +66,9 @@ func onlyStepAtMaxDepth(game types.Game, action types.Action) error {
return nil return nil
} }
func onlyMoveBeforeMaxDepth(game types.Game, action types.Action) error { // onlyMoveBeforeMaxDepth verifies that move actions are not performed against leaf claims
// Rationale: The action would be rejected by the contracts
func onlyMoveBeforeMaxDepth(game types.Game, action types.Action, _ types.TraceProvider) error {
if action.Type == types.ActionTypeMove { if action.Type == types.ActionTypeMove {
return nil return nil
} }
...@@ -56,7 +80,9 @@ func onlyMoveBeforeMaxDepth(game types.Game, action types.Action) error { ...@@ -56,7 +80,9 @@ func onlyMoveBeforeMaxDepth(game types.Game, action types.Action) error {
return nil return nil
} }
func doNotDuplicateExistingMoves(game types.Game, action types.Action) error { // doNotDuplicateExistingMoves verifies that the challenger doesn't attempt to post a duplicate claim
// Rationale: The action would be rejected by the contracts
func doNotDuplicateExistingMoves(game types.Game, action types.Action, _ types.TraceProvider) error {
newClaimData := types.ClaimData{ newClaimData := types.ClaimData{
Value: action.Value, Value: action.Value,
Position: resultingPosition(game, action), Position: resultingPosition(game, action),
...@@ -67,13 +93,180 @@ func doNotDuplicateExistingMoves(game types.Game, action types.Action) error { ...@@ -67,13 +93,180 @@ func doNotDuplicateExistingMoves(game types.Game, action types.Action) error {
return nil return nil
} }
func doNotDefendRootClaim(game types.Game, action types.Action) error { // doNotStepAlreadyCounteredClaims checks the challenger does not attempt to call step on already countered claims
// Rationale: The step call is redundant and a waste of gas
func doNotStepAlreadyCounteredClaims(game types.Game, action types.Action, _ types.TraceProvider) error {
claim := game.Claims()[action.ParentIdx]
if claim.CounteredBy != (common.Address{}) {
return fmt.Errorf("attempting to step already countered claim: %v", claim.ContractIndex)
}
return nil
}
// doNotDefendRootClaim checks the challenger doesn't attempt to defend the root claim
// Rationale: The action would be rejected by the contracts
func doNotDefendRootClaim(game types.Game, action types.Action, _ types.TraceProvider) error {
if game.Claims()[action.ParentIdx].IsRootPosition() && !action.IsAttack { if game.Claims()[action.ParentIdx].IsRootPosition() && !action.IsAttack {
return fmt.Errorf("defending the root claim at idx %v", action.ParentIdx) return fmt.Errorf("defending the root claim at idx %v", action.ParentIdx)
} }
return nil return nil
} }
// doNotCounterSelf checks the challenger doesn't counter its own claims
// Rationale: The challenger should not disagree with itself
func doNotCounterSelf(game types.Game, action types.Action, _ types.TraceProvider) error {
claim := game.Claims()[action.ParentIdx]
if claim.Claimant == challengerAddr {
return fmt.Errorf("countering own claim at idx %v", action.ParentIdx)
}
return nil
}
// avoidPoisonedPrestate checks the challenger does not perform a move that results in a claim where the ancestor
// with the largest trace index less than the new claim's trace index is invalid.
// Rationale: If such a claim were posted, an attacker could attack with invalid values down to max depth and setup a
// step call which uses the invalid claim as the pre-state. The challenger could not call step because it does not have
// the preimage of the invalid state. If the attacker should call step, they could provide a carefully crafted state
// that allows it to successfully step against the challenger's claim.
func avoidPoisonedPrestate(game types.Game, action types.Action, correctTrace types.TraceProvider) error {
if action.Type == types.ActionTypeStep {
return nil
}
ancestors := ""
movePosition := resultingPosition(game, action)
honestTraceIndex := movePosition.TraceIndex(game.MaxDepth())
// Walk back up the claims and find the claim with highest trace index < honestTraceIndex
claim := game.Claims()[action.ParentIdx]
var preStateClaim types.Claim
for {
ancestors += printClaim(claim, game) + "\n"
claimTraceIdx := claim.TraceIndex(game.MaxDepth())
if claimTraceIdx.Cmp(honestTraceIndex) < 0 { // Check it's left of the honest claim
if preStateClaim == (types.Claim{}) || claimTraceIdx.Cmp(preStateClaim.TraceIndex(game.MaxDepth())) > 0 {
preStateClaim = claim
}
}
if claim.IsRoot() {
break
}
parent, err := game.GetParent(claim)
if err != nil {
return fmt.Errorf("no parent of claim %v: %w", claim.ContractIndex, err)
}
claim = parent
}
if preStateClaim == (types.Claim{}) {
// No claim to the left of the honest claim, so can't have been poisoned
return nil
}
correctValue, err := correctTrace.Get(context.Background(), preStateClaim.Position)
if err != nil {
return fmt.Errorf("failed to get correct trace at position %v: %w", preStateClaim.Position, err)
}
if correctValue != preStateClaim.Value {
err = fmt.Errorf("prestate poisoned claim %v has invalid prestate and is left of honest claim countering %v at trace index %v", preStateClaim.ContractIndex, action.ParentIdx, honestTraceIndex)
return err
}
return nil
}
// detectFailedStep checks that step actions will succeed.
// Rationale: The action would be rejected by the contracts
//
// INVARIANT: If a step is an attack, the poststate is valid if the step produces
//
// the same poststate hash as the parent claim's value.
// If a step is a defense:
// 1. If the parent claim and the found post state agree with each other
// (depth diff % 2 == 0), the step is valid if it produces the same
// state hash as the post state's claim.
// 2. If the parent claim and the found post state disagree with each other
// (depth diff % 2 != 0), the parent cannot be countered unless the step
// produces the same state hash as `postState.claim`.
func detectFailedStep(game types.Game, action types.Action, correctTrace types.TraceProvider) error {
if action.Type != types.ActionTypeStep {
// An invalid post state is not an issue if we are moving, only if the honest challenger has to call step.
return nil
}
position := resultingPosition(game, action)
if position.Depth() != game.MaxDepth() {
// Not at max depth yet
return nil
}
honestTraceIndex := position.TraceIndex(game.MaxDepth())
poststateIndex := honestTraceIndex
if !action.IsAttack {
poststateIndex = new(big.Int).Add(honestTraceIndex, big.NewInt(1))
}
// Walk back up the claims and find the claim required post state index
claim := game.Claims()[action.ParentIdx]
poststateClaim, ok := game.AncestorWithTraceIndex(claim, poststateIndex)
if !ok {
return fmt.Errorf("did not find required poststate at %v to counter claim %v", poststateIndex, action.ParentIdx)
}
correctValue, err := correctTrace.Get(context.Background(), poststateClaim.Position)
if err != nil {
return fmt.Errorf("failed to get correct trace at position %v: %w", poststateClaim.Position, err)
}
validStep := correctValue == poststateClaim.Value
parentPostAgree := (claim.Depth()-poststateClaim.Depth())%2 == 0
if parentPostAgree == validStep {
return fmt.Errorf("failed step against claim at %v using poststate from claim %v post state is correct? %v parentPostAgree? %v",
action.ParentIdx, poststateClaim.ContractIndex, validStep, parentPostAgree)
}
return nil
}
// detectPoisonedStepPrestate checks that:
// 1. step actions performed by the challenger always have a valid prestate
// 2. move actions that create a claim a max depth would have a valid prestate if they are attacked
// 3. the actual prestate provided matches the prestate claim's commitment
// Rationale: A step against an invalid prestate will fail because the preimage of the prestate claim is unknown
// and claims at max depth with an invalid prestate could be stepped against because the prestate is invalid so a VM
// step will not result in the correct post-state.
func detectPoisonedStepPrestate(game types.Game, action types.Action, correctTrace types.TraceProvider) error {
position := resultingPosition(game, action)
if position.Depth() != game.MaxDepth() {
// Not at max depth yet
return nil
}
honestTraceIndex := position.TraceIndex(game.MaxDepth())
prestateIndex := honestTraceIndex
// If we're performing a move to post a leaf claim, assume the attacker will try to attack it from their
// poisoned prestate
if action.IsAttack || action.Type == types.ActionTypeMove {
prestateIndex = new(big.Int).Sub(prestateIndex, big.NewInt(1))
}
if prestateIndex.Cmp(big.NewInt(0)) < 0 {
// Absolute prestate is not poisoned
return nil
}
// Walk back up the claims and find the claim with highest trace index < honestTraceIndex
claim := game.Claims()[action.ParentIdx]
preStateClaim, ok := game.AncestorWithTraceIndex(claim, prestateIndex)
if !ok {
return fmt.Errorf("performing step against claim %v with no prestate available at %v", claim.ContractIndex, prestateIndex)
}
correctValue, err := correctTrace.Get(context.Background(), preStateClaim.Position)
if err != nil {
return fmt.Errorf("failed to get correct trace at position %v: %w", preStateClaim.Position, err)
}
if correctValue != preStateClaim.Value {
if action.Type == types.ActionTypeStep {
return fmt.Errorf("stepping from poisoned prestate at claim %v when countering %v", preStateClaim.ContractIndex, action.ParentIdx)
} else {
return fmt.Errorf("posting leaf claim with poisoned prestate from claim %v when countering %v", preStateClaim.ContractIndex, action.ParentIdx)
}
}
if action.Type == types.ActionTypeStep {
prestateHash := crypto.Keccak256Hash(action.PreState)
if !slices.Equal(prestateHash[1:], preStateClaim.Value[1:]) {
return fmt.Errorf("prestate hash %v does not match expected prestate claim %v from claim %v", prestateHash, preStateClaim.Value, preStateClaim.ContractIndex)
}
}
return nil
}
func resultingPosition(game types.Game, action types.Action) types.Position { func resultingPosition(game types.Game, action types.Action) types.Position {
parentPos := game.Claims()[action.ParentIdx].Position parentPos := game.Claims()[action.ParentIdx].Position
if action.Type == types.ActionTypeStep { if action.Type == types.ActionTypeStep {
......
...@@ -10,7 +10,7 @@ import ( ...@@ -10,7 +10,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
var DefaultClaimant = common.Address{0x09, 0x23, 0x34, 0x45, 0x13, 0xb3} var DefaultClaimant = common.Address{0xba, 0xdb, 0xad, 0xba, 0xdb, 0xad}
type claimCfg struct { type claimCfg struct {
value common.Hash value common.Hash
......
...@@ -2,6 +2,7 @@ package test ...@@ -2,6 +2,7 @@ package test
import ( import (
"math/big" "math/big"
"testing"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/types" "github.com/ethereum-optimism/optimism/op-challenger/game/fault/types"
"github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common"
...@@ -13,6 +14,14 @@ type GameBuilder struct { ...@@ -13,6 +14,14 @@ type GameBuilder struct {
ExpectedActions []types.Action ExpectedActions []types.Action
} }
func NewGameBuilderFromGame(t *testing.T, provider types.TraceProvider, game types.Game) *GameBuilder {
claimBuilder := NewClaimBuilder(t, game.MaxDepth(), provider)
return &GameBuilder{
builder: claimBuilder,
Game: types.NewGameState(game.Claims(), game.MaxDepth()),
}
}
func (c *ClaimBuilder) GameBuilder(rootOpts ...ClaimOpt) *GameBuilder { func (c *ClaimBuilder) GameBuilder(rootOpts ...ClaimOpt) *GameBuilder {
return &GameBuilder{ return &GameBuilder{
builder: c, builder: c,
...@@ -38,9 +47,21 @@ func (g *GameBuilder) SeqFrom(claim types.Claim) *GameBuilderSeq { ...@@ -38,9 +47,21 @@ func (g *GameBuilder) SeqFrom(claim types.Claim) *GameBuilderSeq {
} }
} }
func (g *GameBuilderSeq) IsMaxDepth() bool {
return g.lastClaim.Depth() == g.gameBuilder.Game.MaxDepth()
}
func (g *GameBuilderSeq) IsRoot() bool {
return g.lastClaim.IsRoot()
}
// addClaimToGame replaces the game being built with a new instance that has claim as the latest claim. // addClaimToGame replaces the game being built with a new instance that has claim as the latest claim.
// The ContractIndex in claim is updated with its position in the game's claim array. // The ContractIndex in claim is updated with its position in the game's claim array.
// Does nothing if the claim already exists
func (s *GameBuilderSeq) addClaimToGame(claim *types.Claim) { func (s *GameBuilderSeq) addClaimToGame(claim *types.Claim) {
if s.gameBuilder.Game.IsDuplicate(*claim) {
return
}
claim.ContractIndex = len(s.gameBuilder.Game.Claims()) claim.ContractIndex = len(s.gameBuilder.Game.Claims())
claims := append(s.gameBuilder.Game.Claims(), *claim) claims := append(s.gameBuilder.Game.Claims(), *claim)
s.gameBuilder.Game = types.NewGameState(claims, s.builder.maxDepth) s.gameBuilder.Game = types.NewGameState(claims, s.builder.maxDepth)
......
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