Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion deployment/fastcurse/fastcurse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
82 changes: 82 additions & 0 deletions deployment/fastcurse/validation.go
Original file line number Diff line number Diff line change
@@ -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())
}
162 changes: 162 additions & 0 deletions deployment/fastcurse/validation_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
50 changes: 42 additions & 8 deletions devenv/tests/curse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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 {
Expand All @@ -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{
Expand All @@ -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)
Expand Down
Loading
Loading