Skip to content

Commit

Permalink
rmn - cursing checks and reverts prevention (#321)
Browse files Browse the repository at this point in the history
Commit plugin cursing checks and reverts prevention.

While observing offRamp sequence numbers, the oracle will make a call to RMNRemote to get the cursed source chains and global/dest cursing information.

    In case of a global or dest curse, no seqNums are observered, leading to no merkleRoot processor observation.
    In case of a source chain curse, no seqNums are observed for this particular chain leading to merkle roots not observed for this source chain.

While in the tranmission protocol, the oracle makes a similar check to validate whether the report will cause a revert or not. If the report will revert onChain due to cursed chain(s) it's not transmitted.

Similar to the offRamp logic, if the report only contains chain fee and token prices while on a global/dest curse it is transmitted.
  • Loading branch information
dimkouv authored Nov 25, 2024
1 parent 66549ec commit c63f5f5
Show file tree
Hide file tree
Showing 10 changed files with 237 additions and 2 deletions.
31 changes: 29 additions & 2 deletions commit/merkleroot/observation.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,9 @@ type observerImpl struct {
msgHasher cciptypes.MessageHasher
}

// ObserveOffRampNextSeqNums observes the next sequence numbers for each source chain from the OffRamp
// ObserveOffRampNextSeqNums observes the next sequence numbers for each source chain from the OffRamp.
// If the destination chain is cursed it returns nil.
// If some source chain is cursed, it is not included in the results.
func (o observerImpl) ObserveOffRampNextSeqNums(ctx context.Context) []plugintypes.SeqNumChain {
supportsDestChain, err := o.chainSupport.SupportsDestChain(o.nodeID)
if err != nil {
Expand All @@ -273,16 +275,41 @@ func (o observerImpl) ObserveOffRampNextSeqNums(ctx context.Context) []plugintyp
}

if !supportsDestChain {
o.lggr.Debugw("cannot observe off ramp seq nums since destination chain is not supported")
return nil
}

sourceChains, err := o.chainSupport.KnownSourceChainsSlice()
allSourceChains, err := o.chainSupport.KnownSourceChainsSlice()
if err != nil {
o.lggr.Warnw("call to KnownSourceChainsSlice failed", "err", err)
return nil
}

curseInfo, err := o.ccipReader.GetRmnCurseInfo(ctx, allSourceChains)
if err != nil {
o.lggr.Errorw("failed to get rmn curse info", "err", err, "sourceChains", allSourceChains)
return nil
}

if curseInfo.GlobalCurse || curseInfo.CursedDestination {
o.lggr.Warnw("nothing to observe, chain is cursed", "curseInfo", curseInfo)
return nil
}

sourceChains := make([]cciptypes.ChainSelector, 0, len(allSourceChains))
for _, ch := range allSourceChains {
if !curseInfo.CursedSourceChains[ch] {
sourceChains = append(sourceChains, ch)
}
}
sort.Slice(sourceChains, func(i, j int) bool { return sourceChains[i] < sourceChains[j] })

if len(sourceChains) == 0 {
o.lggr.Warnw("nothing to observe from the offramp, no active source chains exist",
"curseInfo", curseInfo)
return nil
}

offRampNextSeqNums, err := o.ccipReader.NextSeqNum(ctx, sourceChains)
if err != nil {
o.lggr.Warnw("call to NextSeqNum failed", "err", err)
Expand Down
2 changes: 2 additions & 0 deletions commit/merkleroot/observation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ func Test_ObserveOffRampNextSeqNums(t *testing.T) {
chainSupport.EXPECT().KnownSourceChainsSlice().Return(knownSourceChains, nil)
ccipReader := reader_mock.NewMockCCIPReader(t)
ccipReader.EXPECT().NextSeqNum(mock.Anything, knownSourceChains).Return(nextSeqNums, nil)
ccipReader.EXPECT().GetRmnCurseInfo(mock.Anything, mock.Anything).Return(&rmntypes.CurseInfo{}, nil)
return chainSupport, ccipReader
},
expResult: []plugintypes.SeqNumChain{
Expand Down Expand Up @@ -235,6 +236,7 @@ func Test_ObserveOffRampNextSeqNums(t *testing.T) {
ccipReader := reader_mock.NewMockCCIPReader(t)
// return a smaller slice, should trigger validation condition
ccipReader.EXPECT().NextSeqNum(mock.Anything, knownSourceChains).Return(nextSeqNums[1:], nil)
ccipReader.EXPECT().GetRmnCurseInfo(mock.Anything, mock.Anything).Return(&rmntypes.CurseInfo{}, nil)
return chainSupport, ccipReader
},
expResult: nil,
Expand Down
30 changes: 30 additions & 0 deletions commit/merkleroot/rmn/types/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,33 @@ type RemoteSignerInfo struct {
// The index of the node in the RMN config
NodeIndex uint64 `json:"nodeIndex"`
}

// CurseInfo contains cursing information that are fetched from the rmn remote contract.
type CurseInfo struct {
// CursedSourceChains contains the cursed source chains.
CursedSourceChains map[cciptypes.ChainSelector]bool
// CursedDestination indicates that the destination chain is cursed.
CursedDestination bool
// GlobalCurse indicates that all chains are cursed.
GlobalCurse bool
}

// LegacyCurseSubject Defined as a const in RMNRemote.sol
// Docs of RMNRemote:
// An active curse on this subject will cause isCursed() to return true. Use this subject if there is an issue
// with a remote chain, for which there exists a legacy lane contract deployed on the same chain as this RMN contract
// is deployed, relying on isCursed().
var LegacyCurseSubject = [16]byte{
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
}

// GlobalCurseSubject Defined as a const in RMNRemote.sol
// Docs of RMNRemote:
// An active curse on this subject will cause isCursed() and isCursed(bytes16) to return true. Use this subject
// for issues affecting all of CCIP chains, or pertaining to the chain that this contract is deployed on, instead of
// using the local chain selector as a subject.
var GlobalCurseSubject = [16]byte{
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
}
1 change: 1 addition & 0 deletions commit/plugin_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,7 @@ func prepareCcipReaderMock(
ccipReader.EXPECT().
GetContractAddress(mock.Anything, mock.Anything).
Return(ccipocr3.Bytes{}, nil).Maybe()
ccipReader.EXPECT().GetRmnCurseInfo(mock.Anything, mock.Anything).Return(&rmntypes.CurseInfo{}, nil).Maybe()

if mockEmptySeqNrs {
ccipReader.EXPECT().NextSeqNum(ctx, mock.Anything).Unset()
Expand Down
42 changes: 42 additions & 0 deletions commit/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/smartcontractkit/chainlink-ccip/internal/plugincommon"
"github.com/smartcontractkit/chainlink-ccip/internal/plugincommon/consensus"
"github.com/smartcontractkit/chainlink-ccip/pkg/consts"
ccipreader "github.com/smartcontractkit/chainlink-ccip/pkg/reader"
cciptypes "github.com/smartcontractkit/chainlink-ccip/pkg/types/ccipocr3"
)

Expand Down Expand Up @@ -121,6 +122,11 @@ func (p *Plugin) ShouldAcceptAttestedReport(
return false, nil
}

if err := cursingValidation(ctx, p.ccipReader, decodedReport.MerkleRoots); err != nil {
p.lggr.Errorw("report not accepted due to cursing", "err", err)
return false, nil
}

var reportInfo ReportInfo
if err := reportInfo.Decode(r.Info); err != nil {
return false, fmt.Errorf("decode report info: %w", err)
Expand Down Expand Up @@ -172,6 +178,42 @@ func (p *Plugin) ShouldTransmitAcceptedReport(
return true, nil
}

// cursingValidation will make one contract call to get rmn curse info.
// If destination is cursed or some source chain is cursed it returns error.
func cursingValidation(
ctx context.Context,
ccipReader ccipreader.CCIPReader,
merkleRoots []cciptypes.MerkleRootChain,
) error {
// If merkleRoots are empty we still want to transmit chain fee and token prices.
// So the report is considered valid.
if len(merkleRoots) == 0 {
return nil
}

sourceChains := make([]cciptypes.ChainSelector, 0, len(merkleRoots))
for _, mr := range merkleRoots {
sourceChains = append(sourceChains, mr.ChainSel)
}

curseInfo, err := ccipReader.GetRmnCurseInfo(ctx, sourceChains)
if err != nil {
return fmt.Errorf("get rmn curse info sourceChains=%v: %w", sourceChains, err)
}

if curseInfo.CursedDestination || curseInfo.GlobalCurse {
return fmt.Errorf("destination chain is cursed: %v", curseInfo)
}

for sourceChain, isCursed := range curseInfo.CursedSourceChains {
if isCursed {
return fmt.Errorf("source chain %d is cursed", sourceChain)
}
}

return nil
}

func (p *Plugin) isCandidateInstance(ctx context.Context) (bool, error) {
ocrConfigs, err := p.homeChain.GetOCRConfigs(ctx, p.donID, consts.PluginTypeCommit)
if err != nil {
Expand Down
11 changes: 11 additions & 0 deletions internal/mocks/inmem/ccipreader_inmem.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,17 @@ func (r InMemoryCCIPReader) GetRMNRemoteConfig(
return rmntypes.RemoteConfig{}, nil
}

func (r InMemoryCCIPReader) GetRmnCurseInfo(
ctx context.Context,
sourceChainSelectors []cciptypes.ChainSelector,
) (*rmntypes.CurseInfo, error) {
return &rmntypes.CurseInfo{
CursedSourceChains: map[cciptypes.ChainSelector]bool{},
CursedDestination: false,
GlobalCurse: false,
}, nil
}

func (r InMemoryCCIPReader) LinkPriceUSD(ctx context.Context) (cciptypes.BigInt, error) {
return cciptypes.NewBigIntFromInt64(100), nil
}
Expand Down
59 changes: 59 additions & 0 deletions mocks/pkg/reader/ccip_reader.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pkg/consts/consts.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ const (
// Used by the rmn remote reader.
MethodNameGetVersionedConfig = "GetVersionedConfig"
MethodNameGetReportDigestHeader = "GetReportDigestHeader"
MethodNameGetCursedSubjects = "GetCursedSubjects"

// RMNProxy.sol methods
MethodNameGetARM = "GetARM"
Expand Down
55 changes: 55 additions & 0 deletions pkg/reader/ccip.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package reader

import (
"context"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
Expand All @@ -11,6 +12,7 @@ import (
"sync"
"time"

mapset "github.com/deckarep/golang-set/v2"
"golang.org/x/exp/maps"
"golang.org/x/sync/errgroup"

Expand Down Expand Up @@ -667,6 +669,59 @@ func (r *ccipChainReader) GetRMNRemoteConfig(
}, nil
}

// GetRmnCurseInfo returns rmn curse/pausing information about the provided chains
// from the destination chain RMN remote contract.
func (r *ccipChainReader) GetRmnCurseInfo(
ctx context.Context,
sourceChainSelectors []cciptypes.ChainSelector,
) (*rmntypes.CurseInfo, error) {
if err := validateExtendedReaderExistence(r.contractReaders, r.destChain); err != nil {
return nil, fmt.Errorf("validate dest=%d extended reader existence: %w", r.destChain, err)
}

type retTyp struct {
CursedSubjects [][16]byte
}
var cursedSubjects retTyp

err := r.contractReaders[r.destChain].ExtendedGetLatestValue(
ctx,
consts.ContractNameRMNRemote,
consts.MethodNameGetCursedSubjects,
primitives.Unconfirmed,
map[string]any{},
&cursedSubjects,
)
if err != nil {
return nil, fmt.Errorf("get latest value of %s: %w", consts.MethodNameGetCursedSubjects, err)
}

r.lggr.Debugw("got cursed subjects", "cursedSubjects", cursedSubjects.CursedSubjects)
cursedSubjectsSet := mapset.NewSet(cursedSubjects.CursedSubjects...)

curseInfo := &rmntypes.CurseInfo{
CursedSourceChains: make(map[cciptypes.ChainSelector]bool, len(sourceChainSelectors)),
CursedDestination: cursedSubjectsSet.Contains(rmntypes.LegacyCurseSubject) ||
cursedSubjectsSet.Contains(rmntypes.GlobalCurseSubject),
GlobalCurse: cursedSubjectsSet.Contains(rmntypes.GlobalCurseSubject),
}

for _, ch := range sourceChainSelectors {
chainSelB16 := chainSelectorToBytes16(ch)
r.lggr.Debugf("checking if chain %d is cursed after casting it to 16 bytes: %v", ch, chainSelB16)
curseInfo.CursedSourceChains[ch] = cursedSubjectsSet.Contains(chainSelB16)
}

return curseInfo, nil
}

func chainSelectorToBytes16(chainSel cciptypes.ChainSelector) [16]byte {
var result [16]byte
// Convert the uint64 to bytes and place it in the last 8 bytes of the array
binary.BigEndian.PutUint64(result[8:], uint64(chainSel))
return result
}

// discoverOffRampContracts uses the offRamp for a given chain to discover the addresses of other contracts.
func (r *ccipChainReader) discoverOffRampContracts(
ctx context.Context,
Expand Down
7 changes: 7 additions & 0 deletions pkg/reader/ccip_interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,13 @@ type CCIPReader interface {
destChainSelector cciptypes.ChainSelector,
) (rmntypes.RemoteConfig, error)

// GetRmnCurseInfo returns rmn curse/pausing information about the provided chains
// from the destination chain RMN remote contract. Caller should be able to access destination.
GetRmnCurseInfo(
ctx context.Context,
sourceChainSelectors []cciptypes.ChainSelector,
) (*rmntypes.CurseInfo, error)

// DiscoverContracts reads from all available contract readers to discover contract addresses.
DiscoverContracts(ctx context.Context) (ContractAddresses, error)

Expand Down

0 comments on commit c63f5f5

Please sign in to comment.