diff --git a/core/services/ocr2/plugins/ccip/ccipcommit/ocr2.go b/core/services/ocr2/plugins/ccip/ccipcommit/ocr2.go index 3de4cb0084..fbef6a5fdd 100644 --- a/core/services/ocr2/plugins/ccip/ccipcommit/ocr2.go +++ b/core/services/ocr2/plugins/ccip/ccipcommit/ocr2.go @@ -15,6 +15,7 @@ import ( "github.com/smartcontractkit/libocr/offchainreporting2plus/types" "github.com/smartcontractkit/chainlink-common/pkg/utils/mathutil" + "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/cciptypes" "github.com/smartcontractkit/chainlink/v2/core/logger" @@ -98,13 +99,8 @@ func (r *CommitReportingPlugin) Query(context.Context, types.ReportTimestamp) (t // the observation will be considered invalid and rejected. func (r *CommitReportingPlugin) Observation(ctx context.Context, epochAndRound types.ReportTimestamp, _ types.Query) (types.Observation, error) { lggr := r.lggr.Named("CommitObservation") - // If the commit store is down the protocol should halt. - down, err := r.commitStoreReader.IsDown(ctx) - if err != nil { - return nil, errors.Wrap(err, "isDown check errored") - } - if down { - return nil, ccip.ErrCommitStoreIsDown + if err := ccipcommon.VerifyNotDown(ctx, r.lggr, r.commitStoreReader, r.onRampReader); err != nil { + return nil, err } r.inflightReports.expire(lggr) diff --git a/core/services/ocr2/plugins/ccip/ccipcommit/ocr2_test.go b/core/services/ocr2/plugins/ccip/ccipcommit/ocr2_test.go index 11f58568b0..255c15bacb 100644 --- a/core/services/ocr2/plugins/ccip/ccipcommit/ocr2_test.go +++ b/core/services/ocr2/plugins/ccip/ccipcommit/ocr2_test.go @@ -51,14 +51,15 @@ func TestCommitReportingPlugin_Observation(t *testing.T) { someTokenAddr := ccipcalc.HexToAddress("2000") testCases := []struct { - name string - epochAndRound types.ReportTimestamp - commitStoreIsPaused bool - commitStoreSeqNum uint64 - tokenPrices map[cciptypes.Address]*big.Int - sendReqs []cciptypes.EVM2EVMMessageWithTxMeta - tokenDecimals map[cciptypes.Address]uint8 - fee *big.Int + name string + epochAndRound types.ReportTimestamp + commitStorePaused bool + sourceChainCursed bool + commitStoreSeqNum uint64 + tokenPrices map[cciptypes.Address]*big.Int + sendReqs []cciptypes.EVM2EVMMessageWithTxMeta + tokenDecimals map[cciptypes.Address]uint8 + fee *big.Int expErr bool expObs ccip.CommitObservation @@ -90,9 +91,16 @@ func TestCommitReportingPlugin_Observation(t *testing.T) { }, }, { - name: "commit store is down", - commitStoreIsPaused: true, - expErr: true, + name: "commit store is down", + commitStorePaused: true, + sourceChainCursed: false, + expErr: true, + }, + { + name: "source chain is cursed", + commitStorePaused: false, + sourceChainCursed: true, + expErr: true, }, } @@ -100,12 +108,13 @@ func TestCommitReportingPlugin_Observation(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { commitStoreReader := ccipdatamocks.NewCommitStoreReader(t) - commitStoreReader.On("IsDown", ctx).Return(tc.commitStoreIsPaused, nil) - if !tc.commitStoreIsPaused { + commitStoreReader.On("IsDown", ctx).Return(tc.commitStorePaused, nil) + if !tc.commitStorePaused && !tc.sourceChainCursed { commitStoreReader.On("GetExpectedNextSequenceNumber", ctx).Return(tc.commitStoreSeqNum, nil) } onRampReader := ccipdatamocks.NewOnRampReader(t) + onRampReader.On("IsSourceCursed", ctx).Return(tc.sourceChainCursed, nil) if len(tc.sendReqs) > 0 { onRampReader.On("GetSendRequestsBetweenSeqNums", ctx, tc.commitStoreSeqNum, tc.commitStoreSeqNum+OnRampMessagesScanLimit, true). Return(tc.sendReqs, nil) diff --git a/core/services/ocr2/plugins/ccip/ccipexec/ocr2.go b/core/services/ocr2/plugins/ccip/ccipexec/ocr2.go index d941351010..6f6a384fc2 100644 --- a/core/services/ocr2/plugins/ccip/ccipexec/ocr2.go +++ b/core/services/ocr2/plugins/ccip/ccipexec/ocr2.go @@ -97,12 +97,8 @@ func (r *ExecutionReportingPlugin) Query(context.Context, types.ReportTimestamp) func (r *ExecutionReportingPlugin) Observation(ctx context.Context, timestamp types.ReportTimestamp, query types.Query) (types.Observation, error) { lggr := r.lggr.Named("ExecutionObservation") - down, err := r.commitStoreReader.IsDown(ctx) - if err != nil { - return nil, errors.Wrap(err, "isDown check errored") - } - if down { - return nil, ccip.ErrCommitStoreIsDown + if err := ccipcommon.VerifyNotDown(ctx, r.lggr, r.commitStoreReader, r.onRampReader); err != nil { + return nil, err } // Expire any inflight reports. r.inflightReports.expire(lggr) diff --git a/core/services/ocr2/plugins/ccip/ccipexec/ocr2_test.go b/core/services/ocr2/plugins/ccip/ccipexec/ocr2_test.go index 5e841fb7cc..5a8b7b2851 100644 --- a/core/services/ocr2/plugins/ccip/ccipexec/ocr2_test.go +++ b/core/services/ocr2/plugins/ccip/ccipexec/ocr2_test.go @@ -48,6 +48,7 @@ func TestExecutionReportingPlugin_Observation(t *testing.T) { testCases := []struct { name string commitStorePaused bool + sourceChainCursed bool inflightReports []InflightInternalExecutionReport unexpiredReports []cciptypes.CommitStoreReportWithTxMeta sendRequests []cciptypes.EVM2EVMMessageWithTxMeta @@ -61,11 +62,19 @@ func TestExecutionReportingPlugin_Observation(t *testing.T) { { name: "commit store is down", commitStorePaused: true, + sourceChainCursed: false, + expErr: true, + }, + { + name: "source chain is cursed", + commitStorePaused: false, + sourceChainCursed: true, expErr: true, }, { name: "happy flow", commitStorePaused: false, + sourceChainCursed: false, inflightReports: []InflightInternalExecutionReport{}, unexpiredReports: []cciptypes.CommitStoreReportWithTxMeta{ { @@ -149,6 +158,7 @@ func TestExecutionReportingPlugin_Observation(t *testing.T) { p.offRampReader = mockOffRampReader mockOnRampReader := ccipdatamocks.NewOnRampReader(t) + mockOnRampReader.On("IsSourceCursed", ctx).Return(tc.sourceChainCursed, nil).Maybe() mockOnRampReader.On("GetSendRequestsBetweenSeqNums", ctx, mock.Anything, mock.Anything, false). Return(tc.sendRequests, nil).Maybe() sourcePriceRegistryAddress := cciptypes.Address(utils.RandomAddress().String()) diff --git a/core/services/ocr2/plugins/ccip/cciptypes/onramp.go b/core/services/ocr2/plugins/ccip/cciptypes/onramp.go index 70e849d800..d75cf7f810 100644 --- a/core/services/ocr2/plugins/ccip/cciptypes/onramp.go +++ b/core/services/ocr2/plugins/ccip/cciptypes/onramp.go @@ -54,7 +54,9 @@ type OnRampReader interface { // If some requests do not exist in the provided sequence numbers range they will not be part of the response. // It's the responsibility of the caller to validate whether all the requests exist or not. GetSendRequestsBetweenSeqNums(ctx context.Context, seqNumMin, seqNumMax uint64, finalized bool) ([]EVM2EVMMessageWithTxMeta, error) - + // IsSourceCursed returns true if the source chain is cursed. OnRamp communicates with the underlying RMN + // to verify if source chain was cursed or not. + IsSourceCursed(ctx context.Context) (bool, error) // RouterAddress returns the router address that is configured on the onRamp RouterAddress() (Address, error) diff --git a/core/services/ocr2/plugins/ccip/internal/cache/once.go b/core/services/ocr2/plugins/ccip/internal/cache/once.go new file mode 100644 index 0000000000..713501a03e --- /dev/null +++ b/core/services/ocr2/plugins/ccip/internal/cache/once.go @@ -0,0 +1,38 @@ +package cache + +import ( + "context" + "sync" +) + +type OnceCtxFunction[T any] func(ctx context.Context) (T, error) + +// CallOnceOnNoError returns a new function that wraps the given function f with caching capabilities. +// If f returns an error, the result is not cached, allowing f to be retried on subsequent calls. +// Use case for that is to avoid caching an error forever in case of transient errors (e.g. flaky RPC) +func CallOnceOnNoError[T any](f OnceCtxFunction[T]) OnceCtxFunction[T] { + var ( + mu sync.Mutex + value T + err error + called bool + ) + + return func(ctx context.Context) (T, error) { + mu.Lock() + defer mu.Unlock() + + // If the function has been called successfully before, return the cached result. + if called && err == nil { + return value, nil + } + + // Call the function and cache the result only if there is no error. + value, err = f(ctx) + if err == nil { + called = true + } + + return value, err + } +} diff --git a/core/services/ocr2/plugins/ccip/internal/cache/once_test.go b/core/services/ocr2/plugins/ccip/internal/cache/once_test.go new file mode 100644 index 0000000000..6ba2fbddd5 --- /dev/null +++ b/core/services/ocr2/plugins/ccip/internal/cache/once_test.go @@ -0,0 +1,83 @@ +package cache + +import ( + "context" + "errors" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" +) + +// TestCallOnceOnNoErrorCachingSuccess tests caching behavior when the function succeeds. +func TestCallOnceOnNoErrorCachingSuccess(t *testing.T) { + callCount := 0 + testFunc := func(ctx context.Context) (string, error) { + callCount++ + return "test result", nil + } + + cachedFunc := CallOnceOnNoError(testFunc) + + // Call the function twice. + _, err := cachedFunc(tests.Context(t)) + assert.NoError(t, err, "Expected no error on the first call") + + _, err = cachedFunc(tests.Context(t)) + assert.NoError(t, err, "Expected no error on the second call") + + assert.Equal(t, 1, callCount, "Function should be called exactly once") +} + +// TestCallOnceOnNoErrorCachingError tests that the function is retried after an error. +func TestCallOnceOnNoErrorCachingError(t *testing.T) { + callCount := 0 + testFunc := func(ctx context.Context) (string, error) { + callCount++ + if callCount == 1 { + return "", errors.New("test error") + } + return "test result", nil + } + + cachedFunc := CallOnceOnNoError(testFunc) + + // First call should fail. + _, err := cachedFunc(tests.Context(t)) + require.Error(t, err, "Expected an error on the first call") + + // Second call should succeed. + r, err := cachedFunc(tests.Context(t)) + assert.NoError(t, err, "Expected no error on the second call") + assert.Equal(t, "test result", r) + assert.Equal(t, 2, callCount, "Function should be called exactly twice") +} + +// TestCallOnceOnNoErrorCachingConcurrency tests that the function works correctly under concurrent access. +func TestCallOnceOnNoErrorCachingConcurrency(t *testing.T) { + var wg sync.WaitGroup + callCount := 0 + testFunc := func(ctx context.Context) (string, error) { + callCount++ + return "test result", nil + } + + cachedFunc := CallOnceOnNoError(testFunc) + + // Simulate concurrent calls. + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, err := cachedFunc(tests.Context(t)) + assert.NoError(t, err, "Expected no error in concurrent execution") + }() + } + + wg.Wait() + + assert.Equal(t, 1, callCount, "Function should be called exactly once despite concurrent calls") +} diff --git a/core/services/ocr2/plugins/ccip/internal/ccipcommon/shortcuts.go b/core/services/ocr2/plugins/ccip/internal/ccipcommon/shortcuts.go index 9cdb93d49b..6d8993a499 100644 --- a/core/services/ocr2/plugins/ccip/internal/ccipcommon/shortcuts.go +++ b/core/services/ocr2/plugins/ccip/internal/ccipcommon/shortcuts.go @@ -5,9 +5,12 @@ import ( "encoding/hex" "fmt" + "github.com/pkg/errors" "golang.org/x/sync/errgroup" "github.com/smartcontractkit/chainlink/v2/core/chains/evm/logpoller" + "github.com/smartcontractkit/chainlink/v2/core/logger" + "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/cciptypes" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal/ccipdata" ) @@ -73,3 +76,41 @@ func FlattenUniqueSlice[T comparable](slices ...[]T) []T { } return flattened } + +// VerifyNotDown returns error if the commitStore is down (paused or destination cursed) or if the source chain is cursed +// Both RPCs are called in parallel to save some time. These calls cannot be batched because they target different chains. +func VerifyNotDown(ctx context.Context, lggr logger.Logger, commitStore ccipdata.CommitStoreReader, onRamp ccipdata.OnRampReader) error { + var ( + eg = new(errgroup.Group) + isDown bool + isCursed bool + ) + + eg.Go(func() error { + var err error + isDown, err = commitStore.IsDown(ctx) + if err != nil { + return errors.Wrap(err, "commitStore isDown check errored") + } + return nil + }) + + eg.Go(func() error { + var err error + isCursed, err = onRamp.IsSourceCursed(ctx) + if err != nil { + return errors.Wrap(err, "onRamp isSourceCursed errored") + } + return nil + }) + + if err := eg.Wait(); err != nil { + return err + } + + if isDown || isCursed { + lggr.Errorf("Source chain is cursed or CommitStore is down", "isDown", isDown, "isCursed", isCursed) + return ccip.ErrChainPausedOrCursed + } + return nil +} diff --git a/core/services/ocr2/plugins/ccip/internal/ccipcommon/shortcuts_test.go b/core/services/ocr2/plugins/ccip/internal/ccipcommon/shortcuts_test.go index 4ac9fa708b..870f83b7e0 100644 --- a/core/services/ocr2/plugins/ccip/internal/ccipcommon/shortcuts_test.go +++ b/core/services/ocr2/plugins/ccip/internal/ccipcommon/shortcuts_test.go @@ -1,6 +1,7 @@ package ccipcommon import ( + "errors" "math/rand" "strconv" "testing" @@ -8,7 +9,11 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/assert" + "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" + + "github.com/smartcontractkit/chainlink/v2/core/logger" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/cciptypes" + "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/internal/ccipdata/mocks" ) func TestGetMessageIDsAsHexString(t *testing.T) { @@ -59,3 +64,59 @@ func TestFlattenUniqueSlice(t *testing.T) { }) } } + +func TestVerifyNotDown(t *testing.T) { + ctx := tests.Context(t) + + testCases := []struct { + name string + commitStoreDown bool + commitStoreErr error + onRampCursed bool + onRampErr error + expectedErr bool + }{ + { + name: "Neither down nor cursed", + expectedErr: false, + }, + { + name: "CommitStore is down", + commitStoreDown: true, + expectedErr: true, + }, + { + name: "OnRamp is cursed", + onRampCursed: true, + expectedErr: true, + }, + { + name: "CommitStore error", + commitStoreErr: errors.New("commit store error"), + expectedErr: true, + }, + { + name: "OnRamp error", + onRampErr: errors.New("onramp error"), + expectedErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mockCommitStore := mocks.NewCommitStoreReader(t) + mockOnRamp := mocks.NewOnRampReader(t) + + mockCommitStore.On("IsDown", ctx).Return(tc.commitStoreDown, tc.commitStoreErr) + mockOnRamp.On("IsSourceCursed", ctx).Return(tc.onRampCursed, tc.onRampErr) + + err := VerifyNotDown(ctx, logger.TestLogger(t), mockCommitStore, mockOnRamp) + + if tc.expectedErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/core/services/ocr2/plugins/ccip/internal/ccipdata/mocks/onramp_reader_mock.go b/core/services/ocr2/plugins/ccip/internal/ccipdata/mocks/onramp_reader_mock.go index 7dc787eec1..bb0ee2530d 100644 --- a/core/services/ocr2/plugins/ccip/internal/ccipdata/mocks/onramp_reader_mock.go +++ b/core/services/ocr2/plugins/ccip/internal/ccipdata/mocks/onramp_reader_mock.go @@ -101,6 +101,34 @@ func (_m *OnRampReader) GetSendRequestsBetweenSeqNums(ctx context.Context, seqNu return r0, r1 } +// IsSourceCursed provides a mock function with given fields: ctx +func (_m *OnRampReader) IsSourceCursed(ctx context.Context) (bool, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for IsSourceCursed") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (bool, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) bool); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // RouterAddress provides a mock function with given fields: func (_m *OnRampReader) RouterAddress() (cciptypes.Address, error) { ret := _m.Called() diff --git a/core/services/ocr2/plugins/ccip/internal/ccipdata/v1_0_0/onramp.go b/core/services/ocr2/plugins/ccip/internal/ccipdata/v1_0_0/onramp.go index 8be82fb555..2ff76f2718 100644 --- a/core/services/ocr2/plugins/ccip/internal/ccipdata/v1_0_0/onramp.go +++ b/core/services/ocr2/plugins/ccip/internal/ccipdata/v1_0_0/onramp.go @@ -5,11 +5,13 @@ import ( "errors" "fmt" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/smartcontractkit/chainlink/v2/core/chains/evm/client" "github.com/smartcontractkit/chainlink/v2/core/chains/evm/logpoller" + "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/arm_contract" "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/evm_2_evm_onramp_1_0_0" "github.com/smartcontractkit/chainlink/v2/core/logger" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/abihelpers" @@ -38,6 +40,9 @@ type OnRamp struct { sendRequestedSeqNumberWord int filters []logpoller.Filter cachedSourcePriceRegistryAddress cache.AutoSync[cciptypes.Address] + // Static config can be cached, because it's never expected to change. + // The only way to change that is through the contract's constructor (redeployment) + cachedStaticConfig cache.OnceCtxFunction[evm_2_evm_onramp_1_0_0.EVM2EVMOnRampStaticConfig] } func NewOnRamp(lggr logger.Logger, sourceSelector, destSelector uint64, onRampAddress common.Address, sourceLP logpoller.LogPoller, source client.Client) (*OnRamp, error) { @@ -59,6 +64,9 @@ func NewOnRamp(lggr logger.Logger, sourceSelector, destSelector uint64, onRampAd Addresses: []common.Address{onRampAddress}, }, } + cachedStaticConfig := cache.OnceCtxFunction[evm_2_evm_onramp_1_0_0.EVM2EVMOnRampStaticConfig](func(ctx context.Context) (evm_2_evm_onramp_1_0_0.EVM2EVMOnRampStaticConfig, error) { + return onRamp.GetStaticConfig(&bind.CallOpts{Context: ctx}) + }) return &OnRamp{ lggr: lggr, address: onRampAddress, @@ -75,6 +83,7 @@ func NewOnRamp(lggr logger.Logger, sourceSelector, destSelector uint64, onRampAd []common.Hash{abihelpers.MustGetEventID("ConfigSet", onRampABI)}, onRampAddress, ), + cachedStaticConfig: cachedStaticConfig, }, nil } @@ -150,6 +159,24 @@ func (o *OnRamp) RouterAddress() (cciptypes.Address, error) { return cciptypes.Address(config.Router.String()), nil } +func (o *OnRamp) IsSourceCursed(ctx context.Context) (bool, error) { + staticConfig, err := o.cachedStaticConfig(ctx) + if err != nil { + return false, err + } + + arm, err := arm_contract.NewARMContract(staticConfig.ArmProxy, o.client) + if err != nil { + return false, fmt.Errorf("intializing Arm contract through the ArmProxy: %w", err) + } + + cursed, err := arm.IsCursed(&bind.CallOpts{Context: ctx}) + if err != nil { + return false, fmt.Errorf("checking if source Arm is cursed: %w", err) + } + return cursed, nil +} + func (o *OnRamp) GetLastUSDCMessagePriorToLogIndexInTx(ctx context.Context, logIndex int64, txHash common.Hash) ([]byte, error) { return nil, errors.New("USDC not supported in < 1.2.0") } diff --git a/core/services/ocr2/plugins/ccip/internal/ccipdata/v1_2_0/onramp.go b/core/services/ocr2/plugins/ccip/internal/ccipdata/v1_2_0/onramp.go index 9c3c3c45a1..088f801cc7 100644 --- a/core/services/ocr2/plugins/ccip/internal/ccipdata/v1_2_0/onramp.go +++ b/core/services/ocr2/plugins/ccip/internal/ccipdata/v1_2_0/onramp.go @@ -12,6 +12,7 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/chains/evm/client" "github.com/smartcontractkit/chainlink/v2/core/chains/evm/logpoller" + "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/arm_contract" "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/evm_2_evm_onramp_1_2_0" "github.com/smartcontractkit/chainlink/v2/core/logger" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/abihelpers" @@ -56,6 +57,9 @@ type OnRamp struct { sendRequestedSeqNumberWord int filters []logpoller.Filter cachedSourcePriceRegistryAddress cache.AutoSync[cciptypes.Address] + // Static config can be cached, because it's never expected to change. + // The only way to change that is through the contract's constructor (redeployment) + cachedStaticConfig cache.OnceCtxFunction[evm_2_evm_onramp_1_2_0.EVM2EVMOnRampStaticConfig] } func NewOnRamp(lggr logger.Logger, sourceSelector, destSelector uint64, onRampAddress common.Address, sourceLP logpoller.LogPoller, source client.Client) (*OnRamp, error) { @@ -78,6 +82,9 @@ func NewOnRamp(lggr logger.Logger, sourceSelector, destSelector uint64, onRampAd Addresses: []common.Address{onRampAddress}, }, } + cachedStaticConfig := cache.OnceCtxFunction[evm_2_evm_onramp_1_2_0.EVM2EVMOnRampStaticConfig](func(ctx context.Context) (evm_2_evm_onramp_1_2_0.EVM2EVMOnRampStaticConfig, error) { + return onRamp.GetStaticConfig(&bind.CallOpts{Context: ctx}) + }) return &OnRamp{ lggr: lggr, client: source, @@ -93,6 +100,7 @@ func NewOnRamp(lggr logger.Logger, sourceSelector, destSelector uint64, onRampAd []common.Hash{abihelpers.MustGetEventID("ConfigSet", onRampABI)}, onRampAddress, ), + cachedStaticConfig: cachedStaticConfig, }, nil } @@ -169,6 +177,24 @@ func (o *OnRamp) RouterAddress() (cciptypes.Address, error) { return cciptypes.Address(config.Router.String()), nil } +func (o *OnRamp) IsSourceCursed(ctx context.Context) (bool, error) { + staticConfig, err := o.cachedStaticConfig(ctx) + if err != nil { + return false, err + } + + arm, err := arm_contract.NewARMContract(staticConfig.ArmProxy, o.client) + if err != nil { + return false, fmt.Errorf("intializing Arm contract through the ArmProxy: %w", err) + } + + cursed, err := arm.IsCursed(&bind.CallOpts{Context: ctx}) + if err != nil { + return false, fmt.Errorf("checking if source Arm is cursed: %w", err) + } + return cursed, nil +} + func (o *OnRamp) Close(qopts ...pg.QOpt) error { return logpollerutil.UnregisterLpFilters(o.lp, o.filters, qopts...) } diff --git a/core/services/ocr2/plugins/ccip/internal/ccipdata/v1_5_0/onramp.go b/core/services/ocr2/plugins/ccip/internal/ccipdata/v1_5_0/onramp.go index 490ca134eb..e7648eef62 100644 --- a/core/services/ocr2/plugins/ccip/internal/ccipdata/v1_5_0/onramp.go +++ b/core/services/ocr2/plugins/ccip/internal/ccipdata/v1_5_0/onramp.go @@ -12,6 +12,7 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/chains/evm/client" "github.com/smartcontractkit/chainlink/v2/core/chains/evm/logpoller" + "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/arm_contract" "github.com/smartcontractkit/chainlink/v2/core/gethwrappers/ccip/generated/evm_2_evm_onramp" "github.com/smartcontractkit/chainlink/v2/core/logger" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/ccip/abihelpers" @@ -57,6 +58,9 @@ type OnRamp struct { sendRequestedSeqNumberWord int filters []logpoller.Filter cachedSourcePriceRegistryAddress cache.AutoSync[cciptypes.Address] + // Static config can be cached, because it's never expected to change. + // The only way to change that is through the contract's constructor (redeployment) + cachedStaticConfig cache.OnceCtxFunction[evm_2_evm_onramp.EVM2EVMOnRampStaticConfig] } func NewOnRamp(lggr logger.Logger, sourceSelector, destSelector uint64, onRampAddress common.Address, sourceLP logpoller.LogPoller, source client.Client) (*OnRamp, error) { @@ -79,6 +83,9 @@ func NewOnRamp(lggr logger.Logger, sourceSelector, destSelector uint64, onRampAd Addresses: []common.Address{onRampAddress}, }, } + cachedStaticConfig := cache.OnceCtxFunction[evm_2_evm_onramp.EVM2EVMOnRampStaticConfig](func(ctx context.Context) (evm_2_evm_onramp.EVM2EVMOnRampStaticConfig, error) { + return onRamp.GetStaticConfig(&bind.CallOpts{Context: ctx}) + }) return &OnRamp{ lggr: lggr, client: source, @@ -94,6 +101,7 @@ func NewOnRamp(lggr logger.Logger, sourceSelector, destSelector uint64, onRampAd []common.Hash{abihelpers.MustGetEventID("ConfigSet", onRampABI)}, onRampAddress, ), + cachedStaticConfig: cachedStaticConfig, }, nil } @@ -169,6 +177,24 @@ func (o *OnRamp) RouterAddress() (cciptypes.Address, error) { return ccipcalc.EvmAddrToGeneric(config.Router), nil } +func (o *OnRamp) IsSourceCursed(ctx context.Context) (bool, error) { + staticConfig, err := o.cachedStaticConfig(ctx) + if err != nil { + return false, err + } + + arm, err := arm_contract.NewARMContract(staticConfig.ArmProxy, o.client) + if err != nil { + return false, fmt.Errorf("intializing Arm contract through the ArmProxy: %w", err) + } + + cursed, err := arm.IsCursed(&bind.CallOpts{Context: ctx}) + if err != nil { + return false, fmt.Errorf("checking if source Arm is cursed: %w", err) + } + return cursed, nil +} + func (o *OnRamp) Close(qopts ...pg.QOpt) error { return logpollerutil.UnregisterLpFilters(o.lp, o.filters, qopts...) } diff --git a/core/services/ocr2/plugins/ccip/vars.go b/core/services/ocr2/plugins/ccip/vars.go index 840465892c..1eabd6dcbe 100644 --- a/core/services/ocr2/plugins/ccip/vars.go +++ b/core/services/ocr2/plugins/ccip/vars.go @@ -11,4 +11,4 @@ const ( ExecPluginLabel = "exec" ) -var ErrCommitStoreIsDown = errors.New("commitStoreReader is down") +var ErrChainPausedOrCursed = errors.New("commitStoreReader is down or source chain is cursed")