Skip to content

Instantly share code, notes, and snippets.

@ajsutton
Last active August 14, 2025 05:28
Show Gist options
  • Select an option

  • Save ajsutton/47f872678bbff705a69b1f6276d4e5ab to your computer and use it in GitHub Desktop.

Select an option

Save ajsutton/47f872678bbff705a69b1f6276d4e5ab to your computer and use it in GitHub Desktop.
package fault
import (
"context"
"errors"
"math/big"
"slices"
"sync"
"testing"
"time"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/trace"
"github.com/ethereum-optimism/optimism/op-service/clock"
"github.com/ethereum-optimism/optimism/op-service/sources/batching/rpcblock"
"github.com/stretchr/testify/require"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/log"
faulttest "github.com/ethereum-optimism/optimism/op-challenger/game/fault/test"
"github.com/ethereum-optimism/optimism/op-challenger/game/fault/trace/alphabet"
"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-challenger/metrics"
"github.com/ethereum-optimism/optimism/op-service/testlog"
)
var l1Time = time.UnixMilli(100)
// newStubClaimLoaderWithDefaults creates a stubClaimLoader with sensible defaults
// for basic delay tests (prevents clock extension from triggering)
func newStubClaimLoaderWithDefaults() *stubClaimLoader {
return &stubClaimLoader{
// A large clock extension value used to prevent clock
// extension from triggering during basic delay tests
clockExtension: 1 * time.Hour,
}
}
func TestDoNotMakeMovesWhenGameIsResolvable(t *testing.T) {
ctx := context.Background()
tests := []struct {
name string
callResolveStatus gameTypes.GameStatus
}{
{
name: "DefenderWon",
callResolveStatus: gameTypes.GameStatusDefenderWon,
},
{
name: "ChallengerWon",
callResolveStatus: gameTypes.GameStatusChallengerWon,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
agent, claimLoader, responder := setupTestAgent(t)
responder.callResolveStatus = test.callResolveStatus
require.NoError(t, agent.Act(ctx))
require.Equal(t, 1, responder.callResolveCount, "should check if game is resolvable")
require.Equal(t, 1, claimLoader.callCount, "should fetch claims once for resolveClaim")
require.EqualValues(t, 1, responder.resolveCount, "should resolve winning game")
})
}
}
func TestDoNotMakeMovesWhenL2BlockNumberChallenged(t *testing.T) {
ctx := context.Background()
agent, claimLoader, responder := setupTestAgent(t)
claimLoader.blockNumChallenged = true
require.NoError(t, agent.Act(ctx))
require.Equal(t, 1, responder.callResolveCount, "should check if game is resolvable")
require.Equal(t, 1, claimLoader.callCount, "should fetch claims only once for resolveClaim")
}
func createClaimsWithClaimants(t *testing.T, d types.Depth) []types.Claim {
claimBuilder := faulttest.NewClaimBuilder(t, d, alphabet.NewTraceProvider(big.NewInt(0), d))
rootClaim := claimBuilder.CreateRootClaim()
claim1 := rootClaim
claim1.Claimant = common.BigToAddress(big.NewInt(1))
claim2 := claimBuilder.AttackClaim(claim1)
claim2.Claimant = common.BigToAddress(big.NewInt(2))
claim3 := claimBuilder.AttackClaim(claim2)
claim3.Claimant = common.BigToAddress(big.NewInt(3))
return []types.Claim{claim1, claim2, claim3}
}
func TestAgent_SelectiveClaimResolution(t *testing.T) {
ctx := context.Background()
tests := []struct {
name string
callResolveStatus gameTypes.GameStatus
selective bool
claimants []common.Address
claims []types.Claim
expectedResolveCount int
}{
{
name: "NonSelectiveEmptyClaimants",
callResolveStatus: gameTypes.GameStatusDefenderWon,
selective: false,
claimants: []common.Address{},
claims: createClaimsWithClaimants(t, types.Depth(4)),
expectedResolveCount: 3,
},
{
name: "NonSelectiveWithClaimants",
callResolveStatus: gameTypes.GameStatusDefenderWon,
selective: false,
claimants: []common.Address{common.BigToAddress(big.NewInt(1))},
claims: createClaimsWithClaimants(t, types.Depth(4)),
expectedResolveCount: 3,
},
{
name: "SelectiveEmptyClaimants",
callResolveStatus: gameTypes.GameStatusDefenderWon,
selective: true,
claimants: []common.Address{},
claims: createClaimsWithClaimants(t, types.Depth(4)),
},
{
name: "SelectiveWithClaimants",
callResolveStatus: gameTypes.GameStatusDefenderWon,
selective: true,
claimants: []common.Address{common.BigToAddress(big.NewInt(1))},
claims: createClaimsWithClaimants(t, types.Depth(4)),
expectedResolveCount: 1,
},
}
for _, tCase := range tests {
tCase := tCase
t.Run(tCase.name, func(t *testing.T) {
agent, claimLoader, responder := setupTestAgent(t)
agent.selective = tCase.selective
agent.claimants = tCase.claimants
claimLoader.maxLoads = 1
if tCase.selective {
claimLoader.maxLoads = 0
}
claimLoader.claims = tCase.claims
responder.callResolveStatus = tCase.callResolveStatus
require.NoError(t, agent.Act(ctx))
require.Equal(t, tCase.expectedResolveCount, responder.callResolveClaimCount, "should check if game is resolvable")
require.Equal(t, tCase.expectedResolveCount, responder.resolveClaimCount, "should check if game is resolvable")
if tCase.selective {
require.Equal(t, 0, responder.callResolveCount, "should not resolve game in selective mode")
require.Equal(t, 0, responder.resolveCount, "should not resolve game in selective mode")
}
})
}
}
func TestSkipAttemptingToResolveClaimsWhenClockNotExpired(t *testing.T) {
agent, claimLoader, responder := setupTestAgent(t)
responder.callResolveErr = errors.New("game is not resolvable")
responder.callResolveClaimErr = errors.New("claim is not resolvable")
depth := types.Depth(4)
claimBuilder := faulttest.NewClaimBuilder(t, depth, alphabet.NewTraceProvider(big.NewInt(0), depth))
rootTime := l1Time.Add(-agent.maxClockDuration - 5*time.Minute)
gameBuilder := claimBuilder.GameBuilder(faulttest.WithClock(rootTime, 0))
gameBuilder.Seq().
Attack(faulttest.WithClock(rootTime.Add(5*time.Minute), 5*time.Minute)).
Defend(faulttest.WithClock(rootTime.Add(7*time.Minute), 2*time.Minute)).
Attack(faulttest.WithClock(rootTime.Add(11*time.Minute), 4*time.Minute))
claimLoader.claims = gameBuilder.Game.Claims()
require.NoError(t, agent.Act(context.Background()))
// Currently tries to resolve the first two claims because their clock's have expired, but doesn't detect that
// they have unresolvable children.
require.Equal(t, 2, responder.callResolveClaimCount)
}
func TestLoadClaimsWhenGameNotResolvable(t *testing.T) {
// Checks that if the game isn't resolvable, that the agent continues on to start checking claims
agent, claimLoader, responder := setupTestAgent(t)
responder.callResolveErr = errors.New("game is not resolvable")
responder.callResolveClaimErr = errors.New("claim is not resolvable")
depth := types.Depth(4)
claimBuilder := faulttest.NewClaimBuilder(t, depth, alphabet.NewTraceProvider(big.NewInt(0), depth))
claimLoader.claims = []types.Claim{
claimBuilder.CreateRootClaim(),
}
require.NoError(t, agent.Act(context.Background()))
require.EqualValues(t, 2, claimLoader.callCount, "should load claims for unresolvable game")
require.EqualValues(t, responder.callResolveClaimCount, 1, "should check if claim is resolvable")
require.Zero(t, responder.resolveClaimCount, "should not send resolveClaim")
}
func setupTestAgent(t *testing.T) (*Agent, *stubClaimLoader, *stubResponder) {
logger := testlog.Logger(t, log.LevelInfo)
claimLoader := &stubClaimLoader{}
depth := types.Depth(4)
gameDuration := 24 * time.Hour
provider := alphabet.NewTraceProvider(big.NewInt(0), depth)
responder := &stubResponder{}
systemClock := clock.NewDeterministicClock(time.UnixMilli(120200))
l1Clock := clock.NewDeterministicClock(l1Time)
agent := NewAgent(metrics.NoopMetrics, systemClock, l1Clock, claimLoader, depth, gameDuration, trace.NewSimpleTraceAccessor(provider), responder, logger, false, []common.Address{}, 0, 0)
return agent, claimLoader, responder
}
type stubClaimLoader struct {
callCount int
maxLoads int
claims []types.Claim
blockNumChallenged bool
clockExtension time.Duration
clockExtensionErr error
}
func (s *stubClaimLoader) IsL2BlockNumberChallenged(_ context.Context, _ rpcblock.Block) (bool, error) {
return s.blockNumChallenged, nil
}
func (s *stubClaimLoader) GetAllClaims(_ context.Context, _ rpcblock.Block) ([]types.Claim, error) {
s.callCount++
if s.callCount > s.maxLoads && s.maxLoads != 0 {
return []types.Claim{}, nil
}
return s.claims, nil
}
func (s *stubClaimLoader) GetClockExtension(_ context.Context) (time.Duration, error) {
if s.clockExtensionErr != nil {
return 0, s.clockExtensionErr
}
// Return a reasonable default if not set
if s.clockExtension == 0 {
return 5 * time.Minute, nil // Default clock extension
}
return s.clockExtension, nil
}
type stubResponder struct {
l sync.Mutex
callResolveCount int
callResolveStatus gameTypes.GameStatus
callResolveErr error
resolveCount int
resolveErr error
callResolveClaimCount int
callResolveClaimErr error
resolveClaimCount int
resolvedClaims []uint64
performActionCount int
performActionErr error // If set, PerformAction will return this error
}
func (s *stubResponder) CallResolve(_ context.Context) (gameTypes.GameStatus, error) {
s.l.Lock()
defer s.l.Unlock()
s.callResolveCount++
return s.callResolveStatus, s.callResolveErr
}
func (s *stubResponder) Resolve() error {
s.l.Lock()
defer s.l.Unlock()
s.resolveCount++
return s.resolveErr
}
func (s *stubResponder) CallResolveClaim(_ context.Context, idx uint64) error {
s.l.Lock()
defer s.l.Unlock()
if slices.Contains(s.resolvedClaims, idx) {
return errors.New("already resolved")
}
s.callResolveClaimCount++
return s.callResolveClaimErr
}
func (s *stubResponder) ResolveClaims(claims ...uint64) error {
s.l.Lock()
defer s.l.Unlock()
s.resolveClaimCount += len(claims)
s.resolvedClaims = append(s.resolvedClaims, claims...)
return nil
}
func (s *stubResponder) PerformAction(_ context.Context, _ types.Action) error {
s.l.Lock()
defer s.l.Unlock()
s.performActionCount++
return s.performActionErr
}
func (s *stubResponder) PerformedActionCount() int {
s.l.Lock()
defer s.l.Unlock()
return s.performActionCount
}
// TestResponseDelay tests the response delay functionality using deterministic clock
func TestResponseDelay(t *testing.T) {
tests := []struct {
name string
delay time.Duration
}{
{
name: "NoDelay",
delay: 0,
},
{
name: "Delay",
delay: 24 * time.Hour,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
ctx := context.Background()
// Set up agent with deterministic clock
logger := testlog.Logger(t, log.LevelInfo)
claimLoader := newStubClaimLoaderWithDefaults()
depth := types.Depth(4)
gameDuration := 24 * time.Hour // Large value to avoid clock extension triggering
provider := alphabet.NewTraceProvider(big.NewInt(0), depth)
responder := &stubResponder{}
systemClock := clock.NewDeterministicClock(time.UnixMilli(120200))
l1Clock := clock.NewDeterministicClock(l1Time)
// Create agent with the test response delay
agent := NewAgent(metrics.NoopMetrics, systemClock, l1Clock, claimLoader, depth, gameDuration, trace.NewSimpleTraceAccessor(provider), responder, logger, false, []common.Address{}, test.delay, 0)
// Set up game state with a claim to respond to
claimLoader.claims = []types.Claim{
{
ClaimData: types.ClaimData{
Value: common.Hash{},
Position: types.NewPositionFromGIndex(big.NewInt(1)),
},
Clock: types.Clock{
Duration: time.Minute,
Timestamp: l1Time,
},
ContractIndex: 0,
},
}
// Create an action that will trigger the delay
action := types.Action{
Type: types.ActionTypeMove,
ParentClaim: claimLoader.claims[0],
IsAttack: true,
Value: common.Hash{0x01},
}
// Perform action in a goroutine so we can control clock advancement
var wg sync.WaitGroup
wg.Add(1)
done := make(chan struct{})
go func() {
agent.performAction(ctx, &wg, action)
close(done)
}()
if test.delay > 0 {
// Wait for the action delay to begin waiting
require.True(t, systemClock.WaitForNewPendingTaskWithTimeout(30*time.Second))
require.Zero(t, responder.PerformedActionCount(), "Action should not have completed before delay period")
systemClock.AdvanceTime(test.delay)
}
// Verify the action completes
select {
case <-done:
// Expected completion due to cancellation
case <-time.After(30 * time.Second):
t.Fatal("Action did not complete quickly after cancellation")
}
// And verify the wait group is done for good measure
wg.Wait()
require.Equal(t, 1, responder.PerformedActionCount(), "Action should have completed after delay period")
})
}
}
// TestResponseDelayContextCancellation tests that context cancellation interrupts the delay
func TestResponseDelayContextCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
// Set up agent with long delay and deterministic clock
logger := testlog.Logger(t, log.LevelInfo)
claimLoader := newStubClaimLoaderWithDefaults()
depth := types.Depth(4)
gameDuration := 24 * time.Hour
provider := alphabet.NewTraceProvider(big.NewInt(0), depth)
responder := &stubResponder{}
systemClock := clock.NewDeterministicClock(time.UnixMilli(120200))
l1Clock := clock.NewDeterministicClock(l1Time)
longDelay := 5 * time.Minute
agent := NewAgent(metrics.NoopMetrics, systemClock, l1Clock, claimLoader, depth, gameDuration, trace.NewSimpleTraceAccessor(provider), responder, logger, false, []common.Address{}, longDelay, 0)
// Set up game state
claimLoader.claims = []types.Claim{
{
ClaimData: types.ClaimData{
Value: common.Hash{},
Position: types.NewPositionFromGIndex(big.NewInt(1)),
},
Clock: types.Clock{
Duration: time.Minute,
Timestamp: l1Time,
},
ContractIndex: 0,
},
}
action := types.Action{
Type: types.ActionTypeMove,
ParentClaim: claimLoader.claims[0],
IsAttack: true,
Value: common.Hash{0x01},
}
var wg sync.WaitGroup
wg.Add(1)
done := make(chan struct{})
go func() {
agent.performAction(ctx, &wg, action)
close(done)
}()
// Verify the action is waiting for the delay
systemClock.WaitForNewPendingTaskWithTimeout(30 * time.Second)
// Cancel the context (simulates timeout or shutdown)
cancel()
// Action should complete even though the clock didn't progress
select {
case <-done:
// Expected completion due to cancellation
case <-time.After(30 * time.Second):
t.Fatal("Action did not complete quickly after cancellation")
}
// And verify the wait group is done for good measure
wg.Wait()
require.Zero(t, responder.PerformedActionCount(), "Action should not have completed")
}
// TestResponseDelayDifferentActionTypes tests that delay applies to all action types
func TestResponseDelayDifferentActionTypes(t *testing.T) {
actionTypes := []struct {
name string
actionType types.ActionType
}{
{"Move", types.ActionTypeMove},
{"Step", types.ActionTypeStep},
{"ChallengeL2BlockNumber", types.ActionTypeChallengeL2BlockNumber},
}
for _, actionTest := range actionTypes {
actionTest := actionTest
t.Run(actionTest.name, func(t *testing.T) {
ctx := context.Background()
// Set up agent with deterministic clock and response delay
logger := testlog.Logger(t, log.LevelInfo)
claimLoader := newStubClaimLoaderWithDefaults()
depth := types.Depth(4)
gameDuration := 24 * time.Hour // Large value to avoid clock extension triggering
provider := alphabet.NewTraceProvider(big.NewInt(0), depth)
responder := &stubResponder{}
systemClock := clock.NewDeterministicClock(time.UnixMilli(120200))
l1Clock := clock.NewDeterministicClock(l1Time)
responseDelay := 3 * time.Hour
agent := NewAgent(metrics.NoopMetrics, systemClock, l1Clock, claimLoader, depth, gameDuration, trace.NewSimpleTraceAccessor(provider), responder, logger, false, []common.Address{}, responseDelay, 0)
// Set up game state
claimLoader.claims = []types.Claim{
{
ClaimData: types.ClaimData{
Value: common.Hash{},
Position: types.NewPositionFromGIndex(big.NewInt(1)),
},
Clock: types.Clock{
Duration: time.Minute,
Timestamp: l1Time,
},
ContractIndex: 0,
},
}
// Create action of specific type
action := types.Action{
Type: actionTest.actionType,
ParentClaim: claimLoader.claims[0],
IsAttack: true,
Value: common.Hash{0x01},
}
var wg sync.WaitGroup
wg.Add(1)
done := make(chan struct{})
go func() {
agent.performAction(ctx, &wg, action)
close(done)
}()
// First select: Verify the action is waiting for the delay (polling check)
systemClock.WaitForNewPendingTaskWithTimeout(30 * time.Second)
require.Zero(t, responder.PerformedActionCount(), "Action was performed before delay")
// Advance clock by delay amount
systemClock.AdvanceTime(responseDelay)
// Second select: Wait for action to complete after clock advancement
select {
case <-done:
// Expected completion
case <-time.After(30 * time.Second):
t.Fatal("Action did not complete after delay")
}
// Verify the wait group is done for good measure
wg.Wait()
// Verify the action was performed
require.Equal(t, 1, responder.PerformedActionCount(), "Action was not performed after delay")
})
}
}
// TestResponseDelayAfter tests the response delay activation threshold functionality
func TestResponseDelayAfter(t *testing.T) {
tests := []struct {
name string
responseDelay time.Duration
responseDelayAfter uint64
actionsToPerform int
}{
{
name: "DelayFromFirstResponse",
responseDelay: 2 * time.Hour,
responseDelayAfter: 0, // Apply delay from first response
actionsToPerform: 3,
},
{
name: "DelayAfterFirstResponse",
responseDelay: 2 * time.Hour,
responseDelayAfter: 1, // Skip first response, delay subsequent ones
actionsToPerform: 3,
},
{
name: "DelayAfterSecondResponse",
responseDelay: 2 * time.Hour,
responseDelayAfter: 2, // Skip first two responses
actionsToPerform: 4,
},
{
name: "DelayNeverActivates",
responseDelay: 2 * time.Hour,
responseDelayAfter: 5, // Threshold higher than actions performed
actionsToPerform: 3,
},
{
name: "NoDelayConfigured",
responseDelay: 0, // No delay configured
responseDelayAfter: 0,
actionsToPerform: 3,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
ctx := context.Background()
// Set up agent with deterministic clock
logger := testlog.Logger(t, log.LevelInfo)
claimLoader := newStubClaimLoaderWithDefaults()
depth := types.Depth(4)
gameDuration := 24 * time.Hour // Large value to avoid clock extension triggering
provider := alphabet.NewTraceProvider(big.NewInt(0), depth)
responder := &stubResponder{}
systemClock := clock.NewDeterministicClock(time.UnixMilli(120200))
l1Clock := clock.NewDeterministicClock(l1Time)
// Create agent with test parameters
agent := NewAgent(metrics.NoopMetrics, systemClock, l1Clock, claimLoader, depth, gameDuration, trace.NewSimpleTraceAccessor(provider), responder, logger, false, []common.Address{}, test.responseDelay, test.responseDelayAfter)
// Set up initial game state
claimBuilder := faulttest.NewClaimBuilder(t, depth, provider)
baseClaim := claimBuilder.CreateRootClaim()
// Fix timestamp to be realistic
baseClaim.Clock = types.Clock{
Duration: 0, // Root claim starts with no accumulated time
Timestamp: l1Clock.Now(), // Use current time
}
claimLoader.claims = []types.Claim{baseClaim}
// Perform actions and verify delay behavior
for i := 0; i < test.actionsToPerform; i++ {
action := types.Action{
Type: types.ActionTypeMove,
ParentClaim: baseClaim,
IsAttack: true,
Value: common.Hash{byte(i + 1)}, // Unique value for each action
}
var wg sync.WaitGroup
wg.Add(1)
done := make(chan struct{})
go func() {
agent.performAction(ctx, &wg, action)
close(done)
}()
// Calculate if delay should be applied: response count >= threshold AND delay > 0
shouldHaveDelay := uint64(i) >= test.responseDelayAfter && test.responseDelay > 0
if shouldHaveDelay {
systemClock.WaitForNewPendingTaskWithTimeout(30 * time.Second)
require.Equal(t, i, responder.PerformedActionCount(), "Action was performed before delay")
// Advance clock by delay amount
systemClock.AdvanceTime(test.responseDelay)
}
// Wait for completion
select {
case <-done:
// Expected completion
case <-time.After(30 * time.Second):
t.Fatalf("Action %d did not complete after delay", i+1)
}
wg.Wait()
// Verify response count incremented (assuming successful response)
expectedCount := uint64(i + 1)
require.Equal(t, expectedCount, agent.responseCount, "Response count should increment after action %d", expectedCount)
}
})
}
}
// TestResponseDelayAfterWithFailedActions tests that failed actions don't increment response count
func TestResponseDelayAfterWithFailedActions(t *testing.T) {
ctx := context.Background()
// Set up agent with delay after 1 response
logger := testlog.Logger(t, log.LevelInfo)
claimLoader := newStubClaimLoaderWithDefaults()
depth := types.Depth(4)
gameDuration := 24 * time.Hour
provider := alphabet.NewTraceProvider(big.NewInt(0), depth)
responder := &stubResponder{}
systemClock := clock.NewDeterministicClock(time.UnixMilli(120200))
l1Clock := clock.NewDeterministicClock(l1Time)
responseDelay := 2 * time.Hour
responseDelayAfter := uint64(1) // Delay after first successful response
agent := NewAgent(metrics.NoopMetrics, systemClock, l1Clock, claimLoader, depth, gameDuration, trace.NewSimpleTraceAccessor(provider), responder, logger, false, []common.Address{}, responseDelay, responseDelayAfter)
// Set up game state
claimBuilder := faulttest.NewClaimBuilder(t, depth, provider)
baseClaim := claimBuilder.CreateRootClaim()
// Fix timestamp to be realistic
baseClaim.Clock = types.Clock{
Duration: 0, // Root claim starts with no accumulated time
Timestamp: l1Clock.Now(), // Use current time
}
claimLoader.claims = []types.Claim{baseClaim}
action := types.Action{
Type: types.ActionTypeMove,
ParentClaim: baseClaim,
IsAttack: true,
Value: common.Hash{0x01},
}
// First action: make responder fail
responder.performActionErr = errors.New("simulated action failure")
var wg sync.WaitGroup
wg.Add(1)
done := make(chan struct{})
go func() {
agent.performAction(ctx, &wg, action)
close(done)
}()
// Should complete without needing to advance the clock (no delay since responseCount < responseDelayAfter)
select {
case <-done:
// Expected immediate completion
case <-time.After(30 * time.Second):
t.Fatal("Failed action took too long")
}
wg.Wait()
require.Equal(t, uint64(0), agent.responseCount, "Failed action should not increment response count")
// Second action: make responder succeed
responder.performActionErr = nil
wg.Add(1)
done = make(chan struct{})
go func() {
agent.performAction(ctx, &wg, action)
close(done)
}()
// Should complete without needing to advance the clock (no delay since responseCount is still 0)
select {
case <-done:
// Expected immediate completion
case <-time.After(30 * time.Second):
t.Fatal("Successful action took too long")
}
wg.Wait()
// Should be no delay but response count should increment
require.Equal(t, uint64(1), agent.responseCount, "Successful action should increment response count")
// Third action: should now have delay applied
wg.Add(1)
done = make(chan struct{})
go func() {
agent.performAction(ctx, &wg, action)
close(done)
}()
// Should be waiting for delay now (responseCount >= responseDelayAfter)
systemClock.WaitForNewPendingTaskWithTimeout(30 * time.Second)
// Note: 2 attempts have been made - one failed, one successful and the third is delayed.
require.Equal(t, 2, responder.PerformedActionCount(), "Should not have performed action without delay")
// Advance clock by delay amount
systemClock.AdvanceTime(responseDelay)
// Wait for completion
select {
case <-done:
// Expected completion
case <-time.After(30 * time.Second):
t.Fatal("Action did not complete after delay")
}
wg.Wait()
require.Equal(t, 3, responder.PerformedActionCount(), "Should have performed action after delay")
require.Equal(t, uint64(2), agent.responseCount, "Response count should be 2 after second successful action")
}
// TestResponseDelayClockExtension tests that delays are skipped during clock extension periods
func TestResponseDelayClockExtension(t *testing.T) {
// Common test configuration
const (
responseDelay = 2 * time.Hour
responseDelayAfter = 0
maxClockDuration = 10 * time.Minute
clockExtension = 1 * time.Minute
baseTimestamp = 100000 // milliseconds since Unix epoch
)
extensionThreshold := maxClockDuration - clockExtension // 9 minutes
tests := []struct {
name string
parentClockDuration time.Duration // Previous accumulated time
timeSinceCreation time.Duration // Additional time since claim created
}{
{
name: "NoExtension_WithDelay",
parentClockDuration: 3 * time.Minute,
timeSinceCreation: 1 * time.Minute, // Total: 4min < 9min threshold
},
{
name: "InExtension_SkipDelay",
parentClockDuration: 8 * time.Minute,
timeSinceCreation: 2 * time.Minute, // Total: 10min > 9min threshold
},
{
name: "ExactlyAtThreshold_InExtension_SkipDelay",
parentClockDuration: 8 * time.Minute,
timeSinceCreation: 1*time.Minute + 1*time.Microsecond, // Total: just over 9min
},
{
name: "JustBelowThreshold_WithDelay_WaitDelay",
parentClockDuration: 8 * time.Minute,
timeSinceCreation: 59 * time.Second, // Total: 8min59s < 9min threshold
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
ctx := context.Background()
// Set up agent with deterministic clock
logger := testlog.Logger(t, log.LevelInfo)
claimLoader := &stubClaimLoader{
clockExtension: clockExtension,
}
depth := types.Depth(4)
provider := alphabet.NewTraceProvider(big.NewInt(0), depth)
responder := &stubResponder{}
currentTime := time.UnixMilli(baseTimestamp).Add(test.timeSinceCreation)
systemClock := clock.NewDeterministicClock(currentTime)
l1Clock := clock.NewDeterministicClock(currentTime)
// Create agent with test parameters
agent := NewAgent(metrics.NoopMetrics, systemClock, l1Clock, claimLoader, depth, maxClockDuration, trace.NewSimpleTraceAccessor(provider), responder, logger, false, []common.Address{}, responseDelay, responseDelayAfter)
// Set up parent claim with specific clock timing
claimBuilder := faulttest.NewClaimBuilder(t, depth, provider)
parentClaim := claimBuilder.CreateRootClaim()
// Set realistic clock values to match test expectations
parentClaim.Clock = types.Clock{
Duration: test.parentClockDuration,
Timestamp: currentTime.Add(-test.timeSinceCreation), // Realistic timestamp based on current time
}
// Calculate whether delay should be applied based on extension threshold
totalClockTime := test.parentClockDuration + test.timeSinceCreation
expectDelay := totalClockTime <= extensionThreshold
claimLoader.claims = []types.Claim{parentClaim}
// Create action with the parent claim
action := types.Action{
Type: types.ActionTypeMove,
ParentClaim: parentClaim,
IsAttack: true,
Value: common.Hash{0x01},
}
// Perform action and measure timing
var wg sync.WaitGroup
wg.Add(1)
done := make(chan struct{})
go func() {
agent.performAction(ctx, &wg, action)
close(done)
}()
if expectDelay {
// Should be waiting for delay
systemClock.WaitForNewPendingTaskWithTimeout(30 * time.Second)
require.Equal(t, 0, responder.PerformedActionCount(), "Should not have performed action without delay")
// Advance clock by delay amount
systemClock.AdvanceTime(responseDelay)
}
// Wait for completion
select {
case <-done:
// Expected completion
case <-time.After(30 * time.Second):
t.Fatal("Action did not complete in expected time")
}
wg.Wait()
require.Equal(t, 1, responder.PerformedActionCount(), "Should have performed action after delay")
})
}
}
// TestResponseDelayClockExtensionError tests error handling when clock extension detection fails
func TestResponseDelayClockExtensionError(t *testing.T) {
ctx := context.Background()
// Set up agent with claimLoader that returns an error for clock extension
logger := testlog.Logger(t, log.LevelInfo)
claimLoader := &stubClaimLoader{
clockExtensionErr: errors.New("failed to get clock extension"),
}
depth := types.Depth(4)
provider := alphabet.NewTraceProvider(big.NewInt(0), depth)
responder := &stubResponder{}
systemClock := clock.NewDeterministicClock(time.UnixMilli(120200))
l1Clock := clock.NewDeterministicClock(time.UnixMilli(120200))
responseDelay := 2 * time.Hour
maxClockDuration := 10 * time.Minute // Use a reasonable default for error test
agent := NewAgent(metrics.NoopMetrics, systemClock, l1Clock, claimLoader, depth, maxClockDuration, trace.NewSimpleTraceAccessor(provider), responder, logger, false, []common.Address{}, responseDelay, 0)
// Set up game state
claimBuilder := faulttest.NewClaimBuilder(t, depth, provider)
baseClaim := claimBuilder.CreateRootClaim()
claimLoader.claims = []types.Claim{baseClaim}
action := types.Action{
Type: types.ActionTypeMove,
ParentClaim: baseClaim,
IsAttack: true,
Value: common.Hash{0x01},
}
// Perform action - should still apply delay despite error
var wg sync.WaitGroup
wg.Add(1)
done := make(chan struct{})
go func() {
agent.performAction(ctx, &wg, action)
close(done)
}()
// Should complete without needing to advance clock (no delay applied for safety when extension detection fails)
select {
case <-done:
// Expected - immediate completion
case <-time.After(30 * time.Second):
t.Fatal("Action did not complete immediately when extension detection fails")
}
wg.Wait()
require.Equal(t, 1, responder.PerformedActionCount(), "Should have performed action")
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment