diff --git a/deployment/fastcurse/fastcurse.go b/deployment/fastcurse/fastcurse.go index 9d23940dca..3fac66aa2e 100644 --- a/deployment/fastcurse/fastcurse.go +++ b/deployment/fastcurse/fastcurse.go @@ -5,9 +5,10 @@ import ( "github.com/Masterminds/semver/v3" chain_selectors "github.com/smartcontractkit/chain-selectors" + mcms_types "github.com/smartcontractkit/mcms/types" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" - mcms_types "github.com/smartcontractkit/mcms/types" "github.com/smartcontractkit/chainlink-ccip/deployment/utils/changesets" "github.com/smartcontractkit/chainlink-ccip/deployment/utils/mcms" @@ -110,11 +111,18 @@ func formCurseConfigForGlobalCurse(e cldf.Environment, cr *CurseRegistry, cfg Gl if err != nil { return curseCfg, fmt.Errorf("failed to derive curse adapter version for chain selector %d: %w", connectedChainSelector, err) } + // Add both directions for v1.6 lane safety (reverse can be any version). + // Even for a global curse, v1.6 lanes must be represented bidirectionally. curseCfg.CurseActions = append(curseCfg.CurseActions, CurseActionInput{ ChainSelector: connectedChainSelector, Version: connectedVersion, SubjectChainSelector: chainSelector, }) + curseCfg.CurseActions = append(curseCfg.CurseActions, CurseActionInput{ + ChainSelector: chainSelector, + Version: version, + SubjectChainSelector: connectedChainSelector, + }) } } return curseCfg, nil @@ -174,6 +182,10 @@ func filterSubjectsToCurse(e cldf.Environment, force bool, selector uint64, curs func applyCurse(cr *CurseRegistry, mcmsRegistry *changesets.MCMSReaderRegistry) func(cldf.Environment, RMNCurseConfig) (cldf.ChangesetOutput, error) { return func(e cldf.Environment, cfg RMNCurseConfig) (cldf.ChangesetOutput, error) { + if err := validateBidirectionalLaneActions(cfg); err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("curse validation failed: %w", err) + } + batchOps := make([]mcms_types.BatchOperation, 0) reports := make([]cldf_ops.Report[any, any], 0) grouped, err := cr.groupRMNSubjectBySelector(e, cfg.CurseActions) @@ -212,6 +224,10 @@ func applyCurse(cr *CurseRegistry, mcmsRegistry *changesets.MCMSReaderRegistry) func applyUncurse(cr *CurseRegistry, mcmsRegistry *changesets.MCMSReaderRegistry) func(cldf.Environment, RMNCurseConfig) (cldf.ChangesetOutput, error) { return func(e cldf.Environment, cfg RMNCurseConfig) (cldf.ChangesetOutput, error) { + if err := validateBidirectionalLaneActions(cfg); err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("uncurse validation failed: %w", err) + } + batchOps := make([]mcms_types.BatchOperation, 0) reports := make([]cldf_ops.Report[any, any], 0) // Group curse actions by chain selector diff --git a/deployment/fastcurse/validation.go b/deployment/fastcurse/validation.go new file mode 100644 index 0000000000..1b447137ae --- /dev/null +++ b/deployment/fastcurse/validation.go @@ -0,0 +1,82 @@ +package fastcurse + +import ( + "fmt" + "strings" +) + +type laneKey struct { + source uint64 + target uint64 +} + +type curseActionInfo struct { + version string + chainSelector uint64 + subjectSelector uint64 +} + +// validateBidirectionalLaneActions validates that lane curses/uncurses are only applied bidirectionally. +// +// Business rule: +// - non-global lane actions must include the reverse direction (any version is acceptable) +// - global curses are excluded +func validateBidirectionalLaneActions(cfg RMNCurseConfig) error { + allLaneActions := make(map[laneKey]curseActionInfo) + + for _, action := range cfg.CurseActions { + if action.IsGlobalCurse { + continue + } + if action.Version == nil { + return fmt.Errorf( + "lane curse action missing version: chain %d -> %d", + action.ChainSelector, + action.SubjectChainSelector, + ) + } + + allLaneActions[laneKey{source: action.ChainSelector, target: action.SubjectChainSelector}] = curseActionInfo{ + version: action.Version.String(), + chainSelector: action.ChainSelector, + subjectSelector: action.SubjectChainSelector, + } + } + + var unidirectional []curseActionInfo + for key, actionInfo := range allLaneActions { + reverseKey := laneKey{source: key.target, target: key.source} + if _, ok := allLaneActions[reverseKey]; ok { + continue + } + unidirectional = append(unidirectional, actionInfo) + } + + if len(unidirectional) > 0 { + return formatUnidirectionalLaneError(unidirectional) + } + return nil +} + +func formatUnidirectionalLaneError(unidirectional []curseActionInfo) error { + if len(unidirectional) == 1 { + lane := unidirectional[0] + return fmt.Errorf( + "unidirectional lane cursing is not allowed: chain %d -> %d (version %s). lanes must be cursed bidirectionally to prevent requests from getting stuck indefinitely", + lane.chainSelector, + lane.subjectSelector, + lane.version, + ) + } + + var b strings.Builder + fmt.Fprintf( + &b, + "unidirectional lane cursing is not allowed for %d lanes. lanes must be cursed bidirectionally to prevent requests from getting stuck indefinitely:\n", + len(unidirectional), + ) + for i, lane := range unidirectional { + fmt.Fprintf(&b, " %d. Chain %d -> %d (version %s)\n", i+1, lane.chainSelector, lane.subjectSelector, lane.version) + } + return fmt.Errorf("%s", b.String()) +} diff --git a/deployment/fastcurse/validation_test.go b/deployment/fastcurse/validation_test.go new file mode 100644 index 0000000000..a02286803f --- /dev/null +++ b/deployment/fastcurse/validation_test.go @@ -0,0 +1,162 @@ +package fastcurse + +import ( + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/stretchr/testify/require" + + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" +) + +func mustVersion(t *testing.T, v string) *semver.Version { + t.Helper() + ver, err := semver.NewVersion(v) + require.NoError(t, err) + return ver +} + +func laneAction(t *testing.T, chain, subject uint64, version string) CurseActionInput { + t.Helper() + return CurseActionInput{ + ChainSelector: chain, + SubjectChainSelector: subject, + Version: mustVersion(t, version), + } +} + +func globalAction(t *testing.T, chain uint64, version string) CurseActionInput { + t.Helper() + return CurseActionInput{ + IsGlobalCurse: true, + ChainSelector: chain, + Version: mustVersion(t, version), + } +} + +func TestValidateBidirectionalCursing(t *testing.T) { + tests := []struct { + name string + actions []CurseActionInput + expectError bool + errorSubstring string + }{ + { + name: "valid bidirectional v1.6 cursing", + actions: []CurseActionInput{ + laneAction(t, 1, 2, "1.6.0"), + laneAction(t, 2, 1, "1.6.0"), + }, + expectError: false, + }, + { + name: "invalid unidirectional v1.6 cursing", + actions: []CurseActionInput{ + laneAction(t, 1, 2, "1.6.0"), + }, + expectError: true, + errorSubstring: "unidirectional lane", + }, + { + name: "invalid v1.5 unidirectional cursing", + actions: []CurseActionInput{ + laneAction(t, 1, 2, "1.5.0"), + }, + expectError: true, + errorSubstring: "unidirectional lane", + }, + { + name: "valid mixed version bidirectional cursing (v1.6 forward, v1.5 reverse)", + actions: []CurseActionInput{ + laneAction(t, 1, 2, "1.6.0"), + laneAction(t, 2, 1, "1.5.0"), + }, + expectError: false, + }, + { + name: "global curse is not subject to bidirectional validation", + actions: []CurseActionInput{ + globalAction(t, 1, "1.6.0"), + }, + expectError: false, + }, + { + name: "multiple unidirectional lanes", + actions: []CurseActionInput{ + laneAction(t, 1, 2, "1.6.0"), + laneAction(t, 3, 4, "1.6.0"), + }, + expectError: true, + errorSubstring: "unidirectional lane", + }, + { + name: "invalid v1.7+ unidirectional cursing", + actions: []CurseActionInput{ + laneAction(t, 1, 2, "1.7.0"), + }, + expectError: true, + errorSubstring: "unidirectional lane", + }, + { + name: "self-lane is ignored", + actions: []CurseActionInput{ + laneAction(t, 1, 1, "1.6.0"), + }, + expectError: false, + }, + { + name: "self-lane with nil version is rejected", + actions: []CurseActionInput{ + { + ChainSelector: 1, + SubjectChainSelector: 1, + Version: nil, + IsGlobalCurse: false, + }, + }, + expectError: true, + errorSubstring: "missing version", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := RMNCurseConfig{CurseActions: tt.actions} + err := validateBidirectionalLaneActions(cfg) + if tt.expectError { + require.Error(t, err) + if tt.errorSubstring != "" { + require.Contains(t, err.Error(), tt.errorSubstring) + } + } else { + require.NoError(t, err) + } + }) + } +} + +func TestApplyCurse_EnforcesBidirectionalV16Validation(t *testing.T) { + cfg := RMNCurseConfig{ + CurseActions: []CurseActionInput{ + laneAction(t, 1, 2, "1.6.0"), + }, + } + + _, err := applyCurse(nil, nil)(cldf.Environment{}, cfg) + require.Error(t, err) + require.Contains(t, err.Error(), "curse validation failed") + require.Contains(t, err.Error(), "unidirectional lane") +} + +func TestApplyUncurse_EnforcesBidirectionalV16Validation(t *testing.T) { + cfg := RMNCurseConfig{ + CurseActions: []CurseActionInput{ + laneAction(t, 1, 2, "1.6.0"), + }, + } + + _, err := applyUncurse(nil, nil)(cldf.Environment{}, cfg) + require.Error(t, err) + require.Contains(t, err.Error(), "uncurse validation failed") + require.Contains(t, err.Error(), "unidirectional lane") +} diff --git a/devenv/tests/curse.go b/devenv/tests/curse.go index 796fe13823..0ddd24a08c 100644 --- a/devenv/tests/curse.go +++ b/devenv/tests/curse.go @@ -34,6 +34,9 @@ func RunCurseTests(t *testing.T, e *deployment.Environment, selectors []uint64) fromImpl, toImpl := selectorsToImpl[selectors[0]], selectorsToImpl[selectors[1]] require.NotEqual(t, fromImpl, toImpl) + srcFamily, err := chainsel.GetSelectorFamily(fromImpl.ChainSelector()) + require.NoError(t, err) + destFamily, err := chainsel.GetSelectorFamily(toImpl.ChainSelector()) require.NoError(t, err) @@ -45,6 +48,14 @@ func RunCurseTests(t *testing.T, e *deployment.Environment, selectors []uint64) require.NoError(t, curseAdapter.Initialize(*e, toImpl.ChainSelector())) + srcCurseAdapter, ok := fastcurse.GetCurseRegistry().GetCurseAdapter(srcFamily, semver.MustParse("1.6.0")) + if !ok { + t.Skipf("no curse adapter registered for chain family: %s and version: %s", srcFamily, semver.MustParse("1.6.0")) + } + require.NotNil(t, srcCurseAdapter, "registered curse adapter is nil") + + require.NoError(t, srcCurseAdapter.Initialize(*e, fromImpl.ChainSelector())) + // Ping the loki URL to ensure its available, else skip the test. err = PingLoki(t.Context(), DefaultLokiURL) if err != nil { @@ -58,12 +69,21 @@ func RunCurseTests(t *testing.T, e *deployment.Environment, selectors []uint64) Level(zerolog.InfoLevel). WithContext(t.Context()) - // curse the source selector on the destination chain if its not already cursed. - subject := selectorToSubject(fromImpl.ChainSelector()) - alreadyCursed, err := curseAdapter.IsSubjectCursedOnChain(*e, toImpl.ChainSelector(), subject) + // v1.6 lanes must be cursed bidirectionally to avoid requests getting stuck indefinitely. + subjectOnDest := selectorToSubject(fromImpl.ChainSelector()) + subjectOnSrc := selectorToSubject(toImpl.ChainSelector()) + + alreadyCursedOnDest, err := curseAdapter.IsSubjectCursedOnChain(*e, toImpl.ChainSelector(), subjectOnDest) require.NoError(t, err) - if !alreadyCursed { - t.Logf("cursing source selector %d on destination chain %d", fromImpl.ChainSelector(), toImpl.ChainSelector()) + alreadyCursedOnSrc, err := srcCurseAdapter.IsSubjectCursedOnChain(*e, fromImpl.ChainSelector(), subjectOnSrc) + require.NoError(t, err) + + if !alreadyCursedOnDest || !alreadyCursedOnSrc { + t.Logf( + "cursing lane bidirectionally between chains %d <-> %d", + fromImpl.ChainSelector(), + toImpl.ChainSelector(), + ) curseCS := fastcurse.CurseChangeset(fastcurse.GetCurseRegistry(), changesets.GetRegistry()) output, err := curseCS.Apply(*e, fastcurse.RMNCurseConfig{ CurseActions: []fastcurse.CurseActionInput{ @@ -73,19 +93,33 @@ func RunCurseTests(t *testing.T, e *deployment.Environment, selectors []uint64) Version: semver.MustParse("1.6.0"), IsGlobalCurse: false, }, + { + ChainSelector: fromImpl.ChainSelector(), + SubjectChainSelector: toImpl.ChainSelector(), + Version: semver.MustParse("1.6.0"), + IsGlobalCurse: false, + }, }, }) require.NoError(t, err) require.Greater(t, len(output.Reports), 0) } else { - t.Logf("source selector %d is already cursed on destination chain %d, skipping curse", fromImpl.ChainSelector(), toImpl.ChainSelector()) + t.Logf( + "lane already cursed bidirectionally between chains %d <-> %d, skipping curse", + fromImpl.ChainSelector(), + toImpl.ChainSelector(), + ) } - // Confirm that the subject is cursed on the destination chain. - isCursed, err := curseAdapter.IsSubjectCursedOnChain(*e, toImpl.ChainSelector(), subject) + // Confirm that the lane is cursed in both directions. + isCursed, err := curseAdapter.IsSubjectCursedOnChain(*e, toImpl.ChainSelector(), subjectOnDest) require.NoError(t, err) require.True(t, isCursed, "subject should be cursed on destination chain") + isCursed, err = srcCurseAdapter.IsSubjectCursedOnChain(*e, fromImpl.ChainSelector(), subjectOnSrc) + require.NoError(t, err) + require.True(t, isCursed, "subject should be cursed on source chain") + // Send a message from the source chain to the destination chain // The plugin should ignore the message because the dest is cursing the source selector. // block := toImpl.CurrentBlock(t) diff --git a/integration-tests/deployment/fastcurse_test.go b/integration-tests/deployment/fastcurse_test.go index 9fadfcf9ea..f1b26e9112 100644 --- a/integration-tests/deployment/fastcurse_test.go +++ b/integration-tests/deployment/fastcurse_test.go @@ -10,13 +10,14 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/gagliardetto/solana-go" chainsel "github.com/smartcontractkit/chain-selectors" + mcms_types "github.com/smartcontractkit/mcms/types" + "github.com/stretchr/testify/require" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" "github.com/smartcontractkit/chainlink-deployments-framework/deployment" "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" "github.com/smartcontractkit/chainlink-evm/pkg/utils" - mcms_types "github.com/smartcontractkit/mcms/types" - "github.com/stretchr/testify/require" "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/operations/contract" _ "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/adapters" @@ -28,6 +29,7 @@ import ( "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_5_0/rmn_contract" "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_6_0/rmn_remote" soladapterv1_6_0 "github.com/smartcontractkit/chainlink-ccip/chains/solana/deployment/v1_6_0/adapters" + solofframpops "github.com/smartcontractkit/chainlink-ccip/chains/solana/deployment/v1_6_0/operations/offramp" "github.com/smartcontractkit/chainlink-ccip/deployment/deploy" "github.com/smartcontractkit/chainlink-ccip/deployment/fastcurse" "github.com/smartcontractkit/chainlink-ccip/deployment/testhelpers" @@ -74,6 +76,24 @@ func TestFastCurseSolanaAndEVM(t *testing.T) { }, }) require.NoError(t, err, "Failed to apply DeployChainContracts changeset") + + // Ensure the Solana OffRamp is configured with chain2 as an enabled source chain. + // The Solana fastcurse adapter's connectivity check for lane actions reads the OffRamp + // source-chain state (PDA) and will treat the chains as disconnected unless this is set. + _, err = cldf_ops.ExecuteOperation( + env.OperationsBundle, + solofframpops.ConnectChains, + env.BlockChains.SolanaChains()[chainsel.SOLANA_MAINNET.Selector], + solofframpops.ConnectChainsParams{ + OffRamp: solana.MustPublicKeyFromBase58(solanaProgramIDs["ccip_offramp"]), + RemoteChainSelector: chain2, + SourceOnRamp: common.HexToAddress("0x0000000000000000000000000000000000000001").Bytes(), + EnabledAsSource: true, + IsRMNVerificationDisabled: true, + }, + ) + require.NoError(t, err, "Failed to connect Solana OffRamp to EVM chain2") + DeployMCMS(t, env, chainsel.SOLANA_MAINNET.Selector, []string{deploymentutils.CLLQualifier}) SolanaTransferOwnership(t, env, chainsel.SOLANA_MAINNET.Selector) ds := datastore.NewMemoryDataStore() @@ -255,7 +275,7 @@ func TestFastCurseSolanaAndEVM(t *testing.T) { MCMS: mcms.Input{ OverridePreviousRoot: false, ValidUntil: 3759765795, - TimelockDelay: mcms_types.MustParseDuration("0s"), + TimelockDelay: mcms_types.MustParseDuration("1s"), TimelockAction: mcms_types.TimelockActionSchedule, Qualifier: deploymentutils.CLLQualifier, Description: "Transfer ownership to timelock for fast curse test", @@ -291,12 +311,19 @@ func TestFastCurseSolanaAndEVM(t *testing.T) { SubjectChainSelector: chainsel.SOLANA_MAINNET.Selector, Version: semver.MustParse("1.6.0"), }, + { + // Lane curse actions must be represented bidirectionally; reverse direction can be any version. + IsGlobalCurse: false, + ChainSelector: chainsel.SOLANA_MAINNET.Selector, + SubjectChainSelector: chain2, + Version: semver.MustParse("1.6.0"), + }, }, Force: false, MCMS: mcms.Input{ OverridePreviousRoot: false, ValidUntil: 3759765795, - TimelockDelay: mcms_types.MustParseDuration("0s"), + TimelockDelay: mcms_types.MustParseDuration("1s"), TimelockAction: mcms_types.TimelockActionSchedule, Qualifier: deploymentutils.CLLQualifier, Description: "Curse proposal for fast curse test",