diff --git a/core/logger/logger.go b/core/logger/logger.go index bd6893cbb20..8e847a99ac0 100644 --- a/core/logger/logger.go +++ b/core/logger/logger.go @@ -52,6 +52,7 @@ func init() { var _ relaylogger.Logger = (Logger)(nil) //go:generate mockery --quiet --name Logger --output . --filename logger_mock_test.go --inpackage --case=underscore +//go:generate mockery --quiet --name Logger --output ./mocks/ --case=underscore // Logger is the main interface of this package. // It implements uber/zap's SugaredLogger interface and adds conditional logging helpers. diff --git a/core/logger/mocks/logger.go b/core/logger/mocks/logger.go new file mode 100644 index 00000000000..24752c2b6b6 --- /dev/null +++ b/core/logger/mocks/logger.go @@ -0,0 +1,302 @@ +// Code generated by mockery v2.28.1. DO NOT EDIT. + +package mocks + +import ( + logger "github.com/smartcontractkit/chainlink/v2/core/logger" + mock "github.com/stretchr/testify/mock" + + zapcore "go.uber.org/zap/zapcore" +) + +// Logger is an autogenerated mock type for the Logger type +type Logger struct { + mock.Mock +} + +// Critical provides a mock function with given fields: args +func (_m *Logger) Critical(args ...interface{}) { + var _ca []interface{} + _ca = append(_ca, args...) + _m.Called(_ca...) +} + +// Criticalf provides a mock function with given fields: format, values +func (_m *Logger) Criticalf(format string, values ...interface{}) { + var _ca []interface{} + _ca = append(_ca, format) + _ca = append(_ca, values...) + _m.Called(_ca...) +} + +// Criticalw provides a mock function with given fields: msg, keysAndValues +func (_m *Logger) Criticalw(msg string, keysAndValues ...interface{}) { + var _ca []interface{} + _ca = append(_ca, msg) + _ca = append(_ca, keysAndValues...) + _m.Called(_ca...) +} + +// Debug provides a mock function with given fields: args +func (_m *Logger) Debug(args ...interface{}) { + var _ca []interface{} + _ca = append(_ca, args...) + _m.Called(_ca...) +} + +// Debugf provides a mock function with given fields: format, values +func (_m *Logger) Debugf(format string, values ...interface{}) { + var _ca []interface{} + _ca = append(_ca, format) + _ca = append(_ca, values...) + _m.Called(_ca...) +} + +// Debugw provides a mock function with given fields: msg, keysAndValues +func (_m *Logger) Debugw(msg string, keysAndValues ...interface{}) { + var _ca []interface{} + _ca = append(_ca, msg) + _ca = append(_ca, keysAndValues...) + _m.Called(_ca...) +} + +// Error provides a mock function with given fields: args +func (_m *Logger) Error(args ...interface{}) { + var _ca []interface{} + _ca = append(_ca, args...) + _m.Called(_ca...) +} + +// Errorf provides a mock function with given fields: format, values +func (_m *Logger) Errorf(format string, values ...interface{}) { + var _ca []interface{} + _ca = append(_ca, format) + _ca = append(_ca, values...) + _m.Called(_ca...) +} + +// Errorw provides a mock function with given fields: msg, keysAndValues +func (_m *Logger) Errorw(msg string, keysAndValues ...interface{}) { + var _ca []interface{} + _ca = append(_ca, msg) + _ca = append(_ca, keysAndValues...) + _m.Called(_ca...) +} + +// Fatal provides a mock function with given fields: args +func (_m *Logger) Fatal(args ...interface{}) { + var _ca []interface{} + _ca = append(_ca, args...) + _m.Called(_ca...) +} + +// Fatalf provides a mock function with given fields: format, values +func (_m *Logger) Fatalf(format string, values ...interface{}) { + var _ca []interface{} + _ca = append(_ca, format) + _ca = append(_ca, values...) + _m.Called(_ca...) +} + +// Fatalw provides a mock function with given fields: msg, keysAndValues +func (_m *Logger) Fatalw(msg string, keysAndValues ...interface{}) { + var _ca []interface{} + _ca = append(_ca, msg) + _ca = append(_ca, keysAndValues...) + _m.Called(_ca...) +} + +// Helper provides a mock function with given fields: skip +func (_m *Logger) Helper(skip int) logger.Logger { + ret := _m.Called(skip) + + var r0 logger.Logger + if rf, ok := ret.Get(0).(func(int) logger.Logger); ok { + r0 = rf(skip) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(logger.Logger) + } + } + + return r0 +} + +// Info provides a mock function with given fields: args +func (_m *Logger) Info(args ...interface{}) { + var _ca []interface{} + _ca = append(_ca, args...) + _m.Called(_ca...) +} + +// Infof provides a mock function with given fields: format, values +func (_m *Logger) Infof(format string, values ...interface{}) { + var _ca []interface{} + _ca = append(_ca, format) + _ca = append(_ca, values...) + _m.Called(_ca...) +} + +// Infow provides a mock function with given fields: msg, keysAndValues +func (_m *Logger) Infow(msg string, keysAndValues ...interface{}) { + var _ca []interface{} + _ca = append(_ca, msg) + _ca = append(_ca, keysAndValues...) + _m.Called(_ca...) +} + +// Name provides a mock function with given fields: +func (_m *Logger) Name() string { + ret := _m.Called() + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// Named provides a mock function with given fields: name +func (_m *Logger) Named(name string) logger.Logger { + ret := _m.Called(name) + + var r0 logger.Logger + if rf, ok := ret.Get(0).(func(string) logger.Logger); ok { + r0 = rf(name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(logger.Logger) + } + } + + return r0 +} + +// Panic provides a mock function with given fields: args +func (_m *Logger) Panic(args ...interface{}) { + var _ca []interface{} + _ca = append(_ca, args...) + _m.Called(_ca...) +} + +// Panicf provides a mock function with given fields: format, values +func (_m *Logger) Panicf(format string, values ...interface{}) { + var _ca []interface{} + _ca = append(_ca, format) + _ca = append(_ca, values...) + _m.Called(_ca...) +} + +// Panicw provides a mock function with given fields: msg, keysAndValues +func (_m *Logger) Panicw(msg string, keysAndValues ...interface{}) { + var _ca []interface{} + _ca = append(_ca, msg) + _ca = append(_ca, keysAndValues...) + _m.Called(_ca...) +} + +// Recover provides a mock function with given fields: panicErr +func (_m *Logger) Recover(panicErr interface{}) { + _m.Called(panicErr) +} + +// SetLogLevel provides a mock function with given fields: _a0 +func (_m *Logger) SetLogLevel(_a0 zapcore.Level) { + _m.Called(_a0) +} + +// Sync provides a mock function with given fields: +func (_m *Logger) Sync() error { + ret := _m.Called() + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Trace provides a mock function with given fields: args +func (_m *Logger) Trace(args ...interface{}) { + var _ca []interface{} + _ca = append(_ca, args...) + _m.Called(_ca...) +} + +// Tracef provides a mock function with given fields: format, values +func (_m *Logger) Tracef(format string, values ...interface{}) { + var _ca []interface{} + _ca = append(_ca, format) + _ca = append(_ca, values...) + _m.Called(_ca...) +} + +// Tracew provides a mock function with given fields: msg, keysAndValues +func (_m *Logger) Tracew(msg string, keysAndValues ...interface{}) { + var _ca []interface{} + _ca = append(_ca, msg) + _ca = append(_ca, keysAndValues...) + _m.Called(_ca...) +} + +// Warn provides a mock function with given fields: args +func (_m *Logger) Warn(args ...interface{}) { + var _ca []interface{} + _ca = append(_ca, args...) + _m.Called(_ca...) +} + +// Warnf provides a mock function with given fields: format, values +func (_m *Logger) Warnf(format string, values ...interface{}) { + var _ca []interface{} + _ca = append(_ca, format) + _ca = append(_ca, values...) + _m.Called(_ca...) +} + +// Warnw provides a mock function with given fields: msg, keysAndValues +func (_m *Logger) Warnw(msg string, keysAndValues ...interface{}) { + var _ca []interface{} + _ca = append(_ca, msg) + _ca = append(_ca, keysAndValues...) + _m.Called(_ca...) +} + +// With provides a mock function with given fields: args +func (_m *Logger) With(args ...interface{}) logger.Logger { + var _ca []interface{} + _ca = append(_ca, args...) + ret := _m.Called(_ca...) + + var r0 logger.Logger + if rf, ok := ret.Get(0).(func(...interface{}) logger.Logger); ok { + r0 = rf(args...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(logger.Logger) + } + } + + return r0 +} + +type mockConstructorTestingTNewLogger interface { + mock.TestingT + Cleanup(func()) +} + +// NewLogger creates a new instance of Logger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewLogger(t mockConstructorTestingTNewLogger) *Logger { + mock := &Logger{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/services/blockhashstore/common.go b/core/services/blockhashstore/common.go index 61aaf989298..677016253fb 100644 --- a/core/services/blockhashstore/common.go +++ b/core/services/blockhashstore/common.go @@ -33,6 +33,8 @@ type Event struct { } // BHS defines an interface for interacting with a BlockhashStore contract. +// +//go:generate mockery --quiet --name BHS --output ./mocks/ --case=underscore type BHS interface { // Store the hash associated with blockNum. Store(ctx context.Context, blockNum uint64) error diff --git a/core/services/blockhashstore/delegate.go b/core/services/blockhashstore/delegate.go index 0342dff78b7..e8646c53f2e 100644 --- a/core/services/blockhashstore/delegate.go +++ b/core/services/blockhashstore/delegate.go @@ -3,6 +3,7 @@ package blockhashstore import ( "context" "fmt" + "sync" "time" "github.com/pkg/errors" @@ -165,6 +166,7 @@ func (d *Delegate) ServicesForSpec(jb job.Job, qopts ...pg.QOpt) ([]job.ServiceC jb.BlockhashStoreSpec.TrustedBlockhashStoreBatchSize, int(jb.BlockhashStoreSpec.WaitBlocks), int(jb.BlockhashStoreSpec.LookbackBlocks), + jb.BlockhashStoreSpec.HeartbeatPeriod, func(ctx context.Context) (uint64, error) { head, err := lp.LatestBlock(pg.WithParentCtx(ctx)) if err != nil { @@ -178,7 +180,6 @@ func (d *Delegate) ServicesForSpec(jb job.Job, qopts ...pg.QOpt) ([]job.ServiceC pollPeriod: jb.BlockhashStoreSpec.PollPeriod, runTimeout: jb.BlockhashStoreSpec.RunTimeout, logger: log, - done: make(chan struct{}), }}, nil } @@ -198,7 +199,7 @@ func (d *Delegate) OnDeleteJob(spec job.Job, q pg.Queryer) error { return nil } type service struct { utils.StartStopOnce feeder *Feeder - done chan struct{} + wg sync.WaitGroup pollPeriod time.Duration runTimeout time.Duration logger logger.Logger @@ -212,8 +213,13 @@ func (s *service) Start(context.Context) error { s.logger.Infow("Starting BHS feeder") ticker := time.NewTicker(utils.WithJitter(s.pollPeriod)) s.parentCtx, s.cancel = context.WithCancel(context.Background()) + s.wg.Add(2) go func() { - defer close(s.done) + defer s.wg.Done() + s.feeder.StartHeartbeats(s.parentCtx, &realTimer{}) + }() + go func() { + defer s.wg.Done() defer ticker.Stop() for { select { @@ -233,7 +239,7 @@ func (s *service) Close() error { return s.StopOnce("BHS Feeder Service", func() error { s.logger.Infow("Stopping BHS feeder") s.cancel() - <-s.done + s.wg.Wait() return nil }) } diff --git a/core/services/blockhashstore/feeder.go b/core/services/blockhashstore/feeder.go index 14e51e68394..8cc607db9b3 100644 --- a/core/services/blockhashstore/feeder.go +++ b/core/services/blockhashstore/feeder.go @@ -2,6 +2,7 @@ package blockhashstore import ( "context" + "fmt" "sync" "time" @@ -25,6 +26,7 @@ func NewFeeder( trustedBHSBatchSize int32, waitBlocks int, lookbackBlocks int, + heartbeatPeriod time.Duration, latestBlock func(ctx context.Context) (uint64, error), ) *Feeder { return &Feeder{ @@ -40,6 +42,7 @@ func NewFeeder( storedTrusted: make(map[uint64]common.Hash), lastRunBlock: 0, wgStored: sync.WaitGroup{}, + heartbeatPeriod: heartbeatPeriod, } } @@ -55,6 +58,12 @@ type Feeder struct { lookbackBlocks int latestBlock func(ctx context.Context) (uint64, error) + // heartbeatPeriodTime is a heartbeat period in seconds by which + // the feeder will always store a blockhash, even if there are no + // unfulfilled requests. This is to ensure that there are blockhashes + // in the store to start from if we ever need to run backwards mode. + heartbeatPeriod time.Duration + stored map[uint64]struct{} // used for trustless feeder storedTrusted map[uint64]common.Hash // used for trusted feeder lastRunBlock uint64 @@ -63,6 +72,40 @@ type Feeder struct { errsLock sync.Mutex } +//go:generate mockery --quiet --name Timer --output ./mocks/ --case=underscore +type Timer interface { + After(d time.Duration) <-chan time.Time +} + +type realTimer struct{} + +func (r *realTimer) After(d time.Duration) <-chan time.Time { + return time.After(d) +} + +func (f *Feeder) StartHeartbeats(ctx context.Context, timer Timer) { + if f.heartbeatPeriod == 0 { + f.lggr.Infow("Not starting heartbeat blockhash using storeEarliest") + return + } + f.lggr.Infow(fmt.Sprintf("Starting heartbeat blockhash using storeEarliest every %s", f.heartbeatPeriod.String())) + for { + after := timer.After(f.heartbeatPeriod) + select { + case <-after: + f.lggr.Infow("storing heartbeat blockhash using storeEarliest", + "heartbeatPeriodSeconds", f.heartbeatPeriod.Seconds()) + if err := f.bhs.StoreEarliest(ctx); err != nil { + f.lggr.Infow("failed to store heartbeat blockhash using storeEarliest", + "heartbeatPeriodSeconds", f.heartbeatPeriod.Seconds(), + "err", err) + } + case <-ctx.Done(): + return + } + } +} + // Run the feeder. func (f *Feeder) Run(ctx context.Context) error { latestBlock, err := f.latestBlock(ctx) diff --git a/core/services/blockhashstore/feeder_test.go b/core/services/blockhashstore/feeder_test.go index e015253ba28..08d7c0e9c46 100644 --- a/core/services/blockhashstore/feeder_test.go +++ b/core/services/blockhashstore/feeder_test.go @@ -2,8 +2,10 @@ package blockhashstore import ( "context" + "fmt" "math/big" "testing" + "time" "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" @@ -17,12 +19,14 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/utils/mathutil" "github.com/smartcontractkit/chainlink/v2/core/chains/evm/logpoller" + bhsmocks "github.com/smartcontractkit/chainlink/v2/core/services/blockhashstore/mocks" "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/generated/solidity_vrf_coordinator_interface" "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/generated/vrf_coordinator_v2" "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/generated/vrf_coordinator_v2plus" "github.com/smartcontractkit/chainlink/v2/core/internal/testutils" "github.com/smartcontractkit/chainlink/v2/core/logger" + loggermocks "github.com/smartcontractkit/chainlink/v2/core/logger/mocks" ) const ( @@ -236,6 +240,129 @@ var ( } ) +func TestStartHeartbeats(t *testing.T) { + t.Run("bhs_heartbeat_happy_path", func(t *testing.T) { + expectedDuration := 600 * time.Second + mockBHS := bhsmocks.NewBHS(t) + mockLogger := loggermocks.NewLogger(t) + feeder := NewFeeder( + mockLogger, + &TestCoordinator{}, // Not used for this test + mockBHS, + &mocklp.LogPoller{}, // Not used for this test + 0, + 25, // Not used for this test + 100, // Not used for this test + expectedDuration, + func(ctx context.Context) (uint64, error) { + return tests[0].latest, nil + }) + + ctx, cancel := context.WithCancel(testutils.Context(t)) + mockTimer := bhsmocks.NewTimer(t) + + mockBHS.On("StoreEarliest", ctx).Return(nil).Once() + mockTimer.On("After", expectedDuration).Return(func() <-chan time.Time { + c := make(chan time.Time) + close(c) + return c + }()).Once() + mockTimer.On("After", expectedDuration).Return(func() <-chan time.Time { + c := make(chan time.Time) + return c + }()).Run(func(args mock.Arguments) { + cancel() + }).Once() + mockLogger.On("Infow", "Starting heartbeat blockhash using storeEarliest every 10m0s").Once() + mockLogger.On("Infow", "storing heartbeat blockhash using storeEarliest", + "heartbeatPeriodSeconds", expectedDuration.Seconds()).Once() + require.Len(t, mockLogger.ExpectedCalls, 2) + require.Len(t, mockTimer.ExpectedCalls, 2) + defer mockTimer.AssertExpectations(t) + defer mockBHS.AssertExpectations(t) + defer mockLogger.AssertExpectations(t) + + feeder.StartHeartbeats(ctx, mockTimer) + }) + + t.Run("bhs_heartbeat_sad_path_store_earliest_err", func(t *testing.T) { + expectedDuration := 600 * time.Second + expectedError := fmt.Errorf("insufficient gas") + mockBHS := bhsmocks.NewBHS(t) + mockLogger := loggermocks.NewLogger(t) + feeder := NewFeeder( + mockLogger, + &TestCoordinator{}, // Not used for this test + mockBHS, + &mocklp.LogPoller{}, // Not used for this test + 0, + 25, // Not used for this test + 100, // Not used for this test + expectedDuration, + func(ctx context.Context) (uint64, error) { + return tests[0].latest, nil + }) + + ctx, cancel := context.WithCancel(testutils.Context(t)) + mockTimer := bhsmocks.NewTimer(t) + + mockBHS.On("StoreEarliest", ctx).Return(expectedError).Once() + mockTimer.On("After", expectedDuration).Return(func() <-chan time.Time { + c := make(chan time.Time) + close(c) + return c + }()).Once() + mockTimer.On("After", expectedDuration).Return(func() <-chan time.Time { + c := make(chan time.Time) + return c + }()).Run(func(args mock.Arguments) { + cancel() + }).Once() + mockLogger.On("Infow", "Starting heartbeat blockhash using storeEarliest every 10m0s").Once() + mockLogger.On("Infow", "storing heartbeat blockhash using storeEarliest", + "heartbeatPeriodSeconds", expectedDuration.Seconds()).Once() + mockLogger.On("Infow", "failed to store heartbeat blockhash using storeEarliest", + "heartbeatPeriodSeconds", expectedDuration.Seconds(), + "err", expectedError).Once() + require.Len(t, mockLogger.ExpectedCalls, 3) + require.Len(t, mockTimer.ExpectedCalls, 2) + defer mockTimer.AssertExpectations(t) + defer mockBHS.AssertExpectations(t) + defer mockLogger.AssertExpectations(t) + + feeder.StartHeartbeats(ctx, mockTimer) + }) + + t.Run("bhs_heartbeat_sad_path_heartbeat_0", func(t *testing.T) { + expectedDuration := 0 * time.Second + mockBHS := bhsmocks.NewBHS(t) + mockLogger := loggermocks.NewLogger(t) + feeder := NewFeeder( + mockLogger, + &TestCoordinator{}, // Not used for this test + mockBHS, + &mocklp.LogPoller{}, // Not used for this test + 0, + 25, // Not used for this test + 100, // Not used for this test + expectedDuration, + func(ctx context.Context) (uint64, error) { + return tests[0].latest, nil + }) + + mockTimer := bhsmocks.NewTimer(t) + mockLogger.On("Infow", "Not starting heartbeat blockhash using storeEarliest").Once() + require.Len(t, mockLogger.ExpectedCalls, 1) + require.Len(t, mockBHS.ExpectedCalls, 0) + require.Len(t, mockTimer.ExpectedCalls, 0) + defer mockTimer.AssertExpectations(t) + defer mockBHS.AssertExpectations(t) + defer mockLogger.AssertExpectations(t) + + feeder.StartHeartbeats(testutils.Context(t), mockTimer) + }) +} + func TestFeeder(t *testing.T) { for _, test := range tests { @@ -254,6 +381,7 @@ func TestFeeder(t *testing.T) { 0, test.wait, test.lookback, + 600*time.Second, func(ctx context.Context) (uint64, error) { return test.latest, nil }) @@ -346,6 +474,7 @@ func TestFeederWithLogPollerVRFv1(t *testing.T) { 0, test.wait, test.lookback, + 600*time.Second, func(ctx context.Context) (uint64, error) { return test.latest, nil }) @@ -442,6 +571,7 @@ func TestFeederWithLogPollerVRFv2(t *testing.T) { 0, test.wait, test.lookback, + 600*time.Second, func(ctx context.Context) (uint64, error) { return test.latest, nil }) @@ -538,6 +668,7 @@ func TestFeederWithLogPollerVRFv2Plus(t *testing.T) { 0, test.wait, test.lookback, + 600*time.Second, func(ctx context.Context) (uint64, error) { return test.latest, nil }) @@ -571,6 +702,7 @@ func TestFeeder_CachesStoredBlocks(t *testing.T) { 0, 100, 200, + 600*time.Second, func(ctx context.Context) (uint64, error) { return 250, nil }) diff --git a/core/services/blockhashstore/mocks/bhs.go b/core/services/blockhashstore/mocks/bhs.go new file mode 100644 index 00000000000..bf01e80ffdd --- /dev/null +++ b/core/services/blockhashstore/mocks/bhs.go @@ -0,0 +1,111 @@ +// Code generated by mockery v2.28.1. DO NOT EDIT. + +package mocks + +import ( + context "context" + + common "github.com/ethereum/go-ethereum/common" + + mock "github.com/stretchr/testify/mock" +) + +// BHS is an autogenerated mock type for the BHS type +type BHS struct { + mock.Mock +} + +// IsStored provides a mock function with given fields: ctx, blockNum +func (_m *BHS) IsStored(ctx context.Context, blockNum uint64) (bool, error) { + ret := _m.Called(ctx, blockNum) + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, uint64) (bool, error)); ok { + return rf(ctx, blockNum) + } + if rf, ok := ret.Get(0).(func(context.Context, uint64) bool); ok { + r0 = rf(ctx, blockNum) + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func(context.Context, uint64) error); ok { + r1 = rf(ctx, blockNum) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// IsTrusted provides a mock function with given fields: +func (_m *BHS) IsTrusted() bool { + ret := _m.Called() + + var r0 bool + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// Store provides a mock function with given fields: ctx, blockNum +func (_m *BHS) Store(ctx context.Context, blockNum uint64) error { + ret := _m.Called(ctx, blockNum) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, uint64) error); ok { + r0 = rf(ctx, blockNum) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// StoreEarliest provides a mock function with given fields: ctx +func (_m *BHS) StoreEarliest(ctx context.Context) error { + ret := _m.Called(ctx) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context) error); ok { + r0 = rf(ctx) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// StoreTrusted provides a mock function with given fields: ctx, blockNums, blockhashes, recentBlock, recentBlockhash +func (_m *BHS) StoreTrusted(ctx context.Context, blockNums []uint64, blockhashes []common.Hash, recentBlock uint64, recentBlockhash common.Hash) error { + ret := _m.Called(ctx, blockNums, blockhashes, recentBlock, recentBlockhash) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, []uint64, []common.Hash, uint64, common.Hash) error); ok { + r0 = rf(ctx, blockNums, blockhashes, recentBlock, recentBlockhash) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +type mockConstructorTestingTNewBHS interface { + mock.TestingT + Cleanup(func()) +} + +// NewBHS creates a new instance of BHS. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewBHS(t mockConstructorTestingTNewBHS) *BHS { + mock := &BHS{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/services/blockhashstore/mocks/timer.go b/core/services/blockhashstore/mocks/timer.go new file mode 100644 index 00000000000..722cd35f271 --- /dev/null +++ b/core/services/blockhashstore/mocks/timer.go @@ -0,0 +1,45 @@ +// Code generated by mockery v2.28.1. DO NOT EDIT. + +package mocks + +import ( + time "time" + + mock "github.com/stretchr/testify/mock" +) + +// Timer is an autogenerated mock type for the Timer type +type Timer struct { + mock.Mock +} + +// After provides a mock function with given fields: d +func (_m *Timer) After(d time.Duration) <-chan time.Time { + ret := _m.Called(d) + + var r0 <-chan time.Time + if rf, ok := ret.Get(0).(func(time.Duration) <-chan time.Time); ok { + r0 = rf(d) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(<-chan time.Time) + } + } + + return r0 +} + +type mockConstructorTestingTNewTimer interface { + mock.TestingT + Cleanup(func()) +} + +// NewTimer creates a new instance of Timer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewTimer(t mockConstructorTestingTNewTimer) *Timer { + mock := &Timer{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/services/blockhashstore/validate.go b/core/services/blockhashstore/validate.go index 157f6f2ec03..82b813a37f4 100644 --- a/core/services/blockhashstore/validate.go +++ b/core/services/blockhashstore/validate.go @@ -68,6 +68,10 @@ func ValidatedSpec(tomlString string) (job.Job, error) { if spec.RunTimeout == 0 { spec.RunTimeout = 30 * time.Second } + if spec.HeartbeatPeriod < 0 { + return jb, errors.New(`"heartbeatPeriod" must be greater than 0`) + } + // spec.HeartbeatPeriodTime == 0, default is heartbeat disabled // Validation if spec.WaitBlocks >= spec.LookbackBlocks { diff --git a/core/services/blockhashstore/validate_test.go b/core/services/blockhashstore/validate_test.go index 10eaae10d34..0b7110a7528 100644 --- a/core/services/blockhashstore/validate_test.go +++ b/core/services/blockhashstore/validate_test.go @@ -67,6 +67,27 @@ evmChainID = "4"`, require.NoError(t, err) require.Equal(t, int32(100), os.BlockhashStoreSpec.WaitBlocks) require.Equal(t, int32(200), os.BlockhashStoreSpec.LookbackBlocks) + require.Equal(t, time.Duration(0), os.BlockhashStoreSpec.HeartbeatPeriod) + require.Nil(t, os.BlockhashStoreSpec.FromAddresses) + require.Equal(t, 30*time.Second, os.BlockhashStoreSpec.PollPeriod) + require.Equal(t, 30*time.Second, os.BlockhashStoreSpec.RunTimeout) + }, + }, + { + name: "heartbeattimeset", + toml: ` +type = "blockhashstore" +name = "heartbeat-blocks-test" +coordinatorV1Address = "0x1F72B4A5DCf7CC6d2E38423bF2f4BFA7db97d139" +coordinatorV2Address = "0x2be990eE17832b59E0086534c5ea2459Aa75E38F" +blockhashStoreAddress = "0x3e20Cef636EdA7ba135bCbA4fe6177Bd3cE0aB17" +heartbeatPeriod = "650s" +evmChainID = "4"`, + assertion: func(t *testing.T, os job.Job, err error) { + require.NoError(t, err) + require.Equal(t, int32(100), os.BlockhashStoreSpec.WaitBlocks) + require.Equal(t, int32(200), os.BlockhashStoreSpec.LookbackBlocks) + require.Equal(t, time.Duration(650)*time.Second, os.BlockhashStoreSpec.HeartbeatPeriod) require.Nil(t, os.BlockhashStoreSpec.FromAddresses) require.Equal(t, 30*time.Second, os.BlockhashStoreSpec.PollPeriod) require.Equal(t, 30*time.Second, os.BlockhashStoreSpec.RunTimeout) diff --git a/core/services/job/job_orm_test.go b/core/services/job/job_orm_test.go index b885bf4f83f..bf81064e6a6 100644 --- a/core/services/job/job_orm_test.go +++ b/core/services/job/job_orm_test.go @@ -260,6 +260,7 @@ func TestORM(t *testing.T) { require.Equal(t, jb.BlockhashStoreSpec.CoordinatorV2PlusAddress, savedJob.BlockhashStoreSpec.CoordinatorV2PlusAddress) require.Equal(t, jb.BlockhashStoreSpec.WaitBlocks, savedJob.BlockhashStoreSpec.WaitBlocks) require.Equal(t, jb.BlockhashStoreSpec.LookbackBlocks, savedJob.BlockhashStoreSpec.LookbackBlocks) + require.Equal(t, jb.BlockhashStoreSpec.HeartbeatPeriod, savedJob.BlockhashStoreSpec.HeartbeatPeriod) require.Equal(t, jb.BlockhashStoreSpec.BlockhashStoreAddress, savedJob.BlockhashStoreSpec.BlockhashStoreAddress) require.Equal(t, jb.BlockhashStoreSpec.TrustedBlockhashStoreAddress, savedJob.BlockhashStoreSpec.TrustedBlockhashStoreAddress) require.Equal(t, jb.BlockhashStoreSpec.TrustedBlockhashStoreBatchSize, savedJob.BlockhashStoreSpec.TrustedBlockhashStoreBatchSize) diff --git a/core/services/job/models.go b/core/services/job/models.go index 03015aa1a7b..5787ce5fb5f 100644 --- a/core/services/job/models.go +++ b/core/services/job/models.go @@ -573,6 +573,12 @@ type BlockhashStoreSpec struct { // WaitBlocks defines the minimum age of blocks whose hashes should be stored. WaitBlocks int32 `toml:"waitBlocks"` + // HeartbeatPeriodTime defines the number of seconds by which we "heartbeat store" + // a blockhash into the blockhash store contract. + // This is so that we always have a blockhash to anchor to in the event we need to do a + // backwards mode on the contract. + HeartbeatPeriod time.Duration `toml:"heartbeatPeriod"` + // BlockhashStoreAddress is the address of the BlockhashStore contract to store blockhashes // into. BlockhashStoreAddress ethkey.EIP55Address `toml:"blockhashStoreAddress"` diff --git a/core/services/job/orm.go b/core/services/job/orm.go index 190df2f4966..369ce039ad4 100644 --- a/core/services/job/orm.go +++ b/core/services/job/orm.go @@ -388,8 +388,8 @@ func (o *orm) CreateJob(jb *Job, qopts ...pg.QOpt) error { return errors.New("evm chain id must be defined") } var specID int32 - sql := `INSERT INTO blockhash_store_specs (coordinator_v1_address, coordinator_v2_address, coordinator_v2_plus_address, trusted_blockhash_store_address, trusted_blockhash_store_batch_size, wait_blocks, lookback_blocks, blockhash_store_address, poll_period, run_timeout, evm_chain_id, from_addresses, created_at, updated_at) - VALUES (:coordinator_v1_address, :coordinator_v2_address, :coordinator_v2_plus_address, :trusted_blockhash_store_address, :trusted_blockhash_store_batch_size, :wait_blocks, :lookback_blocks, :blockhash_store_address, :poll_period, :run_timeout, :evm_chain_id, :from_addresses, NOW(), NOW()) + sql := `INSERT INTO blockhash_store_specs (coordinator_v1_address, coordinator_v2_address, coordinator_v2_plus_address, trusted_blockhash_store_address, trusted_blockhash_store_batch_size, wait_blocks, lookback_blocks, heartbeat_period, blockhash_store_address, poll_period, run_timeout, evm_chain_id, from_addresses, created_at, updated_at) + VALUES (:coordinator_v1_address, :coordinator_v2_address, :coordinator_v2_plus_address, :trusted_blockhash_store_address, :trusted_blockhash_store_batch_size, :wait_blocks, :lookback_blocks, :heartbeat_period, :blockhash_store_address, :poll_period, :run_timeout, :evm_chain_id, :from_addresses, NOW(), NOW()) RETURNING id;` if err := pg.PrepareQueryRowx(tx, sql, &specID, toBlockhashStoreSpecRow(jb.BlockhashStoreSpec)); err != nil { return errors.Wrap(err, "failed to create BlockhashStore spec") diff --git a/core/services/vrf/v1/integration_test.go b/core/services/vrf/v1/integration_test.go index 0a57c72ef17..14cbb36c9d6 100644 --- a/core/services/vrf/v1/integration_test.go +++ b/core/services/vrf/v1/integration_test.go @@ -150,7 +150,7 @@ func TestIntegration_VRF_WithBHS(t *testing.T) { // Create BHS Job and start it bhsJob := vrftesthelpers.CreateAndStartBHSJob(t, sendingKeys, app, cu.BHSContractAddress.String(), - cu.RootContractAddress.String(), "", "", "", 0, 200) + cu.RootContractAddress.String(), "", "", "", 0, 200, 0) // Ensure log poller is ready and has all logs. require.NoError(t, app.GetRelayers().LegacyEVMChains().Slice()[0].LogPoller().Ready()) diff --git a/core/services/vrf/v2/bhs_feeder_test.go b/core/services/vrf/v2/bhs_feeder_test.go new file mode 100644 index 00000000000..b843dbca9eb --- /dev/null +++ b/core/services/vrf/v2/bhs_feeder_test.go @@ -0,0 +1,105 @@ +package v2_test + +import ( + "testing" + "time" + + "github.com/smartcontractkit/chainlink/v2/core/assets" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/config/toml" + "github.com/smartcontractkit/chainlink/v2/core/internal/cltest" + "github.com/smartcontractkit/chainlink/v2/core/internal/cltest/heavyweight" + "github.com/smartcontractkit/chainlink/v2/core/internal/testutils" + "github.com/smartcontractkit/chainlink/v2/core/services/chainlink" + "github.com/smartcontractkit/chainlink/v2/core/services/vrf/vrfcommon" + "github.com/smartcontractkit/chainlink/v2/core/services/vrf/vrftesthelpers" + "github.com/smartcontractkit/chainlink/v2/core/store/models" + + "github.com/stretchr/testify/require" +) + +func TestStartHeartbeats(t *testing.T) { + t.Parallel() + ownerKey := cltest.MustGenerateRandomKey(t) + uni := newVRFCoordinatorV2Universe(t, ownerKey, 2) + + vrfKey := cltest.MustGenerateRandomKey(t) + sendEth(t, ownerKey, uni.backend, vrfKey.Address, 10) + gasLanePriceWei := assets.GWei(1) + gasLimit := 3_000_000 + + consumers := uni.vrfConsumers + + // generate n BHS keys to make sure BHS job rotates sending keys + var bhsKeyAddresses []string + var keySpecificOverrides []toml.KeySpecific + var keys []interface{} + for i := 0; i < len(consumers); i++ { + bhsKey := cltest.MustGenerateRandomKey(t) + bhsKeyAddresses = append(bhsKeyAddresses, bhsKey.Address.String()) + keys = append(keys, bhsKey) + keySpecificOverrides = append(keySpecificOverrides, toml.KeySpecific{ + Key: ptr(bhsKey.EIP55Address), + GasEstimator: toml.KeySpecificGasEstimator{PriceMax: gasLanePriceWei}, + }) + sendEth(t, ownerKey, uni.backend, bhsKey.Address, 10) + } + keySpecificOverrides = append(keySpecificOverrides, toml.KeySpecific{ + // Gas lane. + Key: ptr(vrfKey.EIP55Address), + GasEstimator: toml.KeySpecificGasEstimator{PriceMax: gasLanePriceWei}, + }) + + keys = append(keys, ownerKey, vrfKey) + + config, _ := heavyweight.FullTestDBV2(t, "vrfv2_needs_blockhash_store", func(c *chainlink.Config, s *chainlink.Secrets) { + simulatedOverrides(t, gasLanePriceWei, keySpecificOverrides...)(c, s) + c.EVM[0].MinIncomingConfirmations = ptr[uint32](2) + c.Feature.LogPoller = ptr(true) + c.EVM[0].FinalityDepth = ptr[uint32](2) + c.EVM[0].GasEstimator.LimitDefault = ptr(uint32(gasLimit)) + c.EVM[0].LogPollInterval = models.MustNewDuration(time.Second) + }) + + heartbeatPeriod := 5 * time.Second + + t.Run("bhs_feeder_startheartbeats_happy_path", func(tt *testing.T) { + coordinatorAddress := uni.rootContractAddress + vrfVersion := vrfcommon.V2 + + app := cltest.NewApplicationWithConfigV2AndKeyOnSimulatedBlockchain(t, config, uni.backend, keys...) + require.NoError(t, app.Start(testutils.Context(t))) + + var ( + v2CoordinatorAddress string + v2PlusCoordinatorAddress string + ) + + if vrfVersion == vrfcommon.V2 { + v2CoordinatorAddress = coordinatorAddress.String() + } else if vrfVersion == vrfcommon.V2Plus { + v2PlusCoordinatorAddress = coordinatorAddress.String() + } + + _ = vrftesthelpers.CreateAndStartBHSJob( + t, bhsKeyAddresses, app, uni.bhsContractAddress.String(), "", + v2CoordinatorAddress, v2PlusCoordinatorAddress, "", 0, 200, heartbeatPeriod) + + // Ensure log poller is ready and has all logs. + require.NoError(t, app.GetRelayers().LegacyEVMChains().Slice()[0].LogPoller().Ready()) + require.NoError(t, app.GetRelayers().LegacyEVMChains().Slice()[0].LogPoller().Replay(testutils.Context(t), 1)) + + initTxns := 260 + // Wait 260 blocks. + for i := 0; i < initTxns; i++ { + uni.backend.Commit() + } + diff := heartbeatPeriod + 1*time.Second + t.Logf("Sleeping %.2f seconds before checking blockhash in BHS added by BHS_Heartbeats_Service\n", diff.Seconds()) + time.Sleep(diff) + // storeEarliest in BHS contract stores blocktip - 256 in the Blockhash Store (BHS) + // before the initTxns:260 txns sent by the loop above, 18 txns are sent by + // newVRFCoordinatorV2Universe method. block tip is initTxns + 18 + blockTip := initTxns + 18 + verifyBlockhashStored(t, uni.coordinatorV2UniverseCommon, uint64(blockTip-256)) + }) +} diff --git a/core/services/vrf/v2/integration_helpers_test.go b/core/services/vrf/v2/integration_helpers_test.go index 74d7175e08b..81f3edfbb2e 100644 --- a/core/services/vrf/v2/integration_helpers_test.go +++ b/core/services/vrf/v2/integration_helpers_test.go @@ -241,7 +241,7 @@ func testMultipleConsumersNeedBHS( _ = vrftesthelpers.CreateAndStartBHSJob( t, bhsKeyAddresses, app, uni.bhsContractAddress.String(), "", - v2CoordinatorAddress, v2PlusCoordinatorAddress, "", 0, 200) + v2CoordinatorAddress, v2PlusCoordinatorAddress, "", 0, 200, 0) // Ensure log poller is ready and has all logs. require.NoError(t, app.GetRelayers().LegacyEVMChains().Slice()[0].LogPoller().Ready()) @@ -386,7 +386,7 @@ func testMultipleConsumersNeedTrustedBHS( _ = vrftesthelpers.CreateAndStartBHSJob( t, bhsKeyAddressesStrings, app, "", "", - v2CoordinatorAddress, v2PlusCoordinatorAddress, uni.trustedBhsContractAddress.String(), 20, 1000) + v2CoordinatorAddress, v2PlusCoordinatorAddress, uni.trustedBhsContractAddress.String(), 20, 1000, 0) // Ensure log poller is ready and has all logs. chain := app.GetRelayers().LegacyEVMChains().Slice()[0] diff --git a/core/services/vrf/vrftesthelpers/helpers.go b/core/services/vrf/vrftesthelpers/helpers.go index d40e2bc747f..15af16f6fd9 100644 --- a/core/services/vrf/vrftesthelpers/helpers.go +++ b/core/services/vrf/vrftesthelpers/helpers.go @@ -51,6 +51,7 @@ func CreateAndStartBHSJob( app *cltest.TestApplication, bhsAddress, coordinatorV1Address, coordinatorV2Address, coordinatorV2PlusAddress string, trustedBlockhashStoreAddress string, trustedBlockhashStoreBatchSize int32, lookback int, + heartbeatPeriod time.Duration, ) job.Job { jid := uuid.New() s := testspecs.GenerateBlockhashStoreSpec(testspecs.BlockhashStoreSpecParams{ @@ -61,6 +62,7 @@ func CreateAndStartBHSJob( CoordinatorV2PlusAddress: coordinatorV2PlusAddress, WaitBlocks: 100, LookbackBlocks: lookback, + HeartbeatPeriod: heartbeatPeriod, BlockhashStoreAddress: bhsAddress, TrustedBlockhashStoreAddress: trustedBlockhashStoreAddress, TrustedBlockhashStoreBatchSize: trustedBlockhashStoreBatchSize, diff --git a/core/store/migrate/migrations/0197_add_heartbeat_to_bhs_feeder.sql b/core/store/migrate/migrations/0197_add_heartbeat_to_bhs_feeder.sql new file mode 100644 index 00000000000..74a7a74cd4b --- /dev/null +++ b/core/store/migrate/migrations/0197_add_heartbeat_to_bhs_feeder.sql @@ -0,0 +1,5 @@ +-- +goose Up +ALTER TABLE blockhash_store_specs ADD COLUMN heartbeat_period bigint DEFAULT 0 NOT NULL; + +-- +goose Down +ALTER TABLE blockhash_store_specs DROP COLUMN heartbeat_period; diff --git a/core/testdata/testspecs/v2_specs.go b/core/testdata/testspecs/v2_specs.go index f2669a6fe9d..3dd7c675d50 100644 --- a/core/testdata/testspecs/v2_specs.go +++ b/core/testdata/testspecs/v2_specs.go @@ -606,6 +606,7 @@ type BlockhashStoreSpecParams struct { CoordinatorV2Address string CoordinatorV2PlusAddress string WaitBlocks int + HeartbeatPeriod time.Duration LookbackBlocks int BlockhashStoreAddress string TrustedBlockhashStoreAddress string @@ -704,11 +705,12 @@ pollPeriod = "%s" runTimeout = "%s" evmChainID = "%d" fromAddresses = %s +heartbeatPeriod = "%s" ` toml := fmt.Sprintf(template, params.Name, params.CoordinatorV1Address, params.CoordinatorV2Address, params.CoordinatorV2PlusAddress, params.WaitBlocks, params.LookbackBlocks, params.BlockhashStoreAddress, params.TrustedBlockhashStoreAddress, params.TrustedBlockhashStoreBatchSize, params.PollPeriod.String(), params.RunTimeout.String(), - params.EVMChainID, formattedFromAddresses) + params.EVMChainID, formattedFromAddresses, params.HeartbeatPeriod.String()) return BlockhashStoreSpec{BlockhashStoreSpecParams: params, toml: toml} } diff --git a/core/web/presenters/job.go b/core/web/presenters/job.go index 01e01c9fa1e..b1f42ebc68f 100644 --- a/core/web/presenters/job.go +++ b/core/web/presenters/job.go @@ -330,6 +330,7 @@ type BlockhashStoreSpec struct { CoordinatorV2PlusAddress *ethkey.EIP55Address `json:"coordinatorV2PlusAddress"` WaitBlocks int32 `json:"waitBlocks"` LookbackBlocks int32 `json:"lookbackBlocks"` + HeartbeatPeriod time.Duration `json:"heartbeatPeriod"` BlockhashStoreAddress ethkey.EIP55Address `json:"blockhashStoreAddress"` TrustedBlockhashStoreAddress *ethkey.EIP55Address `json:"trustedBlockhashStoreAddress"` TrustedBlockhashStoreBatchSize int32 `json:"trustedBlockhashStoreBatchSize"` @@ -349,6 +350,7 @@ func NewBlockhashStoreSpec(spec *job.BlockhashStoreSpec) *BlockhashStoreSpec { CoordinatorV2PlusAddress: spec.CoordinatorV2PlusAddress, WaitBlocks: spec.WaitBlocks, LookbackBlocks: spec.LookbackBlocks, + HeartbeatPeriod: spec.HeartbeatPeriod, BlockhashStoreAddress: spec.BlockhashStoreAddress, TrustedBlockhashStoreAddress: spec.TrustedBlockhashStoreAddress, TrustedBlockhashStoreBatchSize: spec.TrustedBlockhashStoreBatchSize, diff --git a/core/web/presenters/job_test.go b/core/web/presenters/job_test.go index a069a3e1ba5..bb79c8e953c 100644 --- a/core/web/presenters/job_test.go +++ b/core/web/presenters/job_test.go @@ -482,6 +482,7 @@ func TestJob(t *testing.T) { CoordinatorV2PlusAddress: &v2PlusCoordAddress, WaitBlocks: 123, LookbackBlocks: 223, + HeartbeatPeriod: 375 * time.Second, BlockhashStoreAddress: contractAddress, PollPeriod: 25 * time.Second, RunTimeout: 10 * time.Second, @@ -526,6 +527,7 @@ func TestJob(t *testing.T) { "coordinatorV2PlusAddress": "0x92B5e28Ac583812874e4271380c7d070C5FB6E6b", "waitBlocks": 123, "lookbackBlocks": 223, + "heartbeatPeriod": 375000000000, "blockhashStoreAddress": "0x9E40733cC9df84636505f4e6Db28DCa0dC5D1bba", "trustedBlockhashStoreAddress": "0x0ad9FE7a58216242a8475ca92F222b0640E26B63", "trustedBlockhashStoreBatchSize": 20, diff --git a/core/web/resolver/spec.go b/core/web/resolver/spec.go index fa3e5a14fa0..48040d118a7 100644 --- a/core/web/resolver/spec.go +++ b/core/web/resolver/spec.go @@ -794,6 +794,11 @@ func (b *BlockhashStoreSpecResolver) LookbackBlocks() int32 { return b.spec.LookbackBlocks } +// HeartbeatPeriod returns the job's HeartbeatPeriod param. +func (b *BlockhashStoreSpecResolver) HeartbeatPeriod() string { + return b.spec.HeartbeatPeriod.String() +} + // BlockhashStoreAddress returns the job's BlockhashStoreAddress param. func (b *BlockhashStoreSpecResolver) BlockhashStoreAddress() string { return b.spec.BlockhashStoreAddress.String() diff --git a/core/web/resolver/spec_test.go b/core/web/resolver/spec_test.go index c4efbb65825..04bfffbe05e 100644 --- a/core/web/resolver/spec_test.go +++ b/core/web/resolver/spec_test.go @@ -779,6 +779,7 @@ func TestResolver_BlockhashStoreSpec(t *testing.T) { RunTimeout: 37 * time.Second, WaitBlocks: 100, LookbackBlocks: 200, + HeartbeatPeriod: 450 * time.Second, BlockhashStoreAddress: blockhashStoreAddress, TrustedBlockhashStoreAddress: &trustedBlockhashStoreAddress, TrustedBlockhashStoreBatchSize: trustedBlockhashStoreBatchSize, @@ -805,6 +806,7 @@ func TestResolver_BlockhashStoreSpec(t *testing.T) { blockhashStoreAddress trustedBlockhashStoreAddress trustedBlockhashStoreBatchSize + heartbeatPeriod } } } @@ -828,7 +830,8 @@ func TestResolver_BlockhashStoreSpec(t *testing.T) { "lookbackBlocks": 200, "blockhashStoreAddress": "0xb26A6829D454336818477B946f03Fb21c9706f3A", "trustedBlockhashStoreAddress": "0x0ad9FE7a58216242a8475ca92F222b0640E26B63", - "trustedBlockhashStoreBatchSize": 20 + "trustedBlockhashStoreBatchSize": 20, + "heartbeatPeriod": "7m30s" } } } diff --git a/core/web/schema/type/spec.graphql b/core/web/schema/type/spec.graphql index c92a1dd1ea9..cdcbabf9ef0 100644 --- a/core/web/schema/type/spec.graphql +++ b/core/web/schema/type/spec.graphql @@ -128,6 +128,7 @@ type BlockhashStoreSpec { blockhashStoreAddress: String! trustedBlockhashStoreAddress: String trustedBlockhashStoreBatchSize: Int! + heartbeatPeriod: String! pollPeriod: String! runTimeout: String! evmChainID: String