Last active
August 14, 2025 05:28
-
-
Save ajsutton/47f872678bbff705a69b1f6276d4e5ab to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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