From 2ac3c0fee114c8379ad4a9dfc99bf7b117d94de8 Mon Sep 17 00:00:00 2001 From: crispymangoes Date: Wed, 27 May 2026 09:42:09 -0700 Subject: [PATCH 1/6] Add curse validation to applyCurse and applyUncurse --- deployment/fastcurse/fastcurse.go | 15 ++ deployment/fastcurse/validation.go | 94 ++++++++++++ deployment/fastcurse/validation_test.go | 142 ++++++++++++++++++ devenv/tests/curse.go | 50 +++++- .../deployment/fastcurse_test.go | 7 + 5 files changed, 300 insertions(+), 8 deletions(-) create mode 100644 deployment/fastcurse/validation.go create mode 100644 deployment/fastcurse/validation_test.go diff --git a/deployment/fastcurse/fastcurse.go b/deployment/fastcurse/fastcurse.go index 9d23940dca..a4842c2e3b 100644 --- a/deployment/fastcurse/fastcurse.go +++ b/deployment/fastcurse/fastcurse.go @@ -110,11 +110,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 +181,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 := validateBidirectionalV16Cursing(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 +223,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 := validateBidirectionalV16Cursing(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..386d3387cd --- /dev/null +++ b/deployment/fastcurse/validation.go @@ -0,0 +1,94 @@ +package fastcurse + +import ( + "fmt" + "strings" + + "github.com/Masterminds/semver/v3" +) + +type laneKey struct { + source uint64 + target uint64 +} + +type curseActionInfo struct { + version string + chainSelector uint64 + subjectSelector uint64 +} + +// validateBidirectionalV16Cursing validates that v1.6 lanes are only cursed/uncursed bidirectionally. +// +// Business rule: +// - v1.6 lane actions (non-global) must include the reverse direction (any version is acceptable) +// - global curses are excluded +func validateBidirectionalV16Cursing(cfg RMNCurseConfig) error { + allLaneActions := make(map[laneKey]curseActionInfo) + v16LaneActions := make(map[laneKey]curseActionInfo) + + for _, action := range cfg.CurseActions { + if action.IsGlobalCurse { + continue + } + if action.Version == nil { + continue + } + if action.ChainSelector == action.SubjectChainSelector { + continue + } + + key := laneKey{source: action.ChainSelector, target: action.SubjectChainSelector} + actionInfo := curseActionInfo{ + version: action.Version.String(), + chainSelector: action.ChainSelector, + subjectSelector: action.SubjectChainSelector, + } + allLaneActions[key] = actionInfo + + if isV16(action.Version) { + v16LaneActions[key] = actionInfo + } + } + + var unidirectional []curseActionInfo + for key, actionInfo := range v16LaneActions { + reverseKey := laneKey{source: key.target, target: key.source} + if _, ok := allLaneActions[reverseKey]; ok { + continue + } + unidirectional = append(unidirectional, actionInfo) + } + + if len(unidirectional) > 0 { + return formatUnidirectionalV16Error(unidirectional) + } + return nil +} + +func isV16(v *semver.Version) bool { + return v != nil && v.Major() == 1 && v.Minor() == 6 +} + +func formatUnidirectionalV16Error(unidirectional []curseActionInfo) error { + if len(unidirectional) == 1 { + lane := unidirectional[0] + return fmt.Errorf( + "unidirectional v1.6 lane cursing is not allowed: chain %d -> %d (version %s). v1.6 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 v1.6 lane cursing is not allowed for %d lanes. v1.6 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..2155ba0cf3 --- /dev/null +++ b/deployment/fastcurse/validation_test.go @@ -0,0 +1,142 @@ +package fastcurse + +import ( + "testing" + + "github.com/Masterminds/semver/v3" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/stretchr/testify/require" +) + +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 TestValidateBidirectionalV16Cursing(t *testing.T) { + tests := []struct { + name string + actions []CurseActionInput + expectError bool + }{ + { + 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, + }, + { + name: "valid v1.5 unidirectional cursing", + actions: []CurseActionInput{ + laneAction(t, 1, 2, "1.5.0"), + }, + expectError: false, + }, + { + name: "valid mixed version 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 v1.6 lanes", + actions: []CurseActionInput{ + laneAction(t, 1, 2, "1.6.0"), + laneAction(t, 3, 4, "1.6.0"), + }, + expectError: true, + }, + { + name: "v1.7+ unidirectional allowed", + actions: []CurseActionInput{ + laneAction(t, 1, 2, "1.7.0"), + }, + expectError: false, + }, + { + name: "self-lane is ignored", + actions: []CurseActionInput{ + laneAction(t, 1, 1, "1.6.0"), + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := RMNCurseConfig{CurseActions: tt.actions} + err := validateBidirectionalV16Cursing(cfg) + if tt.expectError { + require.Error(t, err) + require.Contains(t, err.Error(), "unidirectional v1.6") + } 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 v1.6") +} + +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 v1.6") +} + 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..36b3e36394 100644 --- a/integration-tests/deployment/fastcurse_test.go +++ b/integration-tests/deployment/fastcurse_test.go @@ -291,6 +291,13 @@ func TestFastCurseSolanaAndEVM(t *testing.T) { SubjectChainSelector: chainsel.SOLANA_MAINNET.Selector, Version: semver.MustParse("1.6.0"), }, + { + // v1.6 lanes 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{ From add28533e926635f2ba98c2c3ac5aa4c0d878bd3 Mon Sep 17 00:00:00 2001 From: crispymangoes Date: Thu, 28 May 2026 07:28:58 -0700 Subject: [PATCH 2/6] Remove version check and validate any --- deployment/fastcurse/fastcurse.go | 7 ++--- deployment/fastcurse/validation.go | 36 +++++++++++-------------- deployment/fastcurse/validation_test.go | 26 +++++++++--------- 3 files changed, 33 insertions(+), 36 deletions(-) diff --git a/deployment/fastcurse/fastcurse.go b/deployment/fastcurse/fastcurse.go index a4842c2e3b..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" @@ -181,7 +182,7 @@ 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 := validateBidirectionalV16Cursing(cfg); err != nil { + if err := validateBidirectionalLaneActions(cfg); err != nil { return cldf.ChangesetOutput{}, fmt.Errorf("curse validation failed: %w", err) } @@ -223,7 +224,7 @@ 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 := validateBidirectionalV16Cursing(cfg); err != nil { + if err := validateBidirectionalLaneActions(cfg); err != nil { return cldf.ChangesetOutput{}, fmt.Errorf("uncurse validation failed: %w", err) } diff --git a/deployment/fastcurse/validation.go b/deployment/fastcurse/validation.go index 386d3387cd..0547f52d33 100644 --- a/deployment/fastcurse/validation.go +++ b/deployment/fastcurse/validation.go @@ -3,8 +3,6 @@ package fastcurse import ( "fmt" "strings" - - "github.com/Masterminds/semver/v3" ) type laneKey struct { @@ -18,21 +16,27 @@ type curseActionInfo struct { subjectSelector uint64 } -// validateBidirectionalV16Cursing validates that v1.6 lanes are only cursed/uncursed bidirectionally. +// validateBidirectionalLaneActions validates that lane curses/uncurses are only applied bidirectionally. // // Business rule: -// - v1.6 lane actions (non-global) must include the reverse direction (any version is acceptable) +// - non-global lane actions must include the reverse direction (any version is acceptable) // - global curses are excluded -func validateBidirectionalV16Cursing(cfg RMNCurseConfig) error { +func validateBidirectionalLaneActions(cfg RMNCurseConfig) error { allLaneActions := make(map[laneKey]curseActionInfo) - v16LaneActions := make(map[laneKey]curseActionInfo) for _, action := range cfg.CurseActions { if action.IsGlobalCurse { continue } if action.Version == nil { - continue + if action.ChainSelector == action.SubjectChainSelector { + continue + } + return fmt.Errorf( + "lane curse action missing version: chain %d -> %d", + action.ChainSelector, + action.SubjectChainSelector, + ) } if action.ChainSelector == action.SubjectChainSelector { continue @@ -45,14 +49,10 @@ func validateBidirectionalV16Cursing(cfg RMNCurseConfig) error { subjectSelector: action.SubjectChainSelector, } allLaneActions[key] = actionInfo - - if isV16(action.Version) { - v16LaneActions[key] = actionInfo - } } var unidirectional []curseActionInfo - for key, actionInfo := range v16LaneActions { + for key, actionInfo := range allLaneActions { reverseKey := laneKey{source: key.target, target: key.source} if _, ok := allLaneActions[reverseKey]; ok { continue @@ -61,20 +61,16 @@ func validateBidirectionalV16Cursing(cfg RMNCurseConfig) error { } if len(unidirectional) > 0 { - return formatUnidirectionalV16Error(unidirectional) + return formatUnidirectionalLaneError(unidirectional) } return nil } -func isV16(v *semver.Version) bool { - return v != nil && v.Major() == 1 && v.Minor() == 6 -} - -func formatUnidirectionalV16Error(unidirectional []curseActionInfo) error { +func formatUnidirectionalLaneError(unidirectional []curseActionInfo) error { if len(unidirectional) == 1 { lane := unidirectional[0] return fmt.Errorf( - "unidirectional v1.6 lane cursing is not allowed: chain %d -> %d (version %s). v1.6 lanes must be cursed bidirectionally to prevent requests from getting stuck indefinitely", + "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, @@ -84,7 +80,7 @@ func formatUnidirectionalV16Error(unidirectional []curseActionInfo) error { var b strings.Builder fmt.Fprintf( &b, - "unidirectional v1.6 lane cursing is not allowed for %d lanes. v1.6 lanes must be cursed bidirectionally to prevent requests from getting stuck indefinitely:\n", + "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 { diff --git a/deployment/fastcurse/validation_test.go b/deployment/fastcurse/validation_test.go index 2155ba0cf3..16bea79960 100644 --- a/deployment/fastcurse/validation_test.go +++ b/deployment/fastcurse/validation_test.go @@ -4,8 +4,9 @@ import ( "testing" "github.com/Masterminds/semver/v3" - cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" "github.com/stretchr/testify/require" + + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" ) func mustVersion(t *testing.T, v string) *semver.Version { @@ -33,7 +34,7 @@ func globalAction(t *testing.T, chain uint64, version string) CurseActionInput { } } -func TestValidateBidirectionalV16Cursing(t *testing.T) { +func TestValidateBidirectionalCursing(t *testing.T) { tests := []struct { name string actions []CurseActionInput @@ -55,14 +56,14 @@ func TestValidateBidirectionalV16Cursing(t *testing.T) { expectError: true, }, { - name: "valid v1.5 unidirectional cursing", + name: "invalid v1.5 unidirectional cursing", actions: []CurseActionInput{ laneAction(t, 1, 2, "1.5.0"), }, - expectError: false, + expectError: true, }, { - name: "valid mixed version cursing (v1.6 forward, v1.5 reverse)", + 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"), @@ -77,7 +78,7 @@ func TestValidateBidirectionalV16Cursing(t *testing.T) { expectError: false, }, { - name: "multiple unidirectional v1.6 lanes", + name: "multiple unidirectional lanes", actions: []CurseActionInput{ laneAction(t, 1, 2, "1.6.0"), laneAction(t, 3, 4, "1.6.0"), @@ -85,11 +86,11 @@ func TestValidateBidirectionalV16Cursing(t *testing.T) { expectError: true, }, { - name: "v1.7+ unidirectional allowed", + name: "invalid v1.7+ unidirectional cursing", actions: []CurseActionInput{ laneAction(t, 1, 2, "1.7.0"), }, - expectError: false, + expectError: true, }, { name: "self-lane is ignored", @@ -103,10 +104,10 @@ func TestValidateBidirectionalV16Cursing(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := RMNCurseConfig{CurseActions: tt.actions} - err := validateBidirectionalV16Cursing(cfg) + err := validateBidirectionalLaneActions(cfg) if tt.expectError { require.Error(t, err) - require.Contains(t, err.Error(), "unidirectional v1.6") + require.Contains(t, err.Error(), "unidirectional lane") } else { require.NoError(t, err) } @@ -124,7 +125,7 @@ func TestApplyCurse_EnforcesBidirectionalV16Validation(t *testing.T) { _, 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 v1.6") + require.Contains(t, err.Error(), "unidirectional lane") } func TestApplyUncurse_EnforcesBidirectionalV16Validation(t *testing.T) { @@ -137,6 +138,5 @@ func TestApplyUncurse_EnforcesBidirectionalV16Validation(t *testing.T) { _, 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 v1.6") + require.Contains(t, err.Error(), "unidirectional lane") } - From 95928c3766b16c82a166517bc34192c6669d65f9 Mon Sep 17 00:00:00 2001 From: crispymangoes Date: Thu, 28 May 2026 07:45:53 -0700 Subject: [PATCH 3/6] Fix nits --- deployment/fastcurse/validation.go | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/deployment/fastcurse/validation.go b/deployment/fastcurse/validation.go index 0547f52d33..d54bc71599 100644 --- a/deployment/fastcurse/validation.go +++ b/deployment/fastcurse/validation.go @@ -28,27 +28,22 @@ func validateBidirectionalLaneActions(cfg RMNCurseConfig) error { if action.IsGlobalCurse { continue } + if action.ChainSelector == action.SubjectChainSelector { + continue + } if action.Version == nil { - if action.ChainSelector == action.SubjectChainSelector { - continue - } return fmt.Errorf( "lane curse action missing version: chain %d -> %d", action.ChainSelector, action.SubjectChainSelector, ) } - if action.ChainSelector == action.SubjectChainSelector { - continue - } - key := laneKey{source: action.ChainSelector, target: action.SubjectChainSelector} - actionInfo := curseActionInfo{ + allLaneActions[laneKey{source: action.ChainSelector, target: action.SubjectChainSelector}] = curseActionInfo{ version: action.Version.String(), chainSelector: action.ChainSelector, subjectSelector: action.SubjectChainSelector, } - allLaneActions[key] = actionInfo } var unidirectional []curseActionInfo From 6184d6b63d47d3b127c53f19e3aae138f8affb32 Mon Sep 17 00:00:00 2001 From: crispymangoes Date: Thu, 28 May 2026 08:29:15 -0700 Subject: [PATCH 4/6] Remove self curse skip --- deployment/fastcurse/validation.go | 3 --- deployment/fastcurse/validation_test.go | 36 +++++++++++++++++++------ 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/deployment/fastcurse/validation.go b/deployment/fastcurse/validation.go index d54bc71599..1b447137ae 100644 --- a/deployment/fastcurse/validation.go +++ b/deployment/fastcurse/validation.go @@ -28,9 +28,6 @@ func validateBidirectionalLaneActions(cfg RMNCurseConfig) error { if action.IsGlobalCurse { continue } - if action.ChainSelector == action.SubjectChainSelector { - continue - } if action.Version == nil { return fmt.Errorf( "lane curse action missing version: chain %d -> %d", diff --git a/deployment/fastcurse/validation_test.go b/deployment/fastcurse/validation_test.go index 16bea79960..a02286803f 100644 --- a/deployment/fastcurse/validation_test.go +++ b/deployment/fastcurse/validation_test.go @@ -36,9 +36,10 @@ func globalAction(t *testing.T, chain uint64, version string) CurseActionInput { func TestValidateBidirectionalCursing(t *testing.T) { tests := []struct { - name string - actions []CurseActionInput - expectError bool + name string + actions []CurseActionInput + expectError bool + errorSubstring string }{ { name: "valid bidirectional v1.6 cursing", @@ -53,14 +54,16 @@ func TestValidateBidirectionalCursing(t *testing.T) { actions: []CurseActionInput{ laneAction(t, 1, 2, "1.6.0"), }, - expectError: true, + expectError: true, + errorSubstring: "unidirectional lane", }, { name: "invalid v1.5 unidirectional cursing", actions: []CurseActionInput{ laneAction(t, 1, 2, "1.5.0"), }, - expectError: true, + expectError: true, + errorSubstring: "unidirectional lane", }, { name: "valid mixed version bidirectional cursing (v1.6 forward, v1.5 reverse)", @@ -83,14 +86,16 @@ func TestValidateBidirectionalCursing(t *testing.T) { laneAction(t, 1, 2, "1.6.0"), laneAction(t, 3, 4, "1.6.0"), }, - expectError: true, + expectError: true, + errorSubstring: "unidirectional lane", }, { name: "invalid v1.7+ unidirectional cursing", actions: []CurseActionInput{ laneAction(t, 1, 2, "1.7.0"), }, - expectError: true, + expectError: true, + errorSubstring: "unidirectional lane", }, { name: "self-lane is ignored", @@ -99,6 +104,19 @@ func TestValidateBidirectionalCursing(t *testing.T) { }, 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 { @@ -107,7 +125,9 @@ func TestValidateBidirectionalCursing(t *testing.T) { err := validateBidirectionalLaneActions(cfg) if tt.expectError { require.Error(t, err) - require.Contains(t, err.Error(), "unidirectional lane") + if tt.errorSubstring != "" { + require.Contains(t, err.Error(), tt.errorSubstring) + } } else { require.NoError(t, err) } From df382b03c25861c2a266dbe2df1c0842a65e55be Mon Sep 17 00:00:00 2001 From: crispymangoes Date: Thu, 28 May 2026 10:34:43 -0700 Subject: [PATCH 5/6] Fix CI test --- .../deployment/fastcurse_test.go | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/integration-tests/deployment/fastcurse_test.go b/integration-tests/deployment/fastcurse_test.go index 36b3e36394..f29fbf8caa 100644 --- a/integration-tests/deployment/fastcurse_test.go +++ b/integration-tests/deployment/fastcurse_test.go @@ -28,6 +28,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 +75,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() @@ -292,7 +311,7 @@ func TestFastCurseSolanaAndEVM(t *testing.T) { Version: semver.MustParse("1.6.0"), }, { - // v1.6 lanes must be represented bidirectionally; reverse direction can be any version. + // Lane curse actions must be represented bidirectionally; reverse direction can be any version. IsGlobalCurse: false, ChainSelector: chainsel.SOLANA_MAINNET.Selector, SubjectChainSelector: chain2, From bcb6679a968985cf8618f00fbdb236651c820dcb Mon Sep 17 00:00:00 2001 From: crispymangoes Date: Thu, 28 May 2026 10:58:06 -0700 Subject: [PATCH 6/6] Add 1 second delay to timelock execution --- integration-tests/deployment/fastcurse_test.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/integration-tests/deployment/fastcurse_test.go b/integration-tests/deployment/fastcurse_test.go index f29fbf8caa..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" @@ -274,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", @@ -322,7 +323,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: "Curse proposal for fast curse test",