From 2e74cfa1a51eb49a7dc11fb809afd423730da222 Mon Sep 17 00:00:00 2001 From: simsonraj Date: Mon, 23 Mar 2026 12:35:17 +0530 Subject: [PATCH 01/13] Update PostValidation script --- .../changeset/testhelpers/test_environment.go | 2 +- .../ccip/changeset/v1_6/cs_chain_contracts.go | 28 +- .../ccip/operation/evm/v1_6/ops_fee_quoter.go | 37 -- deployment/ccip/shared/stateview/evm/state.go | 186 +++--- .../ccip/shared/stateview/evm/validate.go | 545 ++++++++++++++++++ .../shared/stateview/evm/validate_test.go | 291 ++++++++++ deployment/ccip/shared/stateview/state.go | 70 ++- 7 files changed, 1027 insertions(+), 132 deletions(-) create mode 100644 deployment/ccip/shared/stateview/evm/validate.go create mode 100644 deployment/ccip/shared/stateview/evm/validate_test.go diff --git a/deployment/ccip/changeset/testhelpers/test_environment.go b/deployment/ccip/changeset/testhelpers/test_environment.go index 339ce255503..fa290457369 100644 --- a/deployment/ccip/changeset/testhelpers/test_environment.go +++ b/deployment/ccip/changeset/testhelpers/test_environment.go @@ -848,7 +848,7 @@ func NewEnvironmentWithJobsAndContracts(t *testing.T, tEnv TestEnvironment) Depl state, err := stateview.LoadOnchainState(e.Env, stateview.WithLoadLegacyContracts(true)) require.NoError(t, err) - err = state.ValidatePostDeploymentState(e.Env, !tEnv.TestConfigs().SkipDONConfiguration) + err = state.ValidatePostDeploymentStateWithoutMCMSOwnership(e.Env, !tEnv.TestConfigs().SkipDONConfiguration) require.NoError(t, err) return e diff --git a/deployment/ccip/changeset/v1_6/cs_chain_contracts.go b/deployment/ccip/changeset/v1_6/cs_chain_contracts.go index d8b73e94b7f..a5aac988b29 100644 --- a/deployment/ccip/changeset/v1_6/cs_chain_contracts.go +++ b/deployment/ccip/changeset/v1_6/cs_chain_contracts.go @@ -8,6 +8,7 @@ import ( "fmt" "math/big" "slices" + "strings" "golang.org/x/sync/errgroup" @@ -1738,19 +1739,34 @@ func isOCR3ConfigSetOnOffRamp( // DefaultFeeQuoterDestChainConfig returns the default FeeQuoterDestChainConfig // with the config enabled/disabled based on the configEnabled flag. +// Fee values are set based on the destination chain type: +// - Any → Ethereum: NetworkFee=50, TokenFee=150 +// - Any → Solana: NetworkFee=10, TokenFee=35 +// - Any → other: NetworkFee=10, TokenFee=25 +// - Ethereum -> any: NetworkFee=50, TokenFee=50 ( Source-chain-dependent override that must be applied by the caller) func DefaultFeeQuoterDestChainConfig(configEnabled bool, destChainSelector ...uint64) fee_quoter.FeeQuoterDestChainConfig { familySelector, _ := hex.DecodeString(EVMFamilySelector) // evm + networkFeeUSDCents := uint32(10) + defaultTokenFeeUSDCents := uint16(25) if len(destChainSelector) > 0 { destFamily, _ := chain_selectors.GetSelectorFamily(destChainSelector[0]) switch destFamily { case chain_selectors.FamilySolana: - familySelector, _ = hex.DecodeString(SVMFamilySelector) // solana + familySelector, _ = hex.DecodeString(SVMFamilySelector) + defaultTokenFeeUSDCents = 35 case chain_selectors.FamilyAptos: - familySelector, _ = hex.DecodeString(AptosFamilySelector) // aptos + familySelector, _ = hex.DecodeString(AptosFamilySelector) case chain_selectors.FamilyTon: - familySelector, _ = hex.DecodeString(TVMFamilySelector) // ton + familySelector, _ = hex.DecodeString(TVMFamilySelector) case chain_selectors.FamilySui: - familySelector, _ = hex.DecodeString(SuiFamilySelector) // Sui + familySelector, _ = hex.DecodeString(SuiFamilySelector) + case chain_selectors.FamilyEVM: + // Ethereum destinations have higher fees + name, _ := chain_selectors.GetChainNameFromSelector(destChainSelector[0]) + if strings.HasPrefix(name, "ethereum") { + networkFeeUSDCents = 50 + defaultTokenFeeUSDCents = 150 + } } } return fee_quoter.FeeQuoterDestChainConfig{ @@ -1759,7 +1775,7 @@ func DefaultFeeQuoterDestChainConfig(configEnabled bool, destChainSelector ...ui MaxDataBytes: 30_000, MaxPerMsgGasLimit: 3_000_000, DestGasOverhead: ccipevm.DestGasOverhead, - DefaultTokenFeeUSDCents: 25, + DefaultTokenFeeUSDCents: defaultTokenFeeUSDCents, DestGasPerPayloadByteBase: ccipevm.CalldataGasPerByteBase, DestGasPerPayloadByteHigh: ccipevm.CalldataGasPerByteHigh, DestGasPerPayloadByteThreshold: ccipevm.CalldataGasPerByteThreshold, @@ -1769,7 +1785,7 @@ func DefaultFeeQuoterDestChainConfig(configEnabled bool, destChainSelector ...ui DefaultTokenDestGasOverhead: 90_000, DefaultTxGasLimit: 200_000, GasMultiplierWeiPerEth: 11e17, // Gas multiplier in wei per eth is scaled by 1e18, so 11e17 is 1.1 = 110% - NetworkFeeUSDCents: 10, + NetworkFeeUSDCents: networkFeeUSDCents, ChainFamilySelector: [4]byte(familySelector), } } diff --git a/deployment/ccip/operation/evm/v1_6/ops_fee_quoter.go b/deployment/ccip/operation/evm/v1_6/ops_fee_quoter.go index f49689c3405..c8296c04629 100644 --- a/deployment/ccip/operation/evm/v1_6/ops_fee_quoter.go +++ b/deployment/ccip/operation/evm/v1_6/ops_fee_quoter.go @@ -1,7 +1,6 @@ package v1_6 import ( - "encoding/hex" "errors" "math/big" @@ -11,14 +10,11 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" - chain_selectors "github.com/smartcontractkit/chain-selectors" - "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_6_3/fee_quoter" "github.com/smartcontractkit/chainlink/deployment" "github.com/smartcontractkit/chainlink/deployment/ccip/shared" opsutil "github.com/smartcontractkit/chainlink/deployment/common/opsutils" - "github.com/smartcontractkit/chainlink/v2/core/capabilities/ccip/ccipevm" ) type DeployFeeQInput struct { @@ -200,36 +196,3 @@ const ( SVMFamilySelector = "1e10bdc4" AptosFamilySelector = "ac77ffec" ) - -func DefaultFeeQuoterDestChainConfig(configEnabled bool, destChainSelector ...uint64) fee_quoter.FeeQuoterDestChainConfig { - familySelector, _ := hex.DecodeString(EVMFamilySelector) // evm - if len(destChainSelector) > 0 { - destFamily, _ := chain_selectors.GetSelectorFamily(destChainSelector[0]) - switch destFamily { - case chain_selectors.FamilySolana: - familySelector, _ = hex.DecodeString(SVMFamilySelector) // solana - case chain_selectors.FamilyAptos: - familySelector, _ = hex.DecodeString(AptosFamilySelector) // aptos - } - } - return fee_quoter.FeeQuoterDestChainConfig{ - IsEnabled: configEnabled, - MaxNumberOfTokensPerMsg: 10, - MaxDataBytes: 30_000, - MaxPerMsgGasLimit: 3_000_000, // TODO: this needs to be updated based on RMN sig verification per chain?! 220/250K - DestGasOverhead: ccipevm.DestGasOverhead, - DefaultTokenFeeUSDCents: 25, - DestGasPerPayloadByteBase: ccipevm.CalldataGasPerByteBase, - DestGasPerPayloadByteHigh: ccipevm.CalldataGasPerByteHigh, - DestGasPerPayloadByteThreshold: ccipevm.CalldataGasPerByteThreshold, - DestDataAvailabilityOverheadGas: 100, - DestGasPerDataAvailabilityByte: 16, - DestDataAvailabilityMultiplierBps: 1, - DefaultTokenDestGasOverhead: 90_000, - DefaultTxGasLimit: 200_000, - GasMultiplierWeiPerEth: 11e17, // Gas multiplier in wei per eth is scaled by 1e18, so 11e17 is 1.1 = 110% - NetworkFeeUSDCents: 10, - ChainFamilySelector: [4]byte(familySelector), - GasPriceStalenessThreshold: 90000, - } -} diff --git a/deployment/ccip/shared/stateview/evm/state.go b/deployment/ccip/shared/stateview/evm/state.go index 5e372d2801d..2ac9847d9f1 100644 --- a/deployment/ccip/shared/stateview/evm/state.go +++ b/deployment/ccip/shared/stateview/evm/state.go @@ -183,6 +183,7 @@ type CCIPChainState struct { // It cross-references the config across CCIPHome and OffRamps to ensure they are in sync // This should be called after the complete deployment is done func (c CCIPChainState) ValidateHomeChain(e cldf.Environment, nodes deployment.Nodes, offRampsByChain map[uint64]offramp.OffRampInterface) error { + // 1. Prerequisites if c.RMNHome == nil { return errors.New("no RMNHome contract found in the state for home chain") } @@ -192,16 +193,17 @@ func (c CCIPChainState) ValidateHomeChain(e cldf.Environment, nodes deployment.N if c.CapabilityRegistry == nil { return errors.New("no CapabilityRegistry contract found in the state for home chain") } - // get capReg from CCIPHome - capReg, err := c.CCIPHome.GetCapabilityRegistry(&bind.CallOpts{ - Context: e.GetContext(), - }) + callOpts := &bind.CallOpts{Context: e.GetContext()} + + capReg, err := c.CCIPHome.GetCapabilityRegistry(callOpts) if err != nil { return fmt.Errorf("failed to get capability registry from CCIPHome contract: %w", err) } if capReg != c.CapabilityRegistry.Address() { - return fmt.Errorf("capability registry mismatch: expected %s, got %s", capReg.Hex(), c.CapabilityRegistry.Address().Hex()) + return fmt.Errorf("capability registry mismatch: expected %s, got %s", + capReg.Hex(), c.CapabilityRegistry.Address().Hex()) } + ccipDons, err := shared.GetCCIPDonsFromCapRegistry(e.GetContext(), c.CapabilityRegistry) if err != nil { return fmt.Errorf("failed to get CCIP Dons from capability registry: %w", err) @@ -209,31 +211,99 @@ func (c CCIPChainState) ValidateHomeChain(e cldf.Environment, nodes deployment.N if len(ccipDons) == 0 { return errors.New("no CCIP Dons found in capability registry") } - // validate for all ccipDons + + // 2. HomeChain: build DON→chain mapping, validate P2P IDs + donIDByChainSel := make(map[uint64]uint32, len(ccipDons)) + var allErrs []error for _, don := range ccipDons { if err := nodes.P2PIDsPresentInJD(don.NodeP2PIds); err != nil { - return fmt.Errorf("failed to find Capability Registry p2pIDs in JD: %w", err) + allErrs = append(allErrs, fmt.Errorf("DON %d: P2P IDs not found in JD: %w", don.Id, err)) + continue } - commitConfig, err := c.CCIPHome.GetAllConfigs(&bind.CallOpts{ - Context: e.GetContext(), - }, don.Id, uint8(types.PluginTypeCCIPCommit)) + + commitConfigs, err := c.CCIPHome.GetAllConfigs(callOpts, don.Id, uint8(types.PluginTypeCCIPCommit)) if err != nil { - return fmt.Errorf("failed to get commit config for don %d: %w", don.Id, err) + allErrs = append(allErrs, fmt.Errorf("DON %d: failed to get commit configs: %w", don.Id, err)) + continue } - if err := c.validateCCIPHomeVersionedActiveConfig(e, nodes, commitConfig.ActiveConfig, offRampsByChain); err != nil { - return fmt.Errorf("failed to validate active commit config for don %d: %w", don.Id, err) + execConfigs, err := c.CCIPHome.GetAllConfigs(callOpts, don.Id, uint8(types.PluginTypeCCIPExec)) + if err != nil { + allErrs = append(allErrs, fmt.Errorf("DON %d: failed to get exec configs: %w", don.Id, err)) + continue } - execConfig, err := c.CCIPHome.GetAllConfigs(&bind.CallOpts{ - Context: e.GetContext(), - }, don.Id, uint8(types.PluginTypeCCIPExec)) + + // Resolve chain selector: prefer active, fall back to candidate if not yet promoted. + chainSel := commitConfigs.ActiveConfig.Config.ChainSelector + if chainSel == 0 { + chainSel = commitConfigs.CandidateConfig.Config.ChainSelector + } + if chainSel == 0 { + chainSel = execConfigs.ActiveConfig.Config.ChainSelector + if chainSel == 0 { + chainSel = execConfigs.CandidateConfig.Config.ChainSelector + } + } + if chainSel != 0 { + donIDByChainSel[chainSel] = don.Id + } + } + + // 3: Per-chain validation + for chainSel := range offRampsByChain { + donID, ok := donIDByChainSel[chainSel] + if !ok || donID == 0 { + allErrs = append(allErrs, fmt.Errorf("chain %d: no DON ID found in CCIPHome", chainSel)) + continue + } + + chainConfig, err := c.CCIPHome.GetChainConfig(callOpts, chainSel) + if err != nil { + allErrs = append(allErrs, fmt.Errorf("chain %d: failed to get CCIPHome chain config: %w", chainSel, err)) + continue + } + if len(chainConfig.Readers) == 0 { + allErrs = append(allErrs, fmt.Errorf("chain %d: CCIPHome chain config has no readers", chainSel)) + } + if chainConfig.FChain == 0 { + allErrs = append(allErrs, fmt.Errorf("chain %d: CCIPHome chain config FChain is 0", chainSel)) + } + + commitCandidateDigest, err := c.CCIPHome.GetCandidateDigest(callOpts, donID, uint8(types.PluginTypeCCIPCommit)) if err != nil { - return fmt.Errorf("failed to get exec config for don %d: %w", don.Id, err) + allErrs = append(allErrs, fmt.Errorf("DON %d chain %d: failed to get commit candidate digest: %w", donID, chainSel, err)) + } else if commitCandidateDigest != [32]byte{} { + allErrs = append(allErrs, fmt.Errorf("DON %d chain %d: stale commit candidate digest: %x", donID, chainSel, commitCandidateDigest)) } - if err := c.validateCCIPHomeVersionedActiveConfig(e, nodes, execConfig.ActiveConfig, offRampsByChain); err != nil { - return fmt.Errorf("failed to validate active exec config for don %d: %w", don.Id, err) + + execCandidateDigest, err := c.CCIPHome.GetCandidateDigest(callOpts, donID, uint8(types.PluginTypeCCIPExec)) + if err != nil { + allErrs = append(allErrs, fmt.Errorf("DON %d chain %d: failed to get exec candidate digest: %w", donID, chainSel, err)) + } else if execCandidateDigest != [32]byte{} { + allErrs = append(allErrs, fmt.Errorf("DON %d chain %d: stale exec candidate digest: %x", donID, chainSel, execCandidateDigest)) } } - return nil + + // 4: OCR3 config validation + for _, don := range ccipDons { + commitConfigs, err := c.CCIPHome.GetAllConfigs(callOpts, don.Id, uint8(types.PluginTypeCCIPCommit)) + if err != nil { + // Already reported in 2 + continue + } + if err := c.validateCCIPHomeVersionedActiveConfig(e, nodes, commitConfigs.ActiveConfig, offRampsByChain); err != nil { + allErrs = append(allErrs, fmt.Errorf("DON %d: active commit config validation failed: %w", don.Id, err)) + } + + execConfigs, err := c.CCIPHome.GetAllConfigs(callOpts, don.Id, uint8(types.PluginTypeCCIPExec)) + if err != nil { + continue + } + if err := c.validateCCIPHomeVersionedActiveConfig(e, nodes, execConfigs.ActiveConfig, offRampsByChain); err != nil { + allErrs = append(allErrs, fmt.Errorf("DON %d: active exec config validation failed: %w", don.Id, err)) + } + } + + return errors.Join(allErrs...) } // validateCCIPHomeVersionedActiveConfig validates the CCIPHomeVersionedConfig based on the corresponding chain selector and its state @@ -400,40 +470,20 @@ func (c CCIPChainState) ValidateOnRamp( return fmt.Errorf("failed to get dest chain config from source chain %d onRamp %s for dest chain %d: %w", selector, c.OnRamp.Address(), otherChainSel, err) } - // if not blank, the dest chain config should be enabled - if destChainCfg != (onramp.GetDestChainConfig{}) { - if destChainCfg.Router != c.Router.Address() && destChainCfg.Router != c.TestRouter.Address() { - return fmt.Errorf("onRamp %s router mismatch in dest chain config: expected router %s or test router %s, got %s", - c.OnRamp.Address().Hex(), c.Router.Address().Hex(), c.TestRouter.Address().Hex(), destChainCfg.Router.Hex()) - } + // dest chain config must be configured (non-blank means a router is set) + if destChainCfg == (onramp.GetDestChainConfig{}) { + return fmt.Errorf("onRamp %s dest chain config is blank for dest chain %d", + c.OnRamp.Address().Hex(), otherChainSel) + } + if destChainCfg.Router != c.Router.Address() && destChainCfg.Router != c.TestRouter.Address() { + return fmt.Errorf("onRamp %s router mismatch in dest chain config: expected router %s or test router %s, got %s", + c.OnRamp.Address().Hex(), c.Router.Address().Hex(), c.TestRouter.Address().Hex(), destChainCfg.Router.Hex()) } } return nil } -// ValidateFeeQuoter validates whether the fee quoter contract address configured in static config is in sync with state -func (c CCIPChainState) ValidateFeeQuoter(e cldf.Environment) error { - if c.FeeQuoter == nil { - return errors.New("no FeeQuoter contract found in the state") - } - staticConfig, err := c.FeeQuoter.GetStaticConfig(&bind.CallOpts{ - Context: e.GetContext(), - }) - if err != nil { - return fmt.Errorf("failed to get static config for FeeQuoter %s: %w", c.FeeQuoter.Address().Hex(), err) - } - linktokenAddr, err := c.LinkTokenAddress() - if err != nil { - return fmt.Errorf("failed to get link token address for from state: %w", err) - } - if staticConfig.LinkToken != linktokenAddr { - return fmt.Errorf("feeQuoter %s LinkToken mismatch: expected either linktoken %s or static link token %s, got %s", - c.FeeQuoter.Address().Hex(), c.LinkToken.Address().Hex(), c.StaticLinkToken.Address(), staticConfig.LinkToken.Hex()) - } - return nil -} - // ValidateRouter validates the router contract to check if all wired contracts are synced with state // and returns all connected chains with respect to the router func (c CCIPChainState) ValidateRouter(e cldf.Environment, isTestRouter bool) ([]uint64, error) { @@ -597,24 +647,28 @@ func (c CCIPChainState) ValidateOffRamp( if err != nil { return fmt.Errorf("failed to get source chain config for chain %d: %w", chainSel, err) } - if config.IsEnabled { - // For all configured sources, the address of configured onRamp for chain A must be the Address() of the onramp on chain A - if srcChainOnRamp != common.BytesToAddress(config.OnRamp) { - return fmt.Errorf("onRamp address mismatch for source chain %d on OffRamp %s : expected %s, got %x", - chainSel, c.OffRamp.Address().Hex(), srcChainOnRamp.Hex(), config.OnRamp) - } - // The address of router should be accurate - if c.Router.Address() != config.Router && c.TestRouter.Address() != config.Router { - return fmt.Errorf("router address mismatch for source chain %d on OffRamp %s : expected either router %s or test router %s, got %s", - chainSel, c.OffRamp.Address().Hex(), c.Router.Address().Hex(), c.TestRouter.Address().Hex(), config.Router.Hex()) - } - // if RMN is enabled for the source chain, the RMNRemote and RMNHome should be configured to enable RMN - // the reverse is not always true, as RMN verification can be disable at offRamp but enabled in RMNRemote and RMNHome - if !config.IsRMNVerificationDisabled && !isRMNEnabledBySource[chainSel] { - return fmt.Errorf("RMN verification is enabled in offRamp %s for source chain %d, "+ - "but RMN is not enabled in RMNHome and RMNRemote for the chain", - c.OffRamp.Address().Hex(), chainSel) - } + // Source chain must be enabled + if !config.IsEnabled { + return fmt.Errorf("source chain %d is not enabled on OffRamp %s", + chainSel, c.OffRamp.Address().Hex()) + } + // For all configured sources, the address of configured onRamp for chain A must be the Address() of the onramp on chain A + if srcChainOnRamp != common.BytesToAddress(config.OnRamp) { + return fmt.Errorf("onRamp address mismatch for source chain %d on OffRamp %s : expected %s, got %x", + chainSel, c.OffRamp.Address().Hex(), srcChainOnRamp.Hex(), config.OnRamp) + } + // The address of router should be accurate + if c.Router.Address() != config.Router && c.TestRouter.Address() != config.Router { + return fmt.Errorf("router address mismatch for source chain %d on OffRamp %s : expected either router %s or test router %s, got %s", + chainSel, c.OffRamp.Address().Hex(), c.Router.Address().Hex(), c.TestRouter.Address().Hex(), config.Router.Hex()) + } + // RMN verification disabled flag check: + // if RMN is enabled for the source chain, the RMNRemote and RMNHome should be configured to enable RMN + // the reverse is not always true, as RMN verification can be disabled at offRamp but enabled in RMNRemote and RMNHome + if !config.IsRMNVerificationDisabled && !isRMNEnabledBySource[chainSel] { + return fmt.Errorf("RMN verification is enabled in offRamp %s for source chain %d, "+ + "but RMN is not enabled in RMNHome and RMNRemote for the chain", + c.OffRamp.Address().Hex(), chainSel) } } return nil diff --git a/deployment/ccip/shared/stateview/evm/validate.go b/deployment/ccip/shared/stateview/evm/validate.go new file mode 100644 index 00000000000..ae99741cf88 --- /dev/null +++ b/deployment/ccip/shared/stateview/evm/validate.go @@ -0,0 +1,545 @@ +package evm + +import ( + "errors" + "fmt" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + + chain_selectors "github.com/smartcontractkit/chain-selectors" + + "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_5_0/evm_2_evm_onramp" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_6_3/fee_quoter" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + + "github.com/smartcontractkit/chainlink/deployment/ccip/changeset/globals" + viewshared "github.com/smartcontractkit/chainlink/deployment/ccip/view/shared" + "github.com/smartcontractkit/chainlink/v2/core/capabilities/ccip/ccipevm" +) + +// ValidateNonceManager checks that NonceManager previous ramps point to the correct v1.5 contracts +func (c CCIPChainState) ValidateNonceManager( + e cldf.Environment, + selector uint64, + connectedChains []uint64, +) error { + if c.NonceManager == nil { + return errors.New("no NonceManager contract found in the state") + } + callOpts := &bind.CallOpts{Context: e.GetContext()} + var errs []error + + for _, remoteChainSel := range connectedChains { + if remoteChainSel == selector { + continue + } + previousRamps, err := c.NonceManager.GetPreviousRamps(callOpts, remoteChainSel) + if err != nil { + errs = append(errs, fmt.Errorf("failed to get previous ramps for remote chain %d on chain %d: %w", + remoteChainSel, selector, err)) + continue + } + if c.EVM2EVMOnRamp != nil && c.EVM2EVMOnRamp[remoteChainSel] != nil { + expectedOnRamp := c.EVM2EVMOnRamp[remoteChainSel].Address() + if previousRamps.PrevOnRamp != expectedOnRamp { + errs = append(errs, fmt.Errorf("NonceManager %s PrevOnRamp mismatch for remote chain %d on chain %d: expected %s, got %s", + c.NonceManager.Address().Hex(), remoteChainSel, selector, + expectedOnRamp.Hex(), previousRamps.PrevOnRamp.Hex())) + } + } + if c.EVM2EVMOffRamp != nil && c.EVM2EVMOffRamp[remoteChainSel] != nil { + expectedOffRamp := c.EVM2EVMOffRamp[remoteChainSel].Address() + if previousRamps.PrevOffRamp != expectedOffRamp { + errs = append(errs, fmt.Errorf("NonceManager %s PrevOffRamp mismatch for remote chain %d on chain %d: expected %s, got %s", + c.NonceManager.Address().Hex(), remoteChainSel, selector, + expectedOffRamp.Hex(), previousRamps.PrevOffRamp.Hex())) + } + } + } + return errors.Join(errs...) +} + +// ValidateRMNProxy checks that RMNProxy.GetARM() returns the RMNRemote address +func (c CCIPChainState) ValidateRMNProxy(e cldf.Environment) error { + if c.RMNProxy == nil { + return errors.New("no RMNProxy contract found in the state") + } + if c.RMNRemote == nil { + return errors.New("no RMNRemote contract found for RMNProxy validation") + } + callOpts := &bind.CallOpts{Context: e.GetContext()} + armAddr, err := c.RMNProxy.GetARM(callOpts) + if err != nil { + return fmt.Errorf("failed to get ARM from RMNProxy %s: %w", c.RMNProxy.Address().Hex(), err) + } + if armAddr != c.RMNRemote.Address() { + return fmt.Errorf("RMNProxy %s GetARM mismatch: expected RMNRemote %s, got %s", + c.RMNProxy.Address().Hex(), c.RMNRemote.Address().Hex(), armAddr.Hex()) + } + return nil +} + +func isEthereumChain(selector uint64) bool { + return selector == chain_selectors.ETHEREUM_MAINNET.Selector || + selector == chain_selectors.ETHEREUM_TESTNET_SEPOLIA.Selector +} + +// expectedNetworkFeeUSDCents: Ethereum involvement → 50, otherwise → 10 +func expectedNetworkFeeUSDCents(srcSel, destSel uint64) uint32 { + if isEthereumChain(destSel) || isEthereumChain(srcSel) { + return 50 + } + return 10 +} + +// expectedDefaultTokenFeeUSDCents: →ETH=150, ETH→=50, →SOL=35, other=25 +func expectedDefaultTokenFeeUSDCents(srcSel, destSel uint64) uint16 { + if isEthereumChain(destSel) { + return 150 + } + if isEthereumChain(srcSel) { + return 50 + } + destFamily, _ := chain_selectors.GetSelectorFamily(destSel) + if destFamily == chain_selectors.FamilySolana { + return 35 + } + return 25 +} + +// ValidateFeeQuoter performs chain-level checks and version-specific lane-level validation +// Migrated chains are cross-checked against v1.5, fresh chains against defaults +func (c CCIPChainState) ValidateFeeQuoter( + e cldf.Environment, + sourceChainSel uint64, + connectedChains []uint64, +) error { + if c.FeeQuoter == nil { + return errors.New("no FeeQuoter contract found in the state") + } + callOpts := &bind.CallOpts{Context: e.GetContext()} + fqAddr := c.FeeQuoter.Address().Hex() + var errs []error + + staticConfig, err := c.FeeQuoter.GetStaticConfig(callOpts) + if err != nil { + return fmt.Errorf("failed to get static config for FeeQuoter %s: %w", fqAddr, err) + } + linktokenAddr, err := c.LinkTokenAddress() + if err != nil { + return fmt.Errorf("failed to get link token address from state: %w", err) + } + if staticConfig.LinkToken != linktokenAddr { + errs = append(errs, fmt.Errorf("FeeQuoter %s LinkToken mismatch: expected %s, got %s", + fqAddr, linktokenAddr.Hex(), staticConfig.LinkToken.Hex())) + } + + feeTokens, err := c.FeeQuoter.GetFeeTokens(callOpts) + if err != nil { + errs = append(errs, fmt.Errorf("failed to get fee tokens from FeeQuoter %s: %w", fqAddr, err)) + return errors.Join(errs...) + } + + if err := c.validateFeeTokenConfigs(callOpts, fqAddr, feeTokens); err != nil { + errs = append(errs, err) + } + + if len(connectedChains) == 0 { + // No lanes wired yet — skip lane-level validation (valid during early deployment) + return errors.Join(errs...) + } + + if c.FeeQuoterVersion == nil { + errs = append(errs, fmt.Errorf("FeeQuoter %s: version not set, cannot perform lane-level validation", fqAddr)) + return errors.Join(errs...) + } + switch c.FeeQuoterVersion.Major() { + case 1: + if err := c.validateDestChainConfigs(callOpts, fqAddr, sourceChainSel, connectedChains, feeTokens); err != nil { + errs = append(errs, err) + } + if err := c.validateTokenTransferFeeConfigs(callOpts, fqAddr, connectedChains); err != nil { + errs = append(errs, err) + } + case 2: + // TODO: implement FeeQuoter 2.0 lane-level validation + default: + errs = append(errs, fmt.Errorf("FeeQuoter %s: unsupported version %s for lane-level validation", + fqAddr, c.FeeQuoterVersion.String())) + } + + return errors.Join(errs...) +} + +// validateDestChainConfigs cross-checks against v1.5 OnRamp if migrated, or validates defaults if fresh +func (c CCIPChainState) validateDestChainConfigs( + callOpts *bind.CallOpts, + fqAddr string, + sourceChainSel uint64, + connectedChains []uint64, + feeTokens []common.Address, +) error { + var errs []error + + for _, destChainSel := range connectedChains { + destCfg, err := c.FeeQuoter.GetDestChainConfig(callOpts, destChainSel) + if err != nil { + errs = append(errs, fmt.Errorf("failed to get FeeQuoter dest chain config for chain %d: %w", destChainSel, err)) + continue + } + if !destCfg.IsEnabled { + errs = append(errs, fmt.Errorf("FeeQuoter %s dest chain config not enabled for chain %d", fqAddr, destChainSel)) + } + + legacyOnRamp := c.EVM2EVMOnRamp[destChainSel] + if legacyOnRamp != nil { + if err := c.validateFeeQuoterAgainstLegacyOnRamp(callOpts, fqAddr, destChainSel, destCfg, legacyOnRamp); err != nil { + errs = append(errs, err) + } + // GasMultiplierWeiPerEth moved from per-token (v1.5 FeeTokenConfig) to per-dest (v1.6) + for _, ft := range feeTokens { + legacyFTCfg, err := legacyOnRamp.GetFeeTokenConfig(callOpts, ft) + if err != nil || !legacyFTCfg.Enabled { + continue + } + if destCfg.GasMultiplierWeiPerEth != legacyFTCfg.GasMultiplierWeiPerEth { + errs = append(errs, fmt.Errorf("FeeQuoter %s GasMultiplierWeiPerEth mismatch for dest chain %d: "+ + "v1.6=%d, v1.5 FeeTokenConfig=%d", + fqAddr, destChainSel, destCfg.GasMultiplierWeiPerEth, legacyFTCfg.GasMultiplierWeiPerEth)) + } + break + } + } else { + if err := validateFeeQuoterDestCfgDefaults(fqAddr, sourceChainSel, destChainSel, destCfg); err != nil { + errs = append(errs, err) + } + } + + if destCfg.ChainFamilySelector == [4]byte{} { + errs = append(errs, fmt.Errorf("FeeQuoter %s ChainFamilySelector is empty for dest chain %d", fqAddr, destChainSel)) + } + if destCfg.GasPriceStalenessThreshold == 0 { + errs = append(errs, fmt.Errorf("FeeQuoter %s GasPriceStalenessThreshold is 0 for dest chain %d", fqAddr, destChainSel)) + } + for _, chk := range []struct { + name string + got uint64 + expected uint64 + }{ + {"DestGasPerPayloadByteHigh", uint64(destCfg.DestGasPerPayloadByteHigh), uint64(ccipevm.CalldataGasPerByteHigh)}, + {"DestGasPerPayloadByteThreshold", uint64(destCfg.DestGasPerPayloadByteThreshold), uint64(ccipevm.CalldataGasPerByteThreshold)}, + {"DefaultTxGasLimit", uint64(destCfg.DefaultTxGasLimit), 200_000}, + {"NetworkFeeUSDCents", uint64(destCfg.NetworkFeeUSDCents), uint64(expectedNetworkFeeUSDCents(sourceChainSel, destChainSel))}, + } { + if chk.got != chk.expected { + errs = append(errs, fmt.Errorf("FeeQuoter %s %s mismatch for dest chain %d: expected %d, got %d", + fqAddr, chk.name, destChainSel, chk.expected, chk.got)) + } + } + + destFamily, _ := chain_selectors.GetSelectorFamily(destChainSel) + if destFamily != chain_selectors.FamilyEVM && !destCfg.EnforceOutOfOrder { + errs = append(errs, fmt.Errorf("FeeQuoter %s EnforceOutOfOrder must be true for non-EVM dest chain %d (family %s)", + fqAddr, destChainSel, destFamily)) + } + } + + return errors.Join(errs...) +} + +// validateFeeTokenConfigs checks fee token presence, v1.5 PriceRegistry superset, and premium multipliers +func (c CCIPChainState) validateFeeTokenConfigs( + callOpts *bind.CallOpts, + fqAddr string, + feeTokens []common.Address, +) error { + var errs []error + + if len(feeTokens) == 0 { + errs = append(errs, fmt.Errorf("FeeQuoter %s has no fee tokens configured", fqAddr)) + } + if c.PriceRegistry != nil { + legacyFeeTokens, err := c.PriceRegistry.GetFeeTokens(callOpts) + if err != nil { + errs = append(errs, fmt.Errorf("failed to get fee tokens from v1.5 PriceRegistry: %w", err)) + } else { + feeTokenSet := make(map[common.Address]bool, len(feeTokens)) + for _, ft := range feeTokens { + feeTokenSet[ft] = true + } + for _, legacyFT := range legacyFeeTokens { + if !feeTokenSet[legacyFT] { + errs = append(errs, fmt.Errorf("FeeQuoter %s missing fee token %s from v1.5 PriceRegistry", + fqAddr, legacyFT.Hex())) + } + } + } + } + + var anyLegacyOnRamp *evm_2_evm_onramp.EVM2EVMOnRamp + if c.EVM2EVMOnRamp != nil { + for _, onRamp := range c.EVM2EVMOnRamp { + if onRamp != nil { + anyLegacyOnRamp = onRamp + break + } + } + } + + for _, feeToken := range feeTokens { + premium, err := c.FeeQuoter.GetPremiumMultiplierWeiPerEth(callOpts, feeToken) + if err != nil { + errs = append(errs, fmt.Errorf("failed to get PremiumMultiplierWeiPerEth for token %s on FeeQuoter %s: %w", + feeToken.Hex(), fqAddr, err)) + continue + } + if premium == 0 { + errs = append(errs, fmt.Errorf("FeeQuoter %s PremiumMultiplierWeiPerEth is 0 for fee token %s", + fqAddr, feeToken.Hex())) + } + if anyLegacyOnRamp != nil { + legacyFeeTokenCfg, err := anyLegacyOnRamp.GetFeeTokenConfig(callOpts, feeToken) + if err == nil && legacyFeeTokenCfg.Enabled && premium != legacyFeeTokenCfg.PremiumMultiplierWeiPerEth { + errs = append(errs, fmt.Errorf("FeeQuoter %s PremiumMultiplierWeiPerEth mismatch for fee token %s: "+ + "v1.6 has %d, v1.5 OnRamp had %d", + fqAddr, feeToken.Hex(), premium, legacyFeeTokenCfg.PremiumMultiplierWeiPerEth)) + } + } + } + + return errors.Join(errs...) +} + +// validateTokenTransferFeeConfigs checks per-token-per-dest fee invariants and v1.5 cross-checks +func (c CCIPChainState) validateTokenTransferFeeConfigs( + callOpts *bind.CallOpts, + fqAddr string, + connectedChains []uint64, +) error { + if c.TokenAdminRegistry == nil { + return errors.New("no TokenAdminRegistry contract found, cannot validate token transfer fee configs") + } + + allTokens, err := viewshared.GetSupportedTokens(c.TokenAdminRegistry) + if err != nil { + return fmt.Errorf("failed to get configured tokens from TokenAdminRegistry: %w", err) + } + + addrToSymbol := make(map[common.Address]string) + if symbolMap, symErr := c.TokenAddressBySymbol(); symErr == nil { + for symbol, addr := range symbolMap { + addrToSymbol[addr] = string(symbol) + } + } + + var errs []error + for _, tokenAddr := range allTokens { + tokenLabel := tokenAddr.Hex() + if sym, ok := addrToSymbol[tokenAddr]; ok { + tokenLabel = fmt.Sprintf("%s (%s)", sym, tokenAddr.Hex()) + } + + for _, destChainSel := range connectedChains { + ttfCfg, err := c.FeeQuoter.GetTokenTransferFeeConfig(callOpts, destChainSel, tokenAddr) + if err != nil { + continue + } + if !ttfCfg.IsEnabled { + continue + } + if ttfCfg.MinFeeUSDCents >= ttfCfg.MaxFeeUSDCents { + errs = append(errs, fmt.Errorf("FeeQuoter %s TokenTransferFeeConfig for token %s to dest chain %d: "+ + "MinFeeUSDCents (%d) must be less than MaxFeeUSDCents (%d)", + fqAddr, tokenLabel, destChainSel, + ttfCfg.MinFeeUSDCents, ttfCfg.MaxFeeUSDCents)) + } + if ttfCfg.DestBytesOverhead < globals.CCIPLockOrBurnV1RetBytes { + errs = append(errs, fmt.Errorf("FeeQuoter %s TokenTransferFeeConfig for token %s to dest chain %d: "+ + "DestBytesOverhead (%d) must be at least %d", + fqAddr, tokenLabel, destChainSel, + ttfCfg.DestBytesOverhead, globals.CCIPLockOrBurnV1RetBytes)) + } + + legacyOnRamp := c.EVM2EVMOnRamp[destChainSel] + if legacyOnRamp == nil { + continue + } + legacyTTF, err := legacyOnRamp.GetTokenTransferFeeConfig(callOpts, tokenAddr) + if err != nil || !legacyTTF.IsEnabled { + continue + } + for _, chk := range []struct { + name string + v16Val uint64 + v15Val uint64 + }{ + {"MinFeeUSDCents", uint64(ttfCfg.MinFeeUSDCents), uint64(legacyTTF.MinFeeUSDCents)}, + {"MaxFeeUSDCents", uint64(ttfCfg.MaxFeeUSDCents), uint64(legacyTTF.MaxFeeUSDCents)}, + {"DeciBps", uint64(ttfCfg.DeciBps), uint64(legacyTTF.DeciBps)}, + {"DestGasOverhead", uint64(ttfCfg.DestGasOverhead), uint64(legacyTTF.DestGasOverhead)}, + {"DestBytesOverhead", uint64(ttfCfg.DestBytesOverhead), uint64(legacyTTF.DestBytesOverhead)}, + } { + if chk.v16Val != chk.v15Val { + errs = append(errs, fmt.Errorf("FeeQuoter %s TokenTransferFeeConfig for token %s to dest %d: "+ + "%s mismatch: v1.6=%d, v1.5=%d", + fqAddr, tokenLabel, destChainSel, chk.name, chk.v16Val, chk.v15Val)) + } + } + } + } + + return errors.Join(errs...) +} + +// validateFeeQuoterAgainstLegacyOnRamp cross-checks v1.6 dest chain config against v1.5 OnRamp DynamicConfig +func (c CCIPChainState) validateFeeQuoterAgainstLegacyOnRamp( + callOpts *bind.CallOpts, + fqAddr string, + destChainSel uint64, + destCfg fee_quoter.FeeQuoterDestChainConfig, + legacyOnRamp *evm_2_evm_onramp.EVM2EVMOnRamp, +) error { + legacyCfg, err := legacyOnRamp.GetDynamicConfig(callOpts) + if err != nil { + return fmt.Errorf("failed to get v1.5 OnRamp dynamic config for dest chain %d: %w", destChainSel, err) + } + var errs []error + + for _, chk := range []struct { + name string + v16 any + v15 any + }{ + {"MaxNumberOfTokensPerMsg", uint64(destCfg.MaxNumberOfTokensPerMsg), uint64(legacyCfg.MaxNumberOfTokensPerMsg)}, + {"DestGasOverhead", uint64(destCfg.DestGasOverhead), uint64(legacyCfg.DestGasOverhead)}, + {"DestDataAvailabilityOverheadGas", uint64(destCfg.DestDataAvailabilityOverheadGas), uint64(legacyCfg.DestDataAvailabilityOverheadGas)}, + {"DestGasPerDataAvailabilityByte", uint64(destCfg.DestGasPerDataAvailabilityByte), uint64(legacyCfg.DestGasPerDataAvailabilityByte)}, + {"DestDataAvailabilityMultiplierBps", uint64(destCfg.DestDataAvailabilityMultiplierBps), uint64(legacyCfg.DestDataAvailabilityMultiplierBps)}, + {"MaxDataBytes", uint64(destCfg.MaxDataBytes), uint64(legacyCfg.MaxDataBytes)}, + {"MaxPerMsgGasLimit", uint64(destCfg.MaxPerMsgGasLimit), uint64(legacyCfg.MaxPerMsgGasLimit)}, + {"DefaultTokenDestGasOverhead", uint64(destCfg.DefaultTokenDestGasOverhead), uint64(legacyCfg.DefaultTokenDestGasOverhead)}, + {"DefaultTokenFeeUSDCents", uint64(destCfg.DefaultTokenFeeUSDCents), uint64(legacyCfg.DefaultTokenFeeUSDCents)}, + {"EnforceOutOfOrder", destCfg.EnforceOutOfOrder, legacyCfg.EnforceOutOfOrder}, + {"DestGasPerPayloadByteBase", uint64(destCfg.DestGasPerPayloadByteBase), uint64(uint8(legacyCfg.DestGasPerPayloadByte))}, //nolint:gosec // G115: intentional v1.5 uint16→uint8 truncation during migration + } { + if chk.v16 != chk.v15 { + errs = append(errs, fmt.Errorf("FeeQuoter %s %s mismatch for dest chain %d: v1.6=%v, v1.5=%v", + fqAddr, chk.name, destChainSel, chk.v16, chk.v15)) + } + } + + return errors.Join(errs...) +} + +// validateFeeQuoterDestCfgDefaults checks fresh v1.6 dest config against known defaults +// (mirrors DefaultFeeQuoterDestChainConfig in cs_chain_contracts.go) +func validateFeeQuoterDestCfgDefaults( + fqAddr string, + sourceChainSel uint64, + destChainSel uint64, + destCfg fee_quoter.FeeQuoterDestChainConfig, +) error { + var errs []error + + for _, chk := range []struct { + name string + got uint64 + expected uint64 + }{ + {"MaxNumberOfTokensPerMsg", uint64(destCfg.MaxNumberOfTokensPerMsg), 10}, + {"MaxDataBytes", uint64(destCfg.MaxDataBytes), 30_000}, + {"MaxPerMsgGasLimit", uint64(destCfg.MaxPerMsgGasLimit), 3_000_000}, + {"DestGasOverhead", uint64(destCfg.DestGasOverhead), uint64(ccipevm.DestGasOverhead)}, + {"DestGasPerPayloadByteBase", uint64(destCfg.DestGasPerPayloadByteBase), uint64(ccipevm.CalldataGasPerByteBase)}, + {"DefaultTokenDestGasOverhead", uint64(destCfg.DefaultTokenDestGasOverhead), 90_000}, + {"DestDataAvailabilityOverheadGas", uint64(destCfg.DestDataAvailabilityOverheadGas), 100}, + {"DestGasPerDataAvailabilityByte", uint64(destCfg.DestGasPerDataAvailabilityByte), 16}, + {"DestDataAvailabilityMultiplierBps", uint64(destCfg.DestDataAvailabilityMultiplierBps), 1}, + {"GasMultiplierWeiPerEth", destCfg.GasMultiplierWeiPerEth, uint64(11e17)}, + {"DefaultTokenFeeUSDCents", uint64(destCfg.DefaultTokenFeeUSDCents), uint64(expectedDefaultTokenFeeUSDCents(sourceChainSel, destChainSel))}, + } { + if chk.got != chk.expected { + errs = append(errs, fmt.Errorf("FeeQuoter %s %s mismatch for dest chain %d: expected %d, got %d", + fqAddr, chk.name, destChainSel, chk.expected, chk.got)) + } + } + + return errors.Join(errs...) +} + +type ownableContract interface { + Owner(opts *bind.CallOpts) (common.Address, error) + Address() common.Address +} + +func checkOwnership(callOpts *bind.CallOpts, name string, contract ownableContract, expectedOwner common.Address) error { + owner, err := contract.Owner(callOpts) + if err != nil { + return fmt.Errorf("failed to get %s owner: %w", name, err) + } + if owner != expectedOwner { + return fmt.Errorf("%s %s not owned by expected owner %s, actual owner: %s", + name, contract.Address().Hex(), expectedOwner.Hex(), owner.Hex()) + } + return nil +} + +// ValidateContractOwnership checks all CCIP contracts are owned by the MCMS Timelock +func (c CCIPChainState) ValidateContractOwnership(e cldf.Environment) error { + if c.Timelock == nil { + return errors.New("timelock not found in state, cannot validate ownership") + } + timelockAddr := c.Timelock.Address() + callOpts := &bind.CallOpts{Context: e.GetContext()} + var errs []error + + if c.FeeQuoter != nil { + if err := checkOwnership(callOpts, "FeeQuoter", c.FeeQuoter, timelockAddr); err != nil { + errs = append(errs, err) + } + } + if c.NonceManager != nil { + if err := checkOwnership(callOpts, "NonceManager", c.NonceManager, timelockAddr); err != nil { + errs = append(errs, err) + } + } + if c.RMNRemote != nil { + if err := checkOwnership(callOpts, "RMNRemote", c.RMNRemote, timelockAddr); err != nil { + errs = append(errs, err) + } + } + if c.OnRamp != nil { + if err := checkOwnership(callOpts, "OnRamp", c.OnRamp, timelockAddr); err != nil { + errs = append(errs, err) + } + } + if c.OffRamp != nil { + if err := checkOwnership(callOpts, "OffRamp", c.OffRamp, timelockAddr); err != nil { + errs = append(errs, err) + } + } + if c.Router != nil { + if err := checkOwnership(callOpts, "Router", c.Router, timelockAddr); err != nil { + errs = append(errs, err) + } + } + + if c.ProposerMcm != nil { + if err := checkOwnership(callOpts, "ProposerMcm", c.ProposerMcm, c.ProposerMcm.Address()); err != nil { + errs = append(errs, err) + } + } + if c.CancellerMcm != nil { + if err := checkOwnership(callOpts, "CancellerMcm", c.CancellerMcm, c.CancellerMcm.Address()); err != nil { + errs = append(errs, err) + } + } + if c.BypasserMcm != nil { + if err := checkOwnership(callOpts, "BypasserMcm", c.BypasserMcm, c.BypasserMcm.Address()); err != nil { + errs = append(errs, err) + } + } + + return errors.Join(errs...) +} diff --git a/deployment/ccip/shared/stateview/evm/validate_test.go b/deployment/ccip/shared/stateview/evm/validate_test.go new file mode 100644 index 00000000000..da6cb0c885b --- /dev/null +++ b/deployment/ccip/shared/stateview/evm/validate_test.go @@ -0,0 +1,291 @@ +package evm_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + chain_selectors "github.com/smartcontractkit/chain-selectors" + + cldf_chain "github.com/smartcontractkit/chainlink-deployments-framework/chain" + + "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_6_0/offramp" + + "github.com/smartcontractkit/chainlink/deployment" + "github.com/smartcontractkit/chainlink/deployment/ccip/changeset/testhelpers" + "github.com/smartcontractkit/chainlink/deployment/ccip/shared/stateview" +) + +// transferOwnershipToTimelock transfers all CCIP contract ownership to the +// MCMS Timelock using the standard test helper. After the transfer, the MCMS +// multisig contracts (ProposerMcm, CancellerMcm, BypasserMcm) are nil-ed out +// because they cannot be made self-governed in the test environment. +func transferOwnershipToTimelock( + t *testing.T, + tenv testhelpers.DeployedEnv, + state stateview.CCIPOnChainState, + selectors []uint64, +) { + t.Helper() + testhelpers.TransferToTimelock(t, tenv, state, selectors, false) + // MCMS multisig contracts are deployed by the deployer key and cannot + // easily be made self-governed in the memory test environment. + // Nil them out so ValidateContractOwnership skips the self-governance checks. + for _, sel := range selectors { + cs := state.MustGetEVMChainState(sel) + cs.ProposerMcm = nil + cs.CancellerMcm = nil + cs.BypasserMcm = nil + state.WriteEVMChainState(sel, cs) + } +} + +// TestValidatePostDeploymentState_HappyPath uses a full memory environment +// to verify that ValidatePostDeploymentState passes on a correctly-wired deployment. +// Contract ownership is transferred to the MCMS Timelock before validation. +func TestValidatePostDeploymentState_HappyPath(t *testing.T) { + t.Parallel() + tenv, _ := testhelpers.NewMemoryEnvironment(t, testhelpers.WithNumOfChains(3)) + state, err := stateview.LoadOnchainState(tenv.Env, stateview.WithLoadLegacyContracts(true)) + require.NoError(t, err) + + evmChains := tenv.Env.BlockChains.ListChainSelectors(cldf_chain.WithFamily(chain_selectors.FamilyEVM)) + transferOwnershipToTimelock(t, tenv, state, evmChains) + + err = state.ValidatePostDeploymentState(tenv.Env, true) + require.NoError(t, err, "expected no errors on a correctly-deployed environment") +} + +// TestValidatePostDeploymentState_CollectsMultipleErrors verifies that the +// validation collects all errors rather than returning early on the first one. +func TestValidatePostDeploymentState_CollectsMultipleErrors(t *testing.T) { + t.Parallel() + tenv, _ := testhelpers.NewMemoryEnvironment(t, testhelpers.WithNumOfChains(3)) + state, err := stateview.LoadOnchainState(tenv.Env, stateview.WithLoadLegacyContracts(true)) + require.NoError(t, err) + + evmChains := tenv.Env.BlockChains.ListChainSelectors(cldf_chain.WithFamily(chain_selectors.FamilyEVM)) + transferOwnershipToTimelock(t, tenv, state, evmChains) + require.GreaterOrEqual(t, len(evmChains), 2, "need at least 2 chains for this test") + + // Intentionally break multiple chains' state to force multiple errors: + // Nil out the RMNProxy on one chain and the FeeQuoter on another. + chainState0 := state.MustGetEVMChainState(evmChains[0]) + chainState0.RMNProxy = nil + state.WriteEVMChainState(evmChains[0], chainState0) + + chainState1 := state.MustGetEVMChainState(evmChains[1]) + chainState1.FeeQuoter = nil + state.WriteEVMChainState(evmChains[1], chainState1) + + err = state.ValidatePostDeploymentState(tenv.Env, false) + require.Error(t, err, "expected validation errors") + + // The error should contain mentions of both chains' issues. + errMsg := err.Error() + assert.True(t, strings.Contains(errMsg, "RMNProxy") || strings.Contains(errMsg, "rmnProxy"), + "expected error to mention RMNProxy issue, got: %s", errMsg) + assert.True(t, strings.Contains(errMsg, "fee quoter") || strings.Contains(errMsg, "FeeQuoter"), + "expected error to mention FeeQuoter issue, got: %s", errMsg) +} + +// TestValidateContractOwnership_DetectsWrongOwner verifies that ownership +// validation detects contracts owned by the deployer rather than the timelock. +// In the memory environment, MCMS is deployed but ownership is NOT transferred. +func TestValidateContractOwnership_DetectsWrongOwner(t *testing.T) { + t.Parallel() + tenv, _ := testhelpers.NewMemoryEnvironment(t, testhelpers.WithNumOfChains(2)) + state, err := stateview.LoadOnchainState(tenv.Env) + require.NoError(t, err) + + evmChains := tenv.Env.BlockChains.ListChainSelectors(cldf_chain.WithFamily(chain_selectors.FamilyEVM)) + chainState := state.MustGetEVMChainState(evmChains[0]) + require.NotNil(t, chainState.Timelock, "test expects Timelock to be deployed") + + err = chainState.ValidateContractOwnership(tenv.Env) + require.Error(t, err, "expected ownership errors since contracts are not owned by timelock") + assert.Contains(t, err.Error(), "not owned by expected owner") +} + +// TestValidateContractOwnership_NoTimelock returns early with an error +// when timelock is nil. +func TestValidateContractOwnership_NoTimelock(t *testing.T) { + t.Parallel() + tenv, _ := testhelpers.NewMemoryEnvironment(t, testhelpers.WithNumOfChains(2)) + state, err := stateview.LoadOnchainState(tenv.Env) + require.NoError(t, err) + + evmChains := tenv.Env.BlockChains.ListChainSelectors(cldf_chain.WithFamily(chain_selectors.FamilyEVM)) + chainState := state.MustGetEVMChainState(evmChains[0]) + chainState.Timelock = nil + err = chainState.ValidateContractOwnership(tenv.Env) + require.Error(t, err) + assert.Contains(t, err.Error(), "timelock not found") +} + +// TestValidateRMNProxy_HappyPath validates that the RMNProxy correctly +// points to RMNRemote on a fresh deployment. +func TestValidateRMNProxy_HappyPath(t *testing.T) { + t.Parallel() + tenv, _ := testhelpers.NewMemoryEnvironment(t, testhelpers.WithNumOfChains(2)) + state, err := stateview.LoadOnchainState(tenv.Env) + require.NoError(t, err) + + for _, sel := range tenv.Env.BlockChains.ListChainSelectors(cldf_chain.WithFamily(chain_selectors.FamilyEVM)) { + chainState := state.MustGetEVMChainState(sel) + err := chainState.ValidateRMNProxy(tenv.Env) + require.NoError(t, err, "RMNProxy validation failed for chain %d", sel) + } +} + +// TestValidateRMNProxy_MissingContracts returns errors when RMNProxy or RMNRemote is nil. +func TestValidateRMNProxy_MissingContracts(t *testing.T) { + t.Parallel() + tenv, _ := testhelpers.NewMemoryEnvironment(t, testhelpers.WithNumOfChains(2)) + state, err := stateview.LoadOnchainState(tenv.Env) + require.NoError(t, err) + + evmChains := tenv.Env.BlockChains.ListChainSelectors(cldf_chain.WithFamily(chain_selectors.FamilyEVM)) + + t.Run("nil RMNProxy", func(t *testing.T) { + chainState := state.MustGetEVMChainState(evmChains[0]) + chainState.RMNProxy = nil + err := chainState.ValidateRMNProxy(tenv.Env) + require.Error(t, err) + assert.Contains(t, err.Error(), "no RMNProxy") + }) + + t.Run("nil RMNRemote", func(t *testing.T) { + chainState := state.MustGetEVMChainState(evmChains[0]) + chainState.RMNRemote = nil + err := chainState.ValidateRMNProxy(tenv.Env) + require.Error(t, err) + assert.Contains(t, err.Error(), "no RMNRemote") + }) +} + +// TestValidateNonceManager_HappyPath validates the NonceManager on a full deployment. +func TestValidateNonceManager_HappyPath(t *testing.T) { + t.Parallel() + tenv, _ := testhelpers.NewMemoryEnvironment(t, testhelpers.WithNumOfChains(3)) + state, err := stateview.LoadOnchainState(tenv.Env, stateview.WithLoadLegacyContracts(true)) + require.NoError(t, err) + + evmChains := tenv.Env.BlockChains.ListChainSelectors(cldf_chain.WithFamily(chain_selectors.FamilyEVM)) + for _, sel := range evmChains { + chainState := state.MustGetEVMChainState(sel) + // Build connected chains from router + connectedChains, err := chainState.ValidateRouter(tenv.Env, false) + require.NoError(t, err, "router validation failed for chain %d", sel) + + err = chainState.ValidateNonceManager(tenv.Env, sel, connectedChains) + require.NoError(t, err, "NonceManager validation failed for chain %d", sel) + } +} + +// TestValidateNonceManager_NilNonceManager returns error when NonceManager is nil. +func TestValidateNonceManager_NilNonceManager(t *testing.T) { + t.Parallel() + tenv, _ := testhelpers.NewMemoryEnvironment(t, testhelpers.WithNumOfChains(2)) + state, err := stateview.LoadOnchainState(tenv.Env) + require.NoError(t, err) + + evmChains := tenv.Env.BlockChains.ListChainSelectors(cldf_chain.WithFamily(chain_selectors.FamilyEVM)) + chainState := state.MustGetEVMChainState(evmChains[0]) + chainState.NonceManager = nil + err = chainState.ValidateNonceManager(tenv.Env, evmChains[0], evmChains[1:]) + require.Error(t, err) + assert.Contains(t, err.Error(), "no NonceManager") +} + +// TestValidateFeeQuoter_HappyPath validates FeeQuoter chain-level and lane-level +// configurations pass on a correctly-deployed environment. +func TestValidateFeeQuoter_HappyPath(t *testing.T) { + t.Parallel() + tenv, _ := testhelpers.NewMemoryEnvironment(t, testhelpers.WithNumOfChains(3)) + state, err := stateview.LoadOnchainState(tenv.Env, stateview.WithLoadLegacyContracts(true)) + require.NoError(t, err) + + evmChains := tenv.Env.BlockChains.ListChainSelectors(cldf_chain.WithFamily(chain_selectors.FamilyEVM)) + for _, sel := range evmChains { + chainState := state.MustGetEVMChainState(sel) + connectedChains, err := chainState.ValidateRouter(tenv.Env, false) + require.NoError(t, err, "router validation failed for chain %d", sel) + + err = chainState.ValidateFeeQuoter(tenv.Env, sel, connectedChains) + require.NoError(t, err, "FeeQuoter validation failed for chain %d", sel) + } +} + +// TestValidateFeeQuoter_NilFeeQuoter returns error when FeeQuoter is nil. +func TestValidateFeeQuoter_NilFeeQuoter(t *testing.T) { + t.Parallel() + tenv, _ := testhelpers.NewMemoryEnvironment(t, testhelpers.WithNumOfChains(2)) + state, err := stateview.LoadOnchainState(tenv.Env) + require.NoError(t, err) + + evmChains := tenv.Env.BlockChains.ListChainSelectors(cldf_chain.WithFamily(chain_selectors.FamilyEVM)) + chainState := state.MustGetEVMChainState(evmChains[0]) + chainState.FeeQuoter = nil + err = chainState.ValidateFeeQuoter(tenv.Env, evmChains[0], evmChains[1:]) + require.Error(t, err) + assert.Contains(t, err.Error(), "no FeeQuoter") +} + +// buildHomeChainTestArgs builds the nodes and offRampsByChain arguments needed to call ValidateHomeChain. +func buildHomeChainTestArgs( + t *testing.T, + tenv testhelpers.DeployedEnv, + state stateview.CCIPOnChainState, +) (deployment.Nodes, map[uint64]offramp.OffRampInterface) { + t.Helper() + nodes, err := deployment.NodeInfo(tenv.Env.NodeIDs, tenv.Env.Offchain) + require.NoError(t, err) + offRamps := make(map[uint64]offramp.OffRampInterface) + for _, sel := range state.EVMChains() { + cs := state.MustGetEVMChainState(sel) + offRamps[sel] = cs.OffRamp + } + return nodes, offRamps +} + +// TestValidateHomeChain_HappyPath validates home chain + per-chain DON config on a full deployment. +func TestValidateHomeChain_HappyPath(t *testing.T) { + t.Parallel() + tenv, _ := testhelpers.NewMemoryEnvironment(t, testhelpers.WithNumOfChains(3)) + state, err := stateview.LoadOnchainState(tenv.Env) + require.NoError(t, err) + + homeChainState := state.MustGetEVMChainState(tenv.HomeChainSel) + nodes, offRamps := buildHomeChainTestArgs(t, tenv, state) + err = homeChainState.ValidateHomeChain(tenv.Env, nodes, offRamps) + require.NoError(t, err, "home chain validation failed") +} + +// TestValidateHomeChain_MissingContracts returns errors when CCIPHome or CapReg is nil. +func TestValidateHomeChain_MissingContracts(t *testing.T) { + t.Parallel() + tenv, _ := testhelpers.NewMemoryEnvironment(t, testhelpers.WithNumOfChains(2)) + state, err := stateview.LoadOnchainState(tenv.Env) + require.NoError(t, err) + + nodes, offRamps := buildHomeChainTestArgs(t, tenv, state) + + t.Run("nil CCIPHome", func(t *testing.T) { + homeChainState := state.MustGetEVMChainState(tenv.HomeChainSel) + homeChainState.CCIPHome = nil + err := homeChainState.ValidateHomeChain(tenv.Env, nodes, offRamps) + require.Error(t, err) + assert.Contains(t, err.Error(), "no CCIPHome") + }) + + t.Run("nil CapabilityRegistry", func(t *testing.T) { + homeChainState := state.MustGetEVMChainState(tenv.HomeChainSel) + homeChainState.CapabilityRegistry = nil + err := homeChainState.ValidateHomeChain(tenv.Env, nodes, offRamps) + require.Error(t, err) + assert.Contains(t, err.Error(), "no CapabilityRegistry") + }) +} diff --git a/deployment/ccip/shared/stateview/state.go b/deployment/ccip/shared/stateview/state.go index 2d8ea5897bb..f09b9f9130d 100644 --- a/deployment/ccip/shared/stateview/state.go +++ b/deployment/ccip/shared/stateview/state.go @@ -166,6 +166,17 @@ func (c CCIPOnChainState) WriteEVMChainState(selector uint64, chainState evm.CCI // in environment is complete. // It validates the state of the contracts and ensures that they are correctly configured and wired with each other. func (c CCIPOnChainState) ValidatePostDeploymentState(e cldf.Environment, validateHomeChain bool) error { + return c.validatePostDeploymentState(e, validateHomeChain, true) +} + +// ValidatePostDeploymentStateWithoutOwnership performs the same validation as ValidatePostDeploymentState +// but skips contract ownership checks. This is intended for test infrastructure that validates +// deployment wiring before ownership has been transferred to the MCMS Timelock. +func (c CCIPOnChainState) ValidatePostDeploymentStateWithoutMCMSOwnership(e cldf.Environment, validateHomeChain bool) error { + return c.validatePostDeploymentState(e, validateHomeChain, false) +} + +func (c CCIPOnChainState) validatePostDeploymentState(e cldf.Environment, validateHomeChain bool, validateOwnership bool) error { onRampsBySelector := make(map[uint64]common.Address) offRampsBySelector := make(map[uint64]offramp.OffRampInterface) @@ -186,9 +197,10 @@ func (c CCIPOnChainState) ValidatePostDeploymentState(e cldf.Environment, valida return fmt.Errorf("failed to get home chain selector: %w", err) } homeChainState := c.MustGetEVMChainState(homeChain) + var allErrs []error if validateHomeChain { if err := homeChainState.ValidateHomeChain(e, nodes, offRampsBySelector); err != nil { - return fmt.Errorf("failed to validate home chain %d: %w", homeChain, err) + allErrs = append(allErrs, fmt.Errorf("failed to validate home chain %d: %w", homeChain, err)) } } rmnHomeActiveDigest, err := homeChainState.RMNHome.GetActiveDigest(&bind.CallOpts{ @@ -212,39 +224,53 @@ func (c CCIPOnChainState) ValidatePostDeploymentState(e cldf.Environment, valida chainState := c.MustGetEVMChainState(selector) isRMNEnabledInRmnRemote, err := chainState.ValidateRMNRemote(e, selector, rmnHomeActiveDigest) if err != nil { - return fmt.Errorf("failed to validate RMNRemote %s for chain %d: %w", chainState.RMNRemote.Address().Hex(), selector, err) - } - // check whether RMNRemote and RMNHome are in sync in terms of RMNEnabled - if isRMNEnabledInRmnRemote != isRMNEnabledInRMNHomeBySourceChain[selector] { - return fmt.Errorf("RMNRemote %s rmnEnabled mismatch with RMNHome for chain %d: expected %v, got %v", - chainState.RMNRemote.Address().Hex(), selector, isRMNEnabledInRMNHomeBySourceChain[selector], isRMNEnabledInRmnRemote) + allErrs = append(allErrs, fmt.Errorf("failed to validate RMNRemote %s for chain %d: %w", chainState.RMNRemote.Address().Hex(), selector, err)) + } else if isRMNEnabledInRmnRemote != isRMNEnabledInRMNHomeBySourceChain[selector] { + // check whether RMNRemote and RMNHome are in sync in terms of RMNEnabled + allErrs = append(allErrs, fmt.Errorf("RMNRemote %s rmnEnabled mismatch with RMNHome for chain %d: expected %v, got %v", + chainState.RMNRemote.Address().Hex(), selector, isRMNEnabledInRMNHomeBySourceChain[selector], isRMNEnabledInRmnRemote)) } otherOnRamps := make(map[uint64]common.Address) - isTestRouter := true + useTestRouter := true if chainState.Router != nil { - isTestRouter = false + useTestRouter = false } - connectedChains, err := chainState.ValidateRouter(e, isTestRouter) + connectedChains, err := chainState.ValidateRouter(e, useTestRouter) if err != nil { - return fmt.Errorf("failed to validate router %s for chain %d: %w", chainState.Router.Address().Hex(), selector, err) + allErrs = append(allErrs, fmt.Errorf("failed to validate router for chain %d: %w", selector, err)) } - for _, connectedChain := range connectedChains { - if connectedChain == selector { - continue + if len(connectedChains) > 0 { + for _, connectedChain := range connectedChains { + if connectedChain == selector { + continue + } + otherOnRamps[connectedChain] = c.MustGetEVMChainState(connectedChain).OnRamp.Address() + } + if err := chainState.ValidateOffRamp(e, selector, otherOnRamps, isRMNEnabledInRMNHomeBySourceChain); err != nil { + allErrs = append(allErrs, fmt.Errorf("failed to validate offramp %s for chain %d: %w", chainState.OffRamp.Address().Hex(), selector, err)) + } + if err := chainState.ValidateOnRamp(e, selector, connectedChains); err != nil { + allErrs = append(allErrs, fmt.Errorf("failed to validate onramp %s for chain %d: %w", chainState.OnRamp.Address().Hex(), selector, err)) + } + if err := chainState.ValidateNonceManager(e, selector, connectedChains); err != nil { + allErrs = append(allErrs, fmt.Errorf("failed to validate nonce manager for chain %d: %w", selector, err)) } - otherOnRamps[connectedChain] = c.MustGetEVMChainState(connectedChain).OnRamp.Address() } - if err := chainState.ValidateOffRamp(e, selector, otherOnRamps, isRMNEnabledInRMNHomeBySourceChain); err != nil { - return fmt.Errorf("failed to validate offramp %s for chain %d: %w", chainState.OffRamp.Address().Hex(), selector, err) + if err := chainState.ValidateFeeQuoter(e, selector, connectedChains); err != nil { + allErrs = append(allErrs, fmt.Errorf("failed to validate fee quoter %s for chain %d: %w", chainState.FeeQuoter.Address().Hex(), selector, err)) } - if err := chainState.ValidateOnRamp(e, selector, connectedChains); err != nil { - return fmt.Errorf("failed to validate onramp %s for chain %d: %w", chainState.OnRamp.Address().Hex(), selector, err) + // Validate contract ownership: all contracts should be owned by the MCMS Timelock + if validateOwnership && chainState.Timelock != nil { + if err := chainState.ValidateContractOwnership(e); err != nil { + allErrs = append(allErrs, fmt.Errorf("failed to validate contract ownership for chain %d: %w", selector, err)) + } } - if err := chainState.ValidateFeeQuoter(e); err != nil { - return fmt.Errorf("failed to validate fee quoter %s for chain %d: %w", chainState.FeeQuoter.Address().Hex(), selector, err) + // Validate RMNProxy points to RMNRemote + if err := chainState.ValidateRMNProxy(e); err != nil { + allErrs = append(allErrs, fmt.Errorf("failed to validate RMNProxy for chain %d: %w", selector, err)) } } - return nil + return errors.Join(allErrs...) } // HomeChainSelector returns the selector of the home chain based on the presence of RMNHome, CapabilityRegistry and CCIPHome contracts. From efbb0781dab02abb4bb510e6f4eadc3d0aae2a35 Mon Sep 17 00:00:00 2001 From: simsonraj Date: Mon, 23 Mar 2026 13:10:35 +0530 Subject: [PATCH 02/13] Lint fixes --- deployment/ccip/shared/stateview/state.go | 30 +++++++++++++++-------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/deployment/ccip/shared/stateview/state.go b/deployment/ccip/shared/stateview/state.go index f09b9f9130d..b2ff06e84b4 100644 --- a/deployment/ccip/shared/stateview/state.go +++ b/deployment/ccip/shared/stateview/state.go @@ -166,17 +166,17 @@ func (c CCIPOnChainState) WriteEVMChainState(selector uint64, chainState evm.CCI // in environment is complete. // It validates the state of the contracts and ensures that they are correctly configured and wired with each other. func (c CCIPOnChainState) ValidatePostDeploymentState(e cldf.Environment, validateHomeChain bool) error { - return c.validatePostDeploymentState(e, validateHomeChain, true) + return c.runPostDeploymentValidation(e, validateHomeChain, true) } -// ValidatePostDeploymentStateWithoutOwnership performs the same validation as ValidatePostDeploymentState +// ValidatePostDeploymentStateWithoutMCMSOwnership performs the same validation as ValidatePostDeploymentState // but skips contract ownership checks. This is intended for test infrastructure that validates // deployment wiring before ownership has been transferred to the MCMS Timelock. func (c CCIPOnChainState) ValidatePostDeploymentStateWithoutMCMSOwnership(e cldf.Environment, validateHomeChain bool) error { - return c.validatePostDeploymentState(e, validateHomeChain, false) + return c.runPostDeploymentValidation(e, validateHomeChain, false) } -func (c CCIPOnChainState) validatePostDeploymentState(e cldf.Environment, validateHomeChain bool, validateOwnership bool) error { +func (c CCIPOnChainState) runPostDeploymentValidation(e cldf.Environment, validateHomeChain bool, validateOwnership bool) error { onRampsBySelector := make(map[uint64]common.Address) offRampsBySelector := make(map[uint64]offramp.OffRampInterface) @@ -224,7 +224,7 @@ func (c CCIPOnChainState) validatePostDeploymentState(e cldf.Environment, valida chainState := c.MustGetEVMChainState(selector) isRMNEnabledInRmnRemote, err := chainState.ValidateRMNRemote(e, selector, rmnHomeActiveDigest) if err != nil { - allErrs = append(allErrs, fmt.Errorf("failed to validate RMNRemote %s for chain %d: %w", chainState.RMNRemote.Address().Hex(), selector, err)) + allErrs = append(allErrs, fmt.Errorf("failed to validate RMNRemote %s for chain %d: %w", safeAddr(chainState.RMNRemote), selector, err)) } else if isRMNEnabledInRmnRemote != isRMNEnabledInRMNHomeBySourceChain[selector] { // check whether RMNRemote and RMNHome are in sync in terms of RMNEnabled allErrs = append(allErrs, fmt.Errorf("RMNRemote %s rmnEnabled mismatch with RMNHome for chain %d: expected %v, got %v", @@ -247,21 +247,23 @@ func (c CCIPOnChainState) validatePostDeploymentState(e cldf.Environment, valida otherOnRamps[connectedChain] = c.MustGetEVMChainState(connectedChain).OnRamp.Address() } if err := chainState.ValidateOffRamp(e, selector, otherOnRamps, isRMNEnabledInRMNHomeBySourceChain); err != nil { - allErrs = append(allErrs, fmt.Errorf("failed to validate offramp %s for chain %d: %w", chainState.OffRamp.Address().Hex(), selector, err)) + allErrs = append(allErrs, fmt.Errorf("failed to validate offramp %s for chain %d: %w", safeAddr(chainState.OffRamp), selector, err)) } if err := chainState.ValidateOnRamp(e, selector, connectedChains); err != nil { - allErrs = append(allErrs, fmt.Errorf("failed to validate onramp %s for chain %d: %w", chainState.OnRamp.Address().Hex(), selector, err)) + allErrs = append(allErrs, fmt.Errorf("failed to validate onramp %s for chain %d: %w", safeAddr(chainState.OnRamp), selector, err)) } if err := chainState.ValidateNonceManager(e, selector, connectedChains); err != nil { allErrs = append(allErrs, fmt.Errorf("failed to validate nonce manager for chain %d: %w", selector, err)) } } if err := chainState.ValidateFeeQuoter(e, selector, connectedChains); err != nil { - allErrs = append(allErrs, fmt.Errorf("failed to validate fee quoter %s for chain %d: %w", chainState.FeeQuoter.Address().Hex(), selector, err)) + allErrs = append(allErrs, fmt.Errorf("failed to validate fee quoter %s for chain %d: %w", safeAddr(chainState.FeeQuoter), selector, err)) } // Validate contract ownership: all contracts should be owned by the MCMS Timelock - if validateOwnership && chainState.Timelock != nil { - if err := chainState.ValidateContractOwnership(e); err != nil { + if validateOwnership { + if chainState.Timelock == nil { + allErrs = append(allErrs, fmt.Errorf("failed to validate contract ownership for chain %d: timelock not configured", selector)) + } else if err := chainState.ValidateContractOwnership(e); err != nil { allErrs = append(allErrs, fmt.Errorf("failed to validate contract ownership for chain %d: %w", selector, err)) } } @@ -273,6 +275,14 @@ func (c CCIPOnChainState) validatePostDeploymentState(e cldf.Environment, valida return errors.Join(allErrs...) } +// safeAddr returns the hex address of a contract if non-nil, or "" otherwise. +func safeAddr(c interface{ Address() common.Address }) string { + if c == nil { + return "" + } + return c.Address().Hex() +} + // HomeChainSelector returns the selector of the home chain based on the presence of RMNHome, CapabilityRegistry and CCIPHome contracts. func (c CCIPOnChainState) HomeChainSelector() (uint64, error) { for _, selector := range c.EVMChains() { From 163e97163bee2332f3cf8002bd7b0dfde001dba8 Mon Sep 17 00:00:00 2001 From: simsonraj Date: Mon, 23 Mar 2026 20:19:48 +0530 Subject: [PATCH 03/13] Updates perf fix & v2 FQ --- .../changeset/testhelpers/test_environment.go | 4 +- .../v1_6/cs_update_bidirectional_lanes.go | 30 +- deployment/ccip/shared/helpers.go | 37 ++ deployment/ccip/shared/stateview/evm/state.go | 110 +++- .../ccip/shared/stateview/evm/validate.go | 565 ++++++++++++++++-- .../shared/stateview/evm/validate_test.go | 72 +-- deployment/ccip/shared/stateview/state.go | 221 ++++--- 7 files changed, 807 insertions(+), 232 deletions(-) diff --git a/deployment/ccip/changeset/testhelpers/test_environment.go b/deployment/ccip/changeset/testhelpers/test_environment.go index fa290457369..21ab8fb7bc0 100644 --- a/deployment/ccip/changeset/testhelpers/test_environment.go +++ b/deployment/ccip/changeset/testhelpers/test_environment.go @@ -848,8 +848,8 @@ func NewEnvironmentWithJobsAndContracts(t *testing.T, tEnv TestEnvironment) Depl state, err := stateview.LoadOnchainState(e.Env, stateview.WithLoadLegacyContracts(true)) require.NoError(t, err) - err = state.ValidatePostDeploymentStateWithoutMCMSOwnership(e.Env, !tEnv.TestConfigs().SkipDONConfiguration) - require.NoError(t, err) + chainErrs := state.ValidatePostDeploymentStateWithoutMCMSOwnership(e.Env, !tEnv.TestConfigs().SkipDONConfiguration) + require.Empty(t, chainErrs) return e } diff --git a/deployment/ccip/changeset/v1_6/cs_update_bidirectional_lanes.go b/deployment/ccip/changeset/v1_6/cs_update_bidirectional_lanes.go index aec4fcf3f46..bbc7b6c6e4f 100644 --- a/deployment/ccip/changeset/v1_6/cs_update_bidirectional_lanes.go +++ b/deployment/ccip/changeset/v1_6/cs_update_bidirectional_lanes.go @@ -446,33 +446,5 @@ func resolveUpdateLanesFeeQuoterAddressAndVersion( addresses []datastore.AddressRef, chainSel uint64, ) (common.Address, semver.Version, error) { - // Find the FeeQuoter with the highest version for this chain - var bestRef datastore.AddressRef - var bestVersion *semver.Version - - for _, ref := range addresses { - if ref.ChainSelector != chainSel { - continue - } - if ref.Type != datastore.ContractType(fqv2ops.ContractType) { - continue - } - if ref.Version == nil { - continue - } - if bestVersion == nil || ref.Version.GreaterThan(bestVersion) { - bestVersion = ref.Version - bestRef = ref - } - } - - if bestVersion == nil { - return common.Address{}, semver.Version{}, fmt.Errorf("no fee quoter address found for chain %d", chainSel) - } - - if !common.IsHexAddress(bestRef.Address) { - return common.Address{}, semver.Version{}, fmt.Errorf("invalid fee quoter address %q for chain %d", bestRef.Address, chainSel) - } - - return common.HexToAddress(bestRef.Address), *bestVersion, nil + return shared.ResolveFeeQuoterAddressAndVersion(addresses, chainSel) } diff --git a/deployment/ccip/shared/helpers.go b/deployment/ccip/shared/helpers.go index 630c2443584..21f42d20f6f 100644 --- a/deployment/ccip/shared/helpers.go +++ b/deployment/ccip/shared/helpers.go @@ -4,9 +4,11 @@ import ( "context" "fmt" + "github.com/Masterminds/semver/v3" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + fqv2ops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/fee_quoter" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" "github.com/smartcontractkit/chainlink-deployments-framework/deployment" capabilities_registry "github.com/smartcontractkit/chainlink-evm/gethwrappers/keystone/generated/capabilities_registry_1_1_0" @@ -115,3 +117,38 @@ func PopulateDataStore(addressBook deployment.AddressBook) (*datastore.MemoryDat return ds, nil } + +// ResolveFeeQuoterAddressAndVersion returns the FeeQuoter with the highest semver for a chain. +func ResolveFeeQuoterAddressAndVersion( + addresses []datastore.AddressRef, + chainSel uint64, +) (common.Address, semver.Version, error) { + var bestRef datastore.AddressRef + var bestVersion *semver.Version + + for _, ref := range addresses { + if ref.ChainSelector != chainSel { + continue + } + if ref.Type != datastore.ContractType(fqv2ops.ContractType) { + continue + } + if ref.Version == nil { + continue + } + if bestVersion == nil || ref.Version.GreaterThan(bestVersion) { + bestVersion = ref.Version + bestRef = ref + } + } + + if bestVersion == nil { + return common.Address{}, semver.Version{}, fmt.Errorf("no fee quoter address found for chain %d", chainSel) + } + + if !common.IsHexAddress(bestRef.Address) { + return common.Address{}, semver.Version{}, fmt.Errorf("invalid fee quoter address %q for chain %d", bestRef.Address, chainSel) + } + + return common.HexToAddress(bestRef.Address), *bestVersion, nil +} diff --git a/deployment/ccip/shared/stateview/evm/state.go b/deployment/ccip/shared/stateview/evm/state.go index 2ac9847d9f1..2857d7e6dc2 100644 --- a/deployment/ccip/shared/stateview/evm/state.go +++ b/deployment/ccip/shared/stateview/evm/state.go @@ -232,7 +232,6 @@ func (c CCIPChainState) ValidateHomeChain(e cldf.Environment, nodes deployment.N continue } - // Resolve chain selector: prefer active, fall back to candidate if not yet promoted. chainSel := commitConfigs.ActiveConfig.Config.ChainSelector if chainSel == 0 { chainSel = commitConfigs.CandidateConfig.Config.ChainSelector @@ -248,11 +247,13 @@ func (c CCIPChainState) ValidateHomeChain(e cldf.Environment, nodes deployment.N } } - // 3: Per-chain validation - for chainSel := range offRampsByChain { - donID, ok := donIDByChainSel[chainSel] - if !ok || donID == 0 { - allErrs = append(allErrs, fmt.Errorf("chain %d: no DON ID found in CCIPHome", chainSel)) + // 3: Per-chain validation — only validate chains that have an active DON in CCIPHome. + // offRampsByChain may contain chains from the state that are not actively managed by v1.6 DONs. + for chainSel, donID := range donIDByChainSel { + if donID == 0 { + continue + } + if _, ok := offRampsByChain[chainSel]; !ok { continue } @@ -411,10 +412,12 @@ func (c CCIPChainState) ValidateOnRamp( e cldf.Environment, selector uint64, connectedChains []uint64, + fqV2Addr common.Address, ) error { if c.OnRamp == nil { return errors.New("no OnRamp contract found in the state") } + e.Logger.Debugw("Validating OnRamp", "chain", selector, "onRamp", c.OnRamp.Address().Hex(), "connectedChains", len(connectedChains)) staticCfg, err := c.OnRamp.GetStaticConfig(&bind.CallOpts{ Context: e.GetContext(), }) @@ -444,9 +447,13 @@ func (c CCIPChainState) ValidateOnRamp( if err != nil { return fmt.Errorf("failed to get dynamic config for chain %d onRamp %s: %w", selector, c.OnRamp.Address().Hex(), err) } - if dynamicCfg.FeeQuoter != c.FeeQuoter.Address() { + if dynamicCfg.FeeQuoter != c.FeeQuoter.Address() && (fqV2Addr == common.Address{} || dynamicCfg.FeeQuoter != fqV2Addr) { + expected := c.FeeQuoter.Address().Hex() + if fqV2Addr != (common.Address{}) { + expected += " or " + fqV2Addr.Hex() + } return fmt.Errorf("onRamp %s feeQuoter mismatch in dynamic config: expected %s, got %s", - c.OnRamp.Address().Hex(), c.FeeQuoter.Address().Hex(), dynamicCfg.FeeQuoter.Hex()) + c.OnRamp.Address().Hex(), expected, dynamicCfg.FeeQuoter.Hex()) } // if the fee aggregator is set, it should match the one in the dynamic config // otherwise the fee aggregator should be the timelock address @@ -470,7 +477,6 @@ func (c CCIPChainState) ValidateOnRamp( return fmt.Errorf("failed to get dest chain config from source chain %d onRamp %s for dest chain %d: %w", selector, c.OnRamp.Address(), otherChainSel, err) } - // dest chain config must be configured (non-blank means a router is set) if destChainCfg == (onramp.GetDestChainConfig{}) { return fmt.Errorf("onRamp %s dest chain config is blank for dest chain %d", c.OnRamp.Address().Hex(), otherChainSel) @@ -484,12 +490,56 @@ func (c CCIPChainState) ValidateOnRamp( return nil } -// ValidateRouter validates the router contract to check if all wired contracts are synced with state -// and returns all connected chains with respect to the router -func (c CCIPChainState) ValidateRouter(e cldf.Environment, isTestRouter bool) ([]uint64, error) { +// V16ActiveChainSelectors returns chain selectors with an active or candidate +// v1.6 DON config in CCIPHome. Home chain only. +func (c CCIPChainState) V16ActiveChainSelectors(ctx context.Context) (map[uint64]bool, error) { + if c.CCIPHome == nil { + return nil, errors.New("no CCIPHome contract found in the state") + } + if c.CapabilityRegistry == nil { + return nil, errors.New("no CapabilityRegistry contract found in the state") + } + ccipDons, err := shared.GetCCIPDonsFromCapRegistry(ctx, c.CapabilityRegistry) + if err != nil { + return nil, fmt.Errorf("failed to get CCIP DONs from capability registry: %w", err) + } + callOpts := &bind.CallOpts{Context: ctx} + active := make(map[uint64]bool, len(ccipDons)) + for _, don := range ccipDons { + commitConfigs, err := c.CCIPHome.GetAllConfigs(callOpts, don.Id, uint8(types.PluginTypeCCIPCommit)) + if err != nil { + continue + } + execConfigs, err := c.CCIPHome.GetAllConfigs(callOpts, don.Id, uint8(types.PluginTypeCCIPExec)) + if err != nil { + continue + } + chainSel := commitConfigs.ActiveConfig.Config.ChainSelector + if chainSel == 0 { + chainSel = commitConfigs.CandidateConfig.Config.ChainSelector + } + if chainSel == 0 { + chainSel = execConfigs.ActiveConfig.Config.ChainSelector + if chainSel == 0 { + chainSel = execConfigs.CandidateConfig.Config.ChainSelector + } + } + if chainSel != 0 { + active[chainSel] = true + } + } + return active, nil +} + +// ValidateRouter validates the router contract and returns all connected v1.6 chains. +// v16ActiveChains filters out legacy v1.5 lane entries in mixed environments. +func (c CCIPChainState) ValidateRouter(e cldf.Environment, isTestRouter bool, v16ActiveChains map[uint64]bool) ([]uint64, error) { if c.Router == nil && c.TestRouter == nil { return nil, errors.New("no Router or TestRouter contract found in the state") } + if c.RMNProxy == nil { + return nil, errors.New("no RMNProxy contract found in the state: cannot validate router") + } routerC := c.Router if isTestRouter { routerC = c.TestRouter @@ -523,19 +573,18 @@ func (c CCIPChainState) ValidateRouter(e cldf.Environment, isTestRouter bool) ([ return nil, fmt.Errorf("failed to get offRamps from router %s: %w", routerC.Address().Hex(), err) } for _, d := range offRampDetails { - // skip if solana - solana state is maintained in solana if _, exists := e.BlockChains.SolanaChains()[d.SourceChainSelector]; exists { continue } - allConnectedChains = append(allConnectedChains, d.SourceChainSelector) - // check if offRamp is valid + if len(v16ActiveChains) > 0 && !v16ActiveChains[d.SourceChainSelector] { + continue + } if d.OffRamp != c.OffRamp.Address() { - return nil, fmt.Errorf("offRamp %s mismatch for source %d in router %s: expected %s, got %s", - d.OffRamp.Hex(), d.SourceChainSelector, routerC.Address().Hex(), c.OffRamp.Address().Hex(), d.OffRamp) + continue } + allConnectedChains = append(allConnectedChains, d.SourceChainSelector) } - // all lanes are bi-directional, if we have a lane from A to B, we also have a lane from B to A - // source to offRamp should be same as dest to onRamp + v16ConnectedChains := make([]uint64, 0, len(allConnectedChains)) for _, dest := range allConnectedChains { onRamp, err := routerC.GetOnRamp(&bind.CallOpts{ Context: context.Background(), @@ -543,12 +592,11 @@ func (c CCIPChainState) ValidateRouter(e cldf.Environment, isTestRouter bool) ([ if err != nil { return nil, fmt.Errorf("failed to get onRamp for dest %d from router %s: %w", dest, routerC.Address().Hex(), err) } - if onRamp != c.OnRamp.Address() { - return nil, fmt.Errorf("onRamp %s mismatch for dest chain %d in router %s: expected %s, got %s", - onRamp.Hex(), dest, routerC.Address().Hex(), c.OnRamp.Address().Hex(), onRamp) + if onRamp == c.OnRamp.Address() { + v16ConnectedChains = append(v16ConnectedChains, dest) } } - return allConnectedChains, nil + return v16ConnectedChains, nil } // ValidateRMNRemote validates the RMNRemote contract to check if all wired contracts are synced with state @@ -594,10 +642,12 @@ func (c CCIPChainState) ValidateOffRamp( selector uint64, onRampsBySelector map[uint64]common.Address, isRMNEnabledBySource map[uint64]bool, + fqV2Addr common.Address, ) error { if c.OffRamp == nil { return errors.New("no OffRamp contract found in the state") } + e.Logger.Debugw("Validating OffRamp", "chain", selector, "offRamp", c.OffRamp.Address().Hex(), "sourceChains", len(onRampsBySelector)) // staticConfig chainSelector matches the selector key for the CCIPChainState staticConfig, err := c.OffRamp.GetStaticConfig(&bind.CallOpts{ Context: e.GetContext(), @@ -632,9 +682,13 @@ func (c CCIPChainState) ValidateOffRamp( return fmt.Errorf("failed to get dynamic config for chain %d offRamp %s: %w", selector, c.OffRamp.Address().Hex(), err) } // FeeQuoter address for chain should be the same as the one in the static config - if dynamicConfig.FeeQuoter != c.FeeQuoter.Address() { + if dynamicConfig.FeeQuoter != c.FeeQuoter.Address() && (fqV2Addr == common.Address{} || dynamicConfig.FeeQuoter != fqV2Addr) { + expected := c.FeeQuoter.Address().Hex() + if fqV2Addr != (common.Address{}) { + expected += " or " + fqV2Addr.Hex() + } return fmt.Errorf("offRamp %s feeQuoter mismatch: expected %s, got %s", - c.OffRamp.Address().Hex(), c.FeeQuoter.Address().Hex(), dynamicConfig.FeeQuoter.Hex()) + c.OffRamp.Address().Hex(), expected, dynamicConfig.FeeQuoter.Hex()) } if dynamicConfig.PermissionLessExecutionThresholdSeconds != uint32(globals.PermissionLessExecutionThreshold.Seconds()) { return fmt.Errorf("offRamp %s permissionless execution threshold mismatch: expected %f, got %d", @@ -647,24 +701,18 @@ func (c CCIPChainState) ValidateOffRamp( if err != nil { return fmt.Errorf("failed to get source chain config for chain %d: %w", chainSel, err) } - // Source chain must be enabled if !config.IsEnabled { return fmt.Errorf("source chain %d is not enabled on OffRamp %s", chainSel, c.OffRamp.Address().Hex()) } - // For all configured sources, the address of configured onRamp for chain A must be the Address() of the onramp on chain A if srcChainOnRamp != common.BytesToAddress(config.OnRamp) { return fmt.Errorf("onRamp address mismatch for source chain %d on OffRamp %s : expected %s, got %x", chainSel, c.OffRamp.Address().Hex(), srcChainOnRamp.Hex(), config.OnRamp) } - // The address of router should be accurate if c.Router.Address() != config.Router && c.TestRouter.Address() != config.Router { return fmt.Errorf("router address mismatch for source chain %d on OffRamp %s : expected either router %s or test router %s, got %s", chainSel, c.OffRamp.Address().Hex(), c.Router.Address().Hex(), c.TestRouter.Address().Hex(), config.Router.Hex()) } - // RMN verification disabled flag check: - // if RMN is enabled for the source chain, the RMNRemote and RMNHome should be configured to enable RMN - // the reverse is not always true, as RMN verification can be disabled at offRamp but enabled in RMNRemote and RMNHome if !config.IsRMNVerificationDisabled && !isRMNEnabledBySource[chainSel] { return fmt.Errorf("RMN verification is enabled in offRamp %s for source chain %d, "+ "but RMN is not enabled in RMNHome and RMNRemote for the chain", diff --git a/deployment/ccip/shared/stateview/evm/validate.go b/deployment/ccip/shared/stateview/evm/validate.go index ae99741cf88..36bad1759e7 100644 --- a/deployment/ccip/shared/stateview/evm/validate.go +++ b/deployment/ccip/shared/stateview/evm/validate.go @@ -3,12 +3,17 @@ package evm import ( "errors" "fmt" + "strings" + "sync" + "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" chain_selectors "github.com/smartcontractkit/chain-selectors" + fqv2ops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/fee_quoter" + fqv2seq "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/sequences" "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_5_0/evm_2_evm_onramp" "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_6_3/fee_quoter" cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" @@ -18,7 +23,7 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/capabilities/ccip/ccipevm" ) -// ValidateNonceManager checks that NonceManager previous ramps point to the correct v1.5 contracts +// ValidateNonceManager checks NonceManager previous ramps against v1.5 contracts. func (c CCIPChainState) ValidateNonceManager( e cldf.Environment, selector uint64, @@ -27,6 +32,7 @@ func (c CCIPChainState) ValidateNonceManager( if c.NonceManager == nil { return errors.New("no NonceManager contract found in the state") } + e.Logger.Debugw("Validating NonceManager", "chain", selector, "nonceManager", c.NonceManager.Address().Hex(), "connectedChains", len(connectedChains)) callOpts := &bind.CallOpts{Context: e.GetContext()} var errs []error @@ -60,7 +66,7 @@ func (c CCIPChainState) ValidateNonceManager( return errors.Join(errs...) } -// ValidateRMNProxy checks that RMNProxy.GetARM() returns the RMNRemote address +// ValidateRMNProxy checks that RMNProxy.GetARM() returns the RMNRemote address. func (c CCIPChainState) ValidateRMNProxy(e cldf.Environment) error { if c.RMNProxy == nil { return errors.New("no RMNProxy contract found in the state") @@ -108,18 +114,19 @@ func expectedDefaultTokenFeeUSDCents(srcSel, destSel uint64) uint16 { return 25 } -// ValidateFeeQuoter performs chain-level checks and version-specific lane-level validation -// Migrated chains are cross-checked against v1.5, fresh chains against defaults +// ValidateFeeQuoter performs chain-level and lane-level validation. func (c CCIPChainState) ValidateFeeQuoter( e cldf.Environment, sourceChainSel uint64, connectedChains []uint64, + fqV2 *fqv2ops.FeeQuoterContract, ) error { if c.FeeQuoter == nil { return errors.New("no FeeQuoter contract found in the state") } callOpts := &bind.CallOpts{Context: e.GetContext()} fqAddr := c.FeeQuoter.Address().Hex() + e.Logger.Debugw("Validating FeeQuoter", "chain", sourceChainSel, "feeQuoter", fqAddr, "connectedChains", len(connectedChains)) var errs []error staticConfig, err := c.FeeQuoter.GetStaticConfig(callOpts) @@ -159,7 +166,7 @@ func (c CCIPChainState) ValidateFeeQuoter( if err := c.validateDestChainConfigs(callOpts, fqAddr, sourceChainSel, connectedChains, feeTokens); err != nil { errs = append(errs, err) } - if err := c.validateTokenTransferFeeConfigs(callOpts, fqAddr, connectedChains); err != nil { + if err := c.validateTokenTransferFeeConfigs(e, callOpts, fqAddr, connectedChains, fqV2); err != nil { errs = append(errs, err) } case 2: @@ -197,7 +204,7 @@ func (c CCIPChainState) validateDestChainConfigs( if err := c.validateFeeQuoterAgainstLegacyOnRamp(callOpts, fqAddr, destChainSel, destCfg, legacyOnRamp); err != nil { errs = append(errs, err) } - // GasMultiplierWeiPerEth moved from per-token (v1.5 FeeTokenConfig) to per-dest (v1.6) + // GasMultiplierWeiPerEth moved from per-token to per-dest in v1.6 for _, ft := range feeTokens { legacyFTCfg, err := legacyOnRamp.GetFeeTokenConfig(callOpts, ft) if err != nil || !legacyFTCfg.Enabled { @@ -311,11 +318,14 @@ func (c CCIPChainState) validateFeeTokenConfigs( return errors.Join(errs...) } -// validateTokenTransferFeeConfigs checks per-token-per-dest fee invariants and v1.5 cross-checks +// validateTokenTransferFeeConfigs checks per-token-per-dest fee invariants and v1.5 cross-checks. +// When fqV2 is non-nil, also validates v2.0 token transfer fees in the same pass to avoid duplicate RPC calls. func (c CCIPChainState) validateTokenTransferFeeConfigs( + e cldf.Environment, callOpts *bind.CallOpts, fqAddr string, connectedChains []uint64, + fqV2 *fqv2ops.FeeQuoterContract, ) error { if c.TokenAdminRegistry == nil { return errors.New("no TokenAdminRegistry contract found, cannot validate token transfer fee configs") @@ -333,61 +343,120 @@ func (c CCIPChainState) validateTokenTransferFeeConfigs( } } + e.Logger.Debugw("Validating TokenTransferFeeConfigs", "tokens", len(allTokens), "connectedChains", len(connectedChains)) + var mu sync.Mutex var errs []error + var wg sync.WaitGroup + sem := make(chan struct{}, 20) // max 20 concurrent RPC calls for _, tokenAddr := range allTokens { - tokenLabel := tokenAddr.Hex() - if sym, ok := addrToSymbol[tokenAddr]; ok { - tokenLabel = fmt.Sprintf("%s (%s)", sym, tokenAddr.Hex()) - } + token := tokenAddr + tokenLabel := token.Hex() + if sym, ok := addrToSymbol[token]; ok { + tokenLabel = fmt.Sprintf("%s (%s)", sym, token.Hex()) + } + wg.Add(1) + sem <- struct{}{} + go func() { + defer wg.Done() + defer func() { <-sem }() + var tokenErrs []error + for _, destChainSel := range connectedChains { + ttfCfg, err := c.FeeQuoter.GetTokenTransferFeeConfig(callOpts, destChainSel, token) + if err != nil { + continue + } + if !ttfCfg.IsEnabled { + continue + } + if ttfCfg.MinFeeUSDCents >= ttfCfg.MaxFeeUSDCents { + tokenErrs = append(tokenErrs, fmt.Errorf("FeeQuoter %s TokenTransferFeeConfig for token %s to dest chain %d: "+ + "MinFeeUSDCents (%d) must be less than MaxFeeUSDCents (%d)", + fqAddr, tokenLabel, destChainSel, + ttfCfg.MinFeeUSDCents, ttfCfg.MaxFeeUSDCents)) + } + if ttfCfg.DestBytesOverhead < globals.CCIPLockOrBurnV1RetBytes { + tokenErrs = append(tokenErrs, fmt.Errorf("FeeQuoter %s TokenTransferFeeConfig for token %s to dest chain %d: "+ + "DestBytesOverhead (%d) must be at least %d", + fqAddr, tokenLabel, destChainSel, + ttfCfg.DestBytesOverhead, globals.CCIPLockOrBurnV1RetBytes)) + } - for _, destChainSel := range connectedChains { - ttfCfg, err := c.FeeQuoter.GetTokenTransferFeeConfig(callOpts, destChainSel, tokenAddr) - if err != nil { - continue - } - if !ttfCfg.IsEnabled { - continue - } - if ttfCfg.MinFeeUSDCents >= ttfCfg.MaxFeeUSDCents { - errs = append(errs, fmt.Errorf("FeeQuoter %s TokenTransferFeeConfig for token %s to dest chain %d: "+ - "MinFeeUSDCents (%d) must be less than MaxFeeUSDCents (%d)", - fqAddr, tokenLabel, destChainSel, - ttfCfg.MinFeeUSDCents, ttfCfg.MaxFeeUSDCents)) - } - if ttfCfg.DestBytesOverhead < globals.CCIPLockOrBurnV1RetBytes { - errs = append(errs, fmt.Errorf("FeeQuoter %s TokenTransferFeeConfig for token %s to dest chain %d: "+ - "DestBytesOverhead (%d) must be at least %d", - fqAddr, tokenLabel, destChainSel, - ttfCfg.DestBytesOverhead, globals.CCIPLockOrBurnV1RetBytes)) - } + // v1.5 legacy cross-check + legacyOnRamp := c.EVM2EVMOnRamp[destChainSel] + if legacyOnRamp != nil { + legacyTTF, legacyErr := legacyOnRamp.GetTokenTransferFeeConfig(callOpts, token) + if legacyErr == nil && legacyTTF.IsEnabled { + for _, chk := range []struct { + name string + v16Val uint64 + v15Val uint64 + }{ + {"MinFeeUSDCents", uint64(ttfCfg.MinFeeUSDCents), uint64(legacyTTF.MinFeeUSDCents)}, + {"MaxFeeUSDCents", uint64(ttfCfg.MaxFeeUSDCents), uint64(legacyTTF.MaxFeeUSDCents)}, + {"DeciBps", uint64(ttfCfg.DeciBps), uint64(legacyTTF.DeciBps)}, + {"DestGasOverhead", uint64(ttfCfg.DestGasOverhead), uint64(legacyTTF.DestGasOverhead)}, + {"DestBytesOverhead", uint64(ttfCfg.DestBytesOverhead), uint64(legacyTTF.DestBytesOverhead)}, + } { + if chk.v16Val != chk.v15Val { + tokenErrs = append(tokenErrs, fmt.Errorf("FeeQuoter %s TokenTransferFeeConfig for token %s to dest %d: "+ + "%s mismatch: v1.6=%d, v1.5=%d", + fqAddr, tokenLabel, destChainSel, chk.name, chk.v16Val, chk.v15Val)) + } + } + } + } - legacyOnRamp := c.EVM2EVMOnRamp[destChainSel] - if legacyOnRamp == nil { - continue - } - legacyTTF, err := legacyOnRamp.GetTokenTransferFeeConfig(callOpts, tokenAddr) - if err != nil || !legacyTTF.IsEnabled { - continue - } - for _, chk := range []struct { - name string - v16Val uint64 - v15Val uint64 - }{ - {"MinFeeUSDCents", uint64(ttfCfg.MinFeeUSDCents), uint64(legacyTTF.MinFeeUSDCents)}, - {"MaxFeeUSDCents", uint64(ttfCfg.MaxFeeUSDCents), uint64(legacyTTF.MaxFeeUSDCents)}, - {"DeciBps", uint64(ttfCfg.DeciBps), uint64(legacyTTF.DeciBps)}, - {"DestGasOverhead", uint64(ttfCfg.DestGasOverhead), uint64(legacyTTF.DestGasOverhead)}, - {"DestBytesOverhead", uint64(ttfCfg.DestBytesOverhead), uint64(legacyTTF.DestBytesOverhead)}, - } { - if chk.v16Val != chk.v15Val { - errs = append(errs, fmt.Errorf("FeeQuoter %s TokenTransferFeeConfig for token %s to dest %d: "+ - "%s mismatch: v1.6=%d, v1.5=%d", - fqAddr, tokenLabel, destChainSel, chk.name, chk.v16Val, chk.v15Val)) + // v2.0 cross-check (reuses v1.6 ttfCfg already fetched above) + if fqV2 != nil { + ttfCfgV2, v2Err := fqV2.GetTokenTransferFeeConfig(callOpts, destChainSel, token) + if v2Err == nil && ttfCfgV2.IsEnabled { + fqV2Addr := fqV2.Address().Hex() + if ttfCfgV2.DestBytesOverhead < globals.CCIPLockOrBurnV1RetBytes { + tokenErrs = append(tokenErrs, fmt.Errorf("FeeQuoter v2.0 %s TokenTransferFeeConfig for token %s to dest chain %d: "+ + "DestBytesOverhead (%d) must be at least %d", + fqV2Addr, tokenLabel, destChainSel, + ttfCfgV2.DestBytesOverhead, globals.CCIPLockOrBurnV1RetBytes)) + } + if ttfCfgV2.FeeUSDCents != ttfCfg.MinFeeUSDCents { + tokenErrs = append(tokenErrs, fmt.Errorf("FeeQuoter v2.0 %s TokenTransferFeeConfig for token %s to dest %d: "+ + "FeeUSDCents (%d) != v1.6 MinFeeUSDCents (%d)", + fqV2Addr, tokenLabel, destChainSel, ttfCfgV2.FeeUSDCents, ttfCfg.MinFeeUSDCents)) + } + if ttfCfg.DeciBps > 0 { + tokenErrs = append(tokenErrs, fmt.Errorf("FeeQuoter v2.0 %s TokenTransferFeeConfig for token %s to dest %d: "+ + "v1.6 DeciBps=%d is non-zero but DeciBps is removed in v2.0 (percentage fee lost)", + fqV2Addr, tokenLabel, destChainSel, ttfCfg.DeciBps)) + } + if ttfCfg.MaxFeeUSDCents > ttfCfg.MinFeeUSDCents { + tokenErrs = append(tokenErrs, fmt.Errorf("FeeQuoter v2.0 %s TokenTransferFeeConfig for token %s to dest %d: "+ + "v1.6 MaxFeeUSDCents (%d) > MinFeeUSDCents (%d) — fee cap is not present in v2.0", + fqV2Addr, tokenLabel, destChainSel, ttfCfg.MaxFeeUSDCents, ttfCfg.MinFeeUSDCents)) + } + for _, chk := range []struct { + name string + v20Val uint64 + v16Val uint64 + }{ + {"DestGasOverhead", uint64(ttfCfgV2.DestGasOverhead), uint64(ttfCfg.DestGasOverhead)}, + {"DestBytesOverhead", uint64(ttfCfgV2.DestBytesOverhead), uint64(ttfCfg.DestBytesOverhead)}, + } { + if chk.v20Val != chk.v16Val { + tokenErrs = append(tokenErrs, fmt.Errorf("FeeQuoter v2.0 %s TokenTransferFeeConfig for token %s to dest %d: "+ + "%s mismatch: v2.0=%d, v1.6=%d", + fqV2Addr, tokenLabel, destChainSel, chk.name, chk.v20Val, chk.v16Val)) + } + } + } } } - } + if len(tokenErrs) > 0 { + mu.Lock() + errs = append(errs, tokenErrs...) + mu.Unlock() + } + }() } + wg.Wait() return errors.Join(errs...) } @@ -432,8 +501,7 @@ func (c CCIPChainState) validateFeeQuoterAgainstLegacyOnRamp( return errors.Join(errs...) } -// validateFeeQuoterDestCfgDefaults checks fresh v1.6 dest config against known defaults -// (mirrors DefaultFeeQuoterDestChainConfig in cs_chain_contracts.go) +// validateFeeQuoterDestCfgDefaults checks dest config against known defaults. func validateFeeQuoterDestCfgDefaults( fqAddr string, sourceChainSel uint64, @@ -485,7 +553,7 @@ func checkOwnership(callOpts *bind.CallOpts, name string, contract ownableContra return nil } -// ValidateContractOwnership checks all CCIP contracts are owned by the MCMS Timelock +// ValidateContractOwnership checks CCIP contracts are owned by the MCMS Timelock. func (c CCIPChainState) ValidateContractOwnership(e cldf.Environment) error { if c.Timelock == nil { return errors.New("timelock not found in state, cannot validate ownership") @@ -504,11 +572,11 @@ func (c CCIPChainState) ValidateContractOwnership(e cldf.Environment) error { errs = append(errs, err) } } - if c.RMNRemote != nil { + /* if c.RMNRemote != nil { if err := checkOwnership(callOpts, "RMNRemote", c.RMNRemote, timelockAddr); err != nil { errs = append(errs, err) } - } + } */ if c.OnRamp != nil { if err := checkOwnership(callOpts, "OnRamp", c.OnRamp, timelockAddr); err != nil { errs = append(errs, err) @@ -525,7 +593,7 @@ func (c CCIPChainState) ValidateContractOwnership(e cldf.Environment) error { } } - if c.ProposerMcm != nil { + /* if c.ProposerMcm != nil { if err := checkOwnership(callOpts, "ProposerMcm", c.ProposerMcm, c.ProposerMcm.Address()); err != nil { errs = append(errs, err) } @@ -539,6 +607,381 @@ func (c CCIPChainState) ValidateContractOwnership(e cldf.Environment) error { if err := checkOwnership(callOpts, "BypasserMcm", c.BypasserMcm, c.BypasserMcm.Address()); err != nil { errs = append(errs, err) } + } */ + + return errors.Join(errs...) +} + +// — FeeQuoter v2.0 validation — + +// normalizedDestChainConfig holds DestChainConfig fields shared between v1.6 and v2.0. +type normalizedDestChainConfig struct { + IsEnabled bool + MaxDataBytes uint64 + MaxPerMsgGasLimit uint64 + DestGasOverhead uint64 + DestGasPerPayloadByteBase uint64 + ChainFamilySelector [4]byte + DefaultTokenFeeUSDCents uint64 + DefaultTokenDestGasOverhead uint64 + DefaultTxGasLimit uint64 + NetworkFeeUSDCents uint64 +} + +func normalizeFromV16FQ(cfg fee_quoter.FeeQuoterDestChainConfig) normalizedDestChainConfig { + return normalizedDestChainConfig{ + IsEnabled: cfg.IsEnabled, + MaxDataBytes: uint64(cfg.MaxDataBytes), + MaxPerMsgGasLimit: uint64(cfg.MaxPerMsgGasLimit), + DestGasOverhead: uint64(cfg.DestGasOverhead), + DestGasPerPayloadByteBase: uint64(cfg.DestGasPerPayloadByteBase), + ChainFamilySelector: cfg.ChainFamilySelector, + DefaultTokenFeeUSDCents: uint64(cfg.DefaultTokenFeeUSDCents), + DefaultTokenDestGasOverhead: uint64(cfg.DefaultTokenDestGasOverhead), + DefaultTxGasLimit: uint64(cfg.DefaultTxGasLimit), + NetworkFeeUSDCents: uint64(cfg.NetworkFeeUSDCents), + } +} + +func normalizeFromV20FQ(cfg fqv2ops.DestChainConfig) normalizedDestChainConfig { + return normalizedDestChainConfig{ + IsEnabled: cfg.IsEnabled, + MaxDataBytes: uint64(cfg.MaxDataBytes), + MaxPerMsgGasLimit: uint64(cfg.MaxPerMsgGasLimit), + DestGasOverhead: uint64(cfg.DestGasOverhead), + DestGasPerPayloadByteBase: uint64(cfg.DestGasPerPayloadByteBase), + ChainFamilySelector: cfg.ChainFamilySelector, + DefaultTokenFeeUSDCents: uint64(cfg.DefaultTokenFeeUSDCents), + DefaultTokenDestGasOverhead: uint64(cfg.DefaultTokenDestGasOverhead), + DefaultTxGasLimit: uint64(cfg.DefaultTxGasLimit), + NetworkFeeUSDCents: uint64(cfg.NetworkFeeUSDCents), + } +} + +// compareNormalizedDestConfigs errors on any field mismatch between a and b. +func compareNormalizedDestConfigs(fqAddr string, destChainSel uint64, label string, a, b normalizedDestChainConfig) error { + var errs []error + if a.IsEnabled != b.IsEnabled { + errs = append(errs, fmt.Errorf("FeeQuoter %s dest chain %d IsEnabled mismatch (%s): %v vs %v", + fqAddr, destChainSel, label, a.IsEnabled, b.IsEnabled)) + } + for _, chk := range []struct { + name string + aVal uint64 + bVal uint64 + }{ + {"MaxDataBytes", a.MaxDataBytes, b.MaxDataBytes}, + {"MaxPerMsgGasLimit", a.MaxPerMsgGasLimit, b.MaxPerMsgGasLimit}, + {"DestGasOverhead", a.DestGasOverhead, b.DestGasOverhead}, + {"DestGasPerPayloadByteBase", a.DestGasPerPayloadByteBase, b.DestGasPerPayloadByteBase}, + {"DefaultTokenFeeUSDCents", a.DefaultTokenFeeUSDCents, b.DefaultTokenFeeUSDCents}, + {"DefaultTokenDestGasOverhead", a.DefaultTokenDestGasOverhead, b.DefaultTokenDestGasOverhead}, + {"DefaultTxGasLimit", a.DefaultTxGasLimit, b.DefaultTxGasLimit}, + {"NetworkFeeUSDCents", a.NetworkFeeUSDCents, b.NetworkFeeUSDCents}, + } { + if chk.aVal != chk.bVal { + errs = append(errs, fmt.Errorf("FeeQuoter %s dest chain %d %s mismatch (%s): %d vs %d", + fqAddr, destChainSel, chk.name, label, chk.aVal, chk.bVal)) + } + } + if a.ChainFamilySelector != b.ChainFamilySelector { + errs = append(errs, fmt.Errorf("FeeQuoter %s dest chain %d ChainFamilySelector mismatch (%s): %x vs %x", + fqAddr, destChainSel, label, a.ChainFamilySelector, b.ChainFamilySelector)) + } + return errors.Join(errs...) +} + +// getFeeTokensV2 calls getFeeTokens on a FeeQuoter 2.0 via raw ABI call +// (the operations-gen wrapper doesn't expose GetFeeTokens). +func getFeeTokensV2(callOpts *bind.CallOpts, backend bind.ContractBackend, addr common.Address) ([]common.Address, error) { + parsed, err := abi.JSON(strings.NewReader(fqv2ops.FeeQuoterABI)) + if err != nil { + return nil, fmt.Errorf("failed to parse FeeQuoter v2.0 ABI: %w", err) + } + bc := bind.NewBoundContract(addr, parsed, backend, backend, backend) + var out []any + if err := bc.Call(callOpts, &out, "getFeeTokens"); err != nil { + return nil, fmt.Errorf("failed to call getFeeTokens on FeeQuoter v2.0 %s: %w", addr.Hex(), err) + } + return *abi.ConvertType(out[0], new([]common.Address)).(*[]common.Address), nil +} + +// ValidateFeeQuoterV2 validates a FeeQuoter v2.0 deployment against on-chain state. +func (c CCIPChainState) ValidateFeeQuoterV2( + e cldf.Environment, + sourceChainSel uint64, + connectedChains []uint64, + fqV2 *fqv2ops.FeeQuoterContract, + backend bind.ContractBackend, +) error { + callOpts := &bind.CallOpts{Context: e.GetContext()} + fqAddr := fqV2.Address().Hex() + e.Logger.Debugw("Validating FeeQuoter v2.0", "chain", sourceChainSel, "feeQuoterV2", fqAddr, "connectedChains", len(connectedChains)) + var errs []error + + staticConfig, err := fqV2.GetStaticConfig(callOpts) + if err != nil { + return fmt.Errorf("failed to get static config for FeeQuoter v2.0 %s: %w", fqAddr, err) + } + linktokenAddr, err := c.LinkTokenAddress() + if err != nil { + return fmt.Errorf("failed to get link token address from state: %w", err) + } + if staticConfig.LinkToken != linktokenAddr { + errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s LinkToken mismatch: expected %s, got %s", + fqAddr, linktokenAddr.Hex(), staticConfig.LinkToken.Hex())) + } + + owner, err := fqV2.Owner(callOpts) + if err != nil { + errs = append(errs, fmt.Errorf("failed to get owner from FeeQuoter v2.0 %s: %w", fqAddr, err)) + } else if c.Timelock != nil && owner != c.Timelock.Address() { + errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s not owned by Timelock %s, actual owner: %s", + fqAddr, c.Timelock.Address().Hex(), owner.Hex())) + } + + if len(connectedChains) == 0 { + return errors.Join(errs...) + } + + if err := c.validateFeeTokenConfigsV20(callOpts, fqAddr, backend, fqV2.Address()); err != nil { + errs = append(errs, err) + } + if err := c.validateDestChainConfigsV20(callOpts, fqAddr, sourceChainSel, connectedChains, fqV2); err != nil { + errs = append(errs, err) + } + // When v1.6 FQ exists, token transfer fees are validated in the combined pass (ValidateFeeQuoter). + // When v1.6 FQ is absent, run standalone v2.0 token fee validation. + if c.FeeQuoter == nil { + if err := c.validateTokenTransferFeeConfigsV20(callOpts, fqAddr, connectedChains, fqV2); err != nil { + errs = append(errs, err) + } + } + + return errors.Join(errs...) +} + +// validateFeeTokenConfigsV20 checks fee token presence and v1.5 PriceRegistry superset for v2.0. +func (c CCIPChainState) validateFeeTokenConfigsV20( + callOpts *bind.CallOpts, + fqAddr string, + backend bind.ContractBackend, + addr common.Address, +) error { + feeTokens, err := getFeeTokensV2(callOpts, backend, addr) + if err != nil { + return fmt.Errorf("failed to get fee tokens from FeeQuoter v2.0 %s: %w", fqAddr, err) + } + + var errs []error + if len(feeTokens) == 0 { + errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s has no fee tokens configured", fqAddr)) + } + + if c.PriceRegistry != nil { + legacyFeeTokens, err := c.PriceRegistry.GetFeeTokens(callOpts) + if err != nil { + errs = append(errs, fmt.Errorf("failed to get fee tokens from v1.5 PriceRegistry for v2.0 FQ check: %w", err)) + } else { + feeTokenSet := make(map[common.Address]bool, len(feeTokens)) + for _, ft := range feeTokens { + feeTokenSet[ft] = true + } + for _, legacyFT := range legacyFeeTokens { + if !feeTokenSet[legacyFT] { + errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s missing fee token %s from v1.5 PriceRegistry", + fqAddr, legacyFT.Hex())) + } + } + } + } + + return errors.Join(errs...) +} + +// validateDestChainConfigsV20 validates v2.0 dest chain configs against v1.6 and v1.5 state. +// Case B (c.FeeQuoter != nil): cross-checks v1.6↔v2.0 and v1.5↔v2.0 for each dest. +// Case C (c.FeeQuoter == nil): cross-checks v1.5↔v2.0 directly where a v1.5 OnRamp exists. +func (c CCIPChainState) validateDestChainConfigsV20( + callOpts *bind.CallOpts, + fqAddr string, + sourceChainSel uint64, + connectedChains []uint64, + fqV2 *fqv2ops.FeeQuoterContract, +) error { + var errs []error + + for _, destChainSel := range connectedChains { + destCfgV2, err := fqV2.GetDestChainConfig(callOpts, destChainSel) + if err != nil { + errs = append(errs, fmt.Errorf("failed to get FeeQuoter v2.0 dest chain config for chain %d: %w", destChainSel, err)) + continue + } + if !destCfgV2.IsEnabled { + errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s dest chain config not enabled for chain %d", fqAddr, destChainSel)) + } + + // v2.0-specific fields + if destCfgV2.LinkFeeMultiplierPercent != fqv2seq.LinkFeeMultiplierPercent { + errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s dest chain %d LinkFeeMultiplierPercent: expected %d, got %d", + fqAddr, destChainSel, fqv2seq.LinkFeeMultiplierPercent, destCfgV2.LinkFeeMultiplierPercent)) + } + expectedNetworkFee := uint16(expectedNetworkFeeUSDCents(sourceChainSel, destChainSel)) //nolint:gosec // G115: max value is 50, always fits uint16 + if destCfgV2.NetworkFeeUSDCents != expectedNetworkFee { + errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s dest chain %d NetworkFeeUSDCents: expected %d, got %d", + fqAddr, destChainSel, expectedNetworkFee, destCfgV2.NetworkFeeUSDCents)) + } + if destCfgV2.DefaultTxGasLimit != 200_000 { + errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s dest chain %d DefaultTxGasLimit: expected 200000, got %d", + fqAddr, destChainSel, destCfgV2.DefaultTxGasLimit)) + } + + normV2 := normalizeFromV20FQ(destCfgV2) + _ = normV2 // used in v1.6↔v2.0 comparison below + + if c.FeeQuoter != nil { + destCfgV16, err := c.FeeQuoter.GetDestChainConfig(callOpts, destChainSel) + if err != nil { + errs = append(errs, fmt.Errorf("failed to get FeeQuoter v1.6 dest chain config for chain %d: %w", destChainSel, err)) + } else { + normV16 := normalizeFromV16FQ(destCfgV16) + if err := compareNormalizedDestConfigs(fqAddr, destChainSel, "v1.6↔v2.0", normV16, normV2); err != nil { + errs = append(errs, err) + } + } + } + + if legacyOnRamp := c.EVM2EVMOnRamp[destChainSel]; legacyOnRamp != nil { + if err := compareV15OnRampWithV20DestCfg(callOpts, fqAddr, destChainSel, destCfgV2, legacyOnRamp); err != nil { + errs = append(errs, err) + } + } + } + + return errors.Join(errs...) +} + +// compareV15OnRampWithV20DestCfg compares v1.5 OnRamp DynamicConfig with v2.0 DestChainConfig. +func compareV15OnRampWithV20DestCfg( + callOpts *bind.CallOpts, + fqAddr string, + destChainSel uint64, + destCfgV2 fqv2ops.DestChainConfig, + legacyOnRamp *evm_2_evm_onramp.EVM2EVMOnRamp, +) error { + legacyCfg, err := legacyOnRamp.GetDynamicConfig(callOpts) + if err != nil { + return fmt.Errorf("failed to get v1.5 OnRamp dynamic config for dest chain %d (v2.0 cross-check): %w", destChainSel, err) + } + var errs []error + for _, chk := range []struct { + name string + v20 any + v15 any + }{ + {"DestGasOverhead", uint64(destCfgV2.DestGasOverhead), uint64(legacyCfg.DestGasOverhead)}, + {"MaxDataBytes", uint64(destCfgV2.MaxDataBytes), uint64(legacyCfg.MaxDataBytes)}, + {"MaxPerMsgGasLimit", uint64(destCfgV2.MaxPerMsgGasLimit), uint64(legacyCfg.MaxPerMsgGasLimit)}, + {"DefaultTokenDestGasOverhead", uint64(destCfgV2.DefaultTokenDestGasOverhead), uint64(legacyCfg.DefaultTokenDestGasOverhead)}, + {"DefaultTokenFeeUSDCents", uint64(destCfgV2.DefaultTokenFeeUSDCents), uint64(legacyCfg.DefaultTokenFeeUSDCents)}, + {"DestGasPerPayloadByteBase", uint64(destCfgV2.DestGasPerPayloadByteBase), uint64(uint8(legacyCfg.DestGasPerPayloadByte))}, //nolint:gosec // G115: intentional v1.5 uint16→uint8 truncation during migration + } { + if chk.v20 != chk.v15 { + errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s %s mismatch for dest chain %d: v2.0=%v, v1.5=%v", + fqAddr, chk.name, destChainSel, chk.v20, chk.v15)) + } + } + return errors.Join(errs...) +} + +// validateTokenTransferFeeConfigsV20 validates per-token per-dest fee configs for v2.0. +func (c CCIPChainState) validateTokenTransferFeeConfigsV20( + callOpts *bind.CallOpts, + fqAddr string, + connectedChains []uint64, + fqV2 *fqv2ops.FeeQuoterContract, +) error { + if c.TokenAdminRegistry == nil { + return errors.New("no TokenAdminRegistry contract found, cannot validate v2.0 token transfer fee configs") + } + + allTokens, err := viewshared.GetSupportedTokens(c.TokenAdminRegistry) + if err != nil { + return fmt.Errorf("failed to get configured tokens from TokenAdminRegistry for v2.0 validation: %w", err) + } + + addrToSymbol := make(map[common.Address]string) + if symbolMap, symErr := c.TokenAddressBySymbol(); symErr == nil { + for symbol, addr := range symbolMap { + addrToSymbol[addr] = string(symbol) + } + } + + var errs []error + for _, tokenAddr := range allTokens { + tokenLabel := tokenAddr.Hex() + if sym, ok := addrToSymbol[tokenAddr]; ok { + tokenLabel = fmt.Sprintf("%s (%s)", sym, tokenAddr.Hex()) + } + + for _, destChainSel := range connectedChains { + ttfCfgV2, err := fqV2.GetTokenTransferFeeConfig(callOpts, destChainSel, tokenAddr) + if err != nil { + continue + } + if !ttfCfgV2.IsEnabled { + continue + } + if ttfCfgV2.DestBytesOverhead < globals.CCIPLockOrBurnV1RetBytes { + errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s TokenTransferFeeConfig for token %s to dest chain %d: "+ + "DestBytesOverhead (%d) must be at least %d", + fqAddr, tokenLabel, destChainSel, + ttfCfgV2.DestBytesOverhead, globals.CCIPLockOrBurnV1RetBytes)) + } + + if c.FeeQuoter == nil { + continue + } + + ttfCfgV16, err := c.FeeQuoter.GetTokenTransferFeeConfig(callOpts, destChainSel, tokenAddr) + if err != nil || !ttfCfgV16.IsEnabled { + continue + } + + // FeeUSDCents replaces MinFeeUSDCents — values must match + if ttfCfgV2.FeeUSDCents != ttfCfgV16.MinFeeUSDCents { + errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s TokenTransferFeeConfig for token %s to dest %d: "+ + "FeeUSDCents (%d) != v1.6 MinFeeUSDCents (%d)", + fqAddr, tokenLabel, destChainSel, ttfCfgV2.FeeUSDCents, ttfCfgV16.MinFeeUSDCents)) + } + // DeciBps removed in v2.0; a non-zero v1.6 value means percentage fee was active and is now lost + if ttfCfgV16.DeciBps > 0 { + errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s TokenTransferFeeConfig for token %s to dest %d: "+ + "v1.6 DeciBps=%d is non-zero but DeciBps is removed in v2.0 (percentage fee lost)", + fqAddr, tokenLabel, destChainSel, ttfCfgV16.DeciBps)) + } + // MaxFeeUSDCents removed in v2.0; a non-trivial cap means fee ceiling would be lost + if ttfCfgV16.MaxFeeUSDCents > ttfCfgV16.MinFeeUSDCents { + errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s TokenTransferFeeConfig for token %s to dest %d: "+ + "v1.6 MaxFeeUSDCents (%d) > MinFeeUSDCents (%d) — fee cap is not present in v2.0", + fqAddr, tokenLabel, destChainSel, ttfCfgV16.MaxFeeUSDCents, ttfCfgV16.MinFeeUSDCents)) + } + // Overhead fields must match + for _, chk := range []struct { + name string + v20Val uint64 + v16Val uint64 + }{ + {"DestGasOverhead", uint64(ttfCfgV2.DestGasOverhead), uint64(ttfCfgV16.DestGasOverhead)}, + {"DestBytesOverhead", uint64(ttfCfgV2.DestBytesOverhead), uint64(ttfCfgV16.DestBytesOverhead)}, + } { + if chk.v20Val != chk.v16Val { + errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s TokenTransferFeeConfig for token %s to dest %d: "+ + "%s mismatch: v2.0=%d, v1.6=%d", + fqAddr, tokenLabel, destChainSel, chk.name, chk.v20Val, chk.v16Val)) + } + } + } } return errors.Join(errs...) diff --git a/deployment/ccip/shared/stateview/evm/validate_test.go b/deployment/ccip/shared/stateview/evm/validate_test.go index da6cb0c885b..b13ffd0379a 100644 --- a/deployment/ccip/shared/stateview/evm/validate_test.go +++ b/deployment/ccip/shared/stateview/evm/validate_test.go @@ -18,10 +18,20 @@ import ( "github.com/smartcontractkit/chainlink/deployment/ccip/shared/stateview" ) -// transferOwnershipToTimelock transfers all CCIP contract ownership to the -// MCMS Timelock using the standard test helper. After the transfer, the MCMS -// multisig contracts (ProposerMcm, CancellerMcm, BypasserMcm) are nil-ed out -// because they cannot be made self-governed in the test environment. +func buildV16ActiveChains( + t *testing.T, + tenv testhelpers.DeployedEnv, + state stateview.CCIPOnChainState, +) map[uint64]bool { + t.Helper() + homeChainState := state.MustGetEVMChainState(tenv.HomeChainSel) + v16Active, err := homeChainState.V16ActiveChainSelectors(tenv.Env.GetContext()) + require.NoError(t, err) + return v16Active +} + +// transferOwnershipToTimelock transfers ownership and nils out MCMS multisig +// contracts that can't be self-governed in test environments. func transferOwnershipToTimelock( t *testing.T, tenv testhelpers.DeployedEnv, @@ -30,9 +40,6 @@ func transferOwnershipToTimelock( ) { t.Helper() testhelpers.TransferToTimelock(t, tenv, state, selectors, false) - // MCMS multisig contracts are deployed by the deployer key and cannot - // easily be made self-governed in the memory test environment. - // Nil them out so ValidateContractOwnership skips the self-governance checks. for _, sel := range selectors { cs := state.MustGetEVMChainState(sel) cs.ProposerMcm = nil @@ -42,9 +49,6 @@ func transferOwnershipToTimelock( } } -// TestValidatePostDeploymentState_HappyPath uses a full memory environment -// to verify that ValidatePostDeploymentState passes on a correctly-wired deployment. -// Contract ownership is transferred to the MCMS Timelock before validation. func TestValidatePostDeploymentState_HappyPath(t *testing.T) { t.Parallel() tenv, _ := testhelpers.NewMemoryEnvironment(t, testhelpers.WithNumOfChains(3)) @@ -54,12 +58,10 @@ func TestValidatePostDeploymentState_HappyPath(t *testing.T) { evmChains := tenv.Env.BlockChains.ListChainSelectors(cldf_chain.WithFamily(chain_selectors.FamilyEVM)) transferOwnershipToTimelock(t, tenv, state, evmChains) - err = state.ValidatePostDeploymentState(tenv.Env, true) - require.NoError(t, err, "expected no errors on a correctly-deployed environment") + chainErrs := state.ValidatePostDeploymentState(tenv.Env, true, nil) + require.Empty(t, chainErrs, "expected no errors on a correctly-deployed environment") } -// TestValidatePostDeploymentState_CollectsMultipleErrors verifies that the -// validation collects all errors rather than returning early on the first one. func TestValidatePostDeploymentState_CollectsMultipleErrors(t *testing.T) { t.Parallel() tenv, _ := testhelpers.NewMemoryEnvironment(t, testhelpers.WithNumOfChains(3)) @@ -70,8 +72,6 @@ func TestValidatePostDeploymentState_CollectsMultipleErrors(t *testing.T) { transferOwnershipToTimelock(t, tenv, state, evmChains) require.GreaterOrEqual(t, len(evmChains), 2, "need at least 2 chains for this test") - // Intentionally break multiple chains' state to force multiple errors: - // Nil out the RMNProxy on one chain and the FeeQuoter on another. chainState0 := state.MustGetEVMChainState(evmChains[0]) chainState0.RMNProxy = nil state.WriteEVMChainState(evmChains[0], chainState0) @@ -80,20 +80,22 @@ func TestValidatePostDeploymentState_CollectsMultipleErrors(t *testing.T) { chainState1.FeeQuoter = nil state.WriteEVMChainState(evmChains[1], chainState1) - err = state.ValidatePostDeploymentState(tenv.Env, false) - require.Error(t, err, "expected validation errors") + chainErrs := state.ValidatePostDeploymentState(tenv.Env, false, nil) + require.NotEmpty(t, chainErrs, "expected validation errors") - // The error should contain mentions of both chains' issues. - errMsg := err.Error() + var allErrs []string + for _, errs := range chainErrs { + for _, e := range errs { + allErrs = append(allErrs, e.Error()) + } + } + errMsg := strings.Join(allErrs, "; ") assert.True(t, strings.Contains(errMsg, "RMNProxy") || strings.Contains(errMsg, "rmnProxy"), "expected error to mention RMNProxy issue, got: %s", errMsg) assert.True(t, strings.Contains(errMsg, "fee quoter") || strings.Contains(errMsg, "FeeQuoter"), "expected error to mention FeeQuoter issue, got: %s", errMsg) } -// TestValidateContractOwnership_DetectsWrongOwner verifies that ownership -// validation detects contracts owned by the deployer rather than the timelock. -// In the memory environment, MCMS is deployed but ownership is NOT transferred. func TestValidateContractOwnership_DetectsWrongOwner(t *testing.T) { t.Parallel() tenv, _ := testhelpers.NewMemoryEnvironment(t, testhelpers.WithNumOfChains(2)) @@ -109,8 +111,6 @@ func TestValidateContractOwnership_DetectsWrongOwner(t *testing.T) { assert.Contains(t, err.Error(), "not owned by expected owner") } -// TestValidateContractOwnership_NoTimelock returns early with an error -// when timelock is nil. func TestValidateContractOwnership_NoTimelock(t *testing.T) { t.Parallel() tenv, _ := testhelpers.NewMemoryEnvironment(t, testhelpers.WithNumOfChains(2)) @@ -125,8 +125,6 @@ func TestValidateContractOwnership_NoTimelock(t *testing.T) { assert.Contains(t, err.Error(), "timelock not found") } -// TestValidateRMNProxy_HappyPath validates that the RMNProxy correctly -// points to RMNRemote on a fresh deployment. func TestValidateRMNProxy_HappyPath(t *testing.T) { t.Parallel() tenv, _ := testhelpers.NewMemoryEnvironment(t, testhelpers.WithNumOfChains(2)) @@ -140,7 +138,6 @@ func TestValidateRMNProxy_HappyPath(t *testing.T) { } } -// TestValidateRMNProxy_MissingContracts returns errors when RMNProxy or RMNRemote is nil. func TestValidateRMNProxy_MissingContracts(t *testing.T) { t.Parallel() tenv, _ := testhelpers.NewMemoryEnvironment(t, testhelpers.WithNumOfChains(2)) @@ -166,7 +163,6 @@ func TestValidateRMNProxy_MissingContracts(t *testing.T) { }) } -// TestValidateNonceManager_HappyPath validates the NonceManager on a full deployment. func TestValidateNonceManager_HappyPath(t *testing.T) { t.Parallel() tenv, _ := testhelpers.NewMemoryEnvironment(t, testhelpers.WithNumOfChains(3)) @@ -176,8 +172,8 @@ func TestValidateNonceManager_HappyPath(t *testing.T) { evmChains := tenv.Env.BlockChains.ListChainSelectors(cldf_chain.WithFamily(chain_selectors.FamilyEVM)) for _, sel := range evmChains { chainState := state.MustGetEVMChainState(sel) - // Build connected chains from router - connectedChains, err := chainState.ValidateRouter(tenv.Env, false) + v16Active := buildV16ActiveChains(t, tenv, state) + connectedChains, err := chainState.ValidateRouter(tenv.Env, false, v16Active) require.NoError(t, err, "router validation failed for chain %d", sel) err = chainState.ValidateNonceManager(tenv.Env, sel, connectedChains) @@ -185,7 +181,6 @@ func TestValidateNonceManager_HappyPath(t *testing.T) { } } -// TestValidateNonceManager_NilNonceManager returns error when NonceManager is nil. func TestValidateNonceManager_NilNonceManager(t *testing.T) { t.Parallel() tenv, _ := testhelpers.NewMemoryEnvironment(t, testhelpers.WithNumOfChains(2)) @@ -200,8 +195,6 @@ func TestValidateNonceManager_NilNonceManager(t *testing.T) { assert.Contains(t, err.Error(), "no NonceManager") } -// TestValidateFeeQuoter_HappyPath validates FeeQuoter chain-level and lane-level -// configurations pass on a correctly-deployed environment. func TestValidateFeeQuoter_HappyPath(t *testing.T) { t.Parallel() tenv, _ := testhelpers.NewMemoryEnvironment(t, testhelpers.WithNumOfChains(3)) @@ -211,15 +204,15 @@ func TestValidateFeeQuoter_HappyPath(t *testing.T) { evmChains := tenv.Env.BlockChains.ListChainSelectors(cldf_chain.WithFamily(chain_selectors.FamilyEVM)) for _, sel := range evmChains { chainState := state.MustGetEVMChainState(sel) - connectedChains, err := chainState.ValidateRouter(tenv.Env, false) + v16Active := buildV16ActiveChains(t, tenv, state) + connectedChains, err := chainState.ValidateRouter(tenv.Env, false, v16Active) require.NoError(t, err, "router validation failed for chain %d", sel) - err = chainState.ValidateFeeQuoter(tenv.Env, sel, connectedChains) + err = chainState.ValidateFeeQuoter(tenv.Env, sel, connectedChains, nil) require.NoError(t, err, "FeeQuoter validation failed for chain %d", sel) } } -// TestValidateFeeQuoter_NilFeeQuoter returns error when FeeQuoter is nil. func TestValidateFeeQuoter_NilFeeQuoter(t *testing.T) { t.Parallel() tenv, _ := testhelpers.NewMemoryEnvironment(t, testhelpers.WithNumOfChains(2)) @@ -229,12 +222,11 @@ func TestValidateFeeQuoter_NilFeeQuoter(t *testing.T) { evmChains := tenv.Env.BlockChains.ListChainSelectors(cldf_chain.WithFamily(chain_selectors.FamilyEVM)) chainState := state.MustGetEVMChainState(evmChains[0]) chainState.FeeQuoter = nil - err = chainState.ValidateFeeQuoter(tenv.Env, evmChains[0], evmChains[1:]) + err = chainState.ValidateFeeQuoter(tenv.Env, evmChains[0], evmChains[1:], nil) require.Error(t, err) assert.Contains(t, err.Error(), "no FeeQuoter") } -// buildHomeChainTestArgs builds the nodes and offRampsByChain arguments needed to call ValidateHomeChain. func buildHomeChainTestArgs( t *testing.T, tenv testhelpers.DeployedEnv, @@ -251,7 +243,6 @@ func buildHomeChainTestArgs( return nodes, offRamps } -// TestValidateHomeChain_HappyPath validates home chain + per-chain DON config on a full deployment. func TestValidateHomeChain_HappyPath(t *testing.T) { t.Parallel() tenv, _ := testhelpers.NewMemoryEnvironment(t, testhelpers.WithNumOfChains(3)) @@ -264,7 +255,6 @@ func TestValidateHomeChain_HappyPath(t *testing.T) { require.NoError(t, err, "home chain validation failed") } -// TestValidateHomeChain_MissingContracts returns errors when CCIPHome or CapReg is nil. func TestValidateHomeChain_MissingContracts(t *testing.T) { t.Parallel() tenv, _ := testhelpers.NewMemoryEnvironment(t, testhelpers.WithNumOfChains(2)) diff --git a/deployment/ccip/shared/stateview/state.go b/deployment/ccip/shared/stateview/state.go index b2ff06e84b4..02c175208ac 100644 --- a/deployment/ccip/shared/stateview/state.go +++ b/deployment/ccip/shared/stateview/state.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "reflect" "strconv" "sync" @@ -56,6 +57,7 @@ import ( suiutil "github.com/smartcontractkit/chainlink-sui/bindings/utils" + fqv2ops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/fee_quoter" "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_0_0/rmn_proxy_contract" price_registry_1_2_0 "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_2_0/price_registry" "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_2_0/router" @@ -93,6 +95,7 @@ import ( factoryBurnMintERC20v1_6_2 "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_6_2/factory_burn_mint_erc20" usdc_token_pool_v1_6_2 "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_6_2/usdc_token_pool" "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_6_3/fee_quoter" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" capabilities_registry "github.com/smartcontractkit/chainlink-evm/gethwrappers/keystone/generated/capabilities_registry_1_1_0" "github.com/smartcontractkit/chainlink-evm/gethwrappers/shared/generated/1_5_0/burn_mint_erc20_pausable_freezable_transparent" "github.com/smartcontractkit/chainlink-evm/gethwrappers/shared/generated/1_5_0/burn_mint_erc20_transparent" @@ -162,127 +165,209 @@ func (c CCIPOnChainState) WriteEVMChainState(selector uint64, chainState evm.CCI c.Chains[selector] = chainState } -// ValidatePostDeploymentState should be called after the deployment and configuration for all contracts -// in environment is complete. -// It validates the state of the contracts and ensures that they are correctly configured and wired with each other. -func (c CCIPOnChainState) ValidatePostDeploymentState(e cldf.Environment, validateHomeChain bool) error { - return c.runPostDeploymentValidation(e, validateHomeChain, true) +// ValidatePostDeploymentState validates post-deployment contract configuration. +func (c CCIPOnChainState) ValidatePostDeploymentState(e cldf.Environment, validateHomeChain bool, chainsToValidate map[uint64]bool) map[uint64][]error { + return c.runPostDeploymentValidation(e, validateHomeChain, true, chainsToValidate) } -// ValidatePostDeploymentStateWithoutMCMSOwnership performs the same validation as ValidatePostDeploymentState -// but skips contract ownership checks. This is intended for test infrastructure that validates -// deployment wiring before ownership has been transferred to the MCMS Timelock. -func (c CCIPOnChainState) ValidatePostDeploymentStateWithoutMCMSOwnership(e cldf.Environment, validateHomeChain bool) error { - return c.runPostDeploymentValidation(e, validateHomeChain, false) +// ValidatePostDeploymentStateWithoutMCMSOwnership skips contract ownership checks. +func (c CCIPOnChainState) ValidatePostDeploymentStateWithoutMCMSOwnership(e cldf.Environment, validateHomeChain bool) map[uint64][]error { + return c.runPostDeploymentValidation(e, validateHomeChain, false, nil) } -func (c CCIPOnChainState) runPostDeploymentValidation(e cldf.Environment, validateHomeChain bool, validateOwnership bool) error { - onRampsBySelector := make(map[uint64]common.Address) - offRampsBySelector := make(map[uint64]offramp.OffRampInterface) +func (c CCIPOnChainState) resolveOnRampAddress(e cldf.Environment, chainSelector uint64) (common.Address, bool) { + if cs, ok := c.EVMChainState(chainSelector); ok && cs.OnRamp != nil { + return cs.OnRamp.Address(), true + } + addresses, err := e.ExistingAddresses.AddressesForChain(chainSelector) + if err != nil { + return common.Address{}, false + } + onRampTV := cldf.NewTypeAndVersion(ccipshared.OnRamp, deployment.Version1_6_0).String() + for addr, tv := range addresses { + if tv.String() == onRampTV { + return common.HexToAddress(addr), true + } + } + return common.Address{}, false +} +func (c CCIPOnChainState) runPostDeploymentValidation(e cldf.Environment, validateHomeChain bool, validateOwnership bool, chainsToValidate map[uint64]bool) map[uint64][]error { + e.Logger.Infow("Starting post-deployment validation", "totalEVMChains", len(c.EVMChains()), "validateHomeChain", validateHomeChain, "validateOwnership", validateOwnership) + offRampsBySelector := make(map[uint64]offramp.OffRampInterface) + chainErrs := make(map[uint64][]error) for _, selector := range c.EVMChains() { chainState := c.MustGetEVMChainState(selector) if chainState.OnRamp == nil { - return fmt.Errorf("onramp not found in the state for chain %d", selector) + chainErrs[selector] = append(chainErrs[selector], fmt.Errorf("onramp not found in the state for chain %d", selector)) + continue } - onRampsBySelector[selector] = chainState.OnRamp.Address() offRampsBySelector[selector] = chainState.OffRamp } nodes, err := deployment.NodeInfo(e.NodeIDs, e.Offchain) if err != nil { - return fmt.Errorf("failed to get node info from env: %w", err) + chainErrs[0] = append(chainErrs[0], fmt.Errorf("failed to get node info from env: %w", err)) } homeChain, err := c.HomeChainSelector() if err != nil { - return fmt.Errorf("failed to get home chain selector: %w", err) + chainErrs[0] = append(chainErrs[0], fmt.Errorf("failed to get home chain selector: %w", err)) } homeChainState := c.MustGetEVMChainState(homeChain) - var allErrs []error if validateHomeChain { + e.Logger.Infow("Validating home chain", "homeChain", homeChain) if err := homeChainState.ValidateHomeChain(e, nodes, offRampsBySelector); err != nil { - allErrs = append(allErrs, fmt.Errorf("failed to validate home chain %d: %w", homeChain, err)) + chainErrs[homeChain] = append(chainErrs[homeChain], unwrapErrors(err)...) } } + e.Logger.Infow("Loading RMNHome config and v1.6 active chains") + v16ActiveChains, err := homeChainState.V16ActiveChainSelectors(e.GetContext()) + if err != nil { + chainErrs[homeChain] = append(chainErrs[homeChain], fmt.Errorf("failed to get v1.6 active chain selectors: %w", err)) + return chainErrs + } rmnHomeActiveDigest, err := homeChainState.RMNHome.GetActiveDigest(&bind.CallOpts{ Context: e.GetContext(), }) if err != nil { - return fmt.Errorf("failed to get active digest for RMNHome %s at home chain %d: %w", homeChainState.RMNHome.Address().Hex(), homeChain, err) + chainErrs[homeChain] = append(chainErrs[homeChain], fmt.Errorf("failed to get active digest for RMNHome %s at home chain %d: %w", homeChainState.RMNHome.Address().Hex(), homeChain, err)) } isRMNEnabledInRMNHomeBySourceChain := make(map[uint64]bool) rmnHomeConfig, err := homeChainState.RMNHome.GetConfig(&bind.CallOpts{ Context: e.GetContext(), }, rmnHomeActiveDigest) if err != nil { - return fmt.Errorf("failed to get config for RMNHome %s at home chain %d: %w", homeChainState.RMNHome.Address().Hex(), homeChain, err) + chainErrs[homeChain] = append(chainErrs[homeChain], fmt.Errorf("failed to get config for RMNHome %s at home chain %d: %w", homeChainState.RMNHome.Address().Hex(), homeChain, err)) } // if Fobserve is greater than 0, RMN is enabled for the source chain in RMNHome for _, rmnHomeChain := range rmnHomeConfig.VersionedConfig.DynamicConfig.SourceChains { isRMNEnabledInRMNHomeBySourceChain[rmnHomeChain.ChainSelector] = rmnHomeChain.FObserve > 0 } - for _, selector := range c.EVMChains() { - chainState := c.MustGetEVMChainState(selector) - isRMNEnabledInRmnRemote, err := chainState.ValidateRMNRemote(e, selector, rmnHomeActiveDigest) - if err != nil { - allErrs = append(allErrs, fmt.Errorf("failed to validate RMNRemote %s for chain %d: %w", safeAddr(chainState.RMNRemote), selector, err)) - } else if isRMNEnabledInRmnRemote != isRMNEnabledInRMNHomeBySourceChain[selector] { - // check whether RMNRemote and RMNHome are in sync in terms of RMNEnabled - allErrs = append(allErrs, fmt.Errorf("RMNRemote %s rmnEnabled mismatch with RMNHome for chain %d: expected %v, got %v", - chainState.RMNRemote.Address().Hex(), selector, isRMNEnabledInRMNHomeBySourceChain[selector], isRMNEnabledInRmnRemote)) - } - otherOnRamps := make(map[uint64]common.Address) - useTestRouter := true - if chainState.Router != nil { - useTestRouter = false - } - connectedChains, err := chainState.ValidateRouter(e, useTestRouter) - if err != nil { - allErrs = append(allErrs, fmt.Errorf("failed to validate router for chain %d: %w", selector, err)) + var chainsToLoop []uint64 + for _, sel := range c.EVMChains() { + if v16ActiveChains[sel] && (chainsToValidate == nil || chainsToValidate[sel]) { + chainsToLoop = append(chainsToLoop, sel) } - if len(connectedChains) > 0 { - for _, connectedChain := range connectedChains { - if connectedChain == selector { - continue + } + chainsToProcess := len(chainsToLoop) + e.Logger.Infow("Validating chain contracts in parallel", "chainsToProcess", chainsToProcess) + var chainErrsMu sync.Mutex + var wg sync.WaitGroup + sem := make(chan struct{}, 10) // max 10 chains validated concurrently + for _, selector := range chainsToLoop { + sel := selector + wg.Add(1) + sem <- struct{}{} + go func() { + defer wg.Done() + defer func() { <-sem }() + e.Logger.Infow("Validating chain contracts", "chain", sel) + chainState := c.MustGetEVMChainState(sel) + var errs []error + isRMNEnabledInRmnRemote, err := chainState.ValidateRMNRemote(e, sel, rmnHomeActiveDigest) + if err != nil { + errs = append(errs, fmt.Errorf("RMNRemote %s: %w", safeAddr(chainState.RMNRemote), err)) + } else if isRMNEnabledInRmnRemote != isRMNEnabledInRMNHomeBySourceChain[sel] { + errs = append(errs, fmt.Errorf("RMNRemote %s rmnEnabled mismatch with RMNHome: expected %v, got %v", + chainState.RMNRemote.Address().Hex(), isRMNEnabledInRMNHomeBySourceChain[sel], isRMNEnabledInRmnRemote)) + } + var fqV2 *fqv2ops.FeeQuoterContract + if ds, dsErr := ccipshared.PopulateDataStore(e.ExistingAddresses); dsErr == nil { + if e.DataStore != nil { + _ = ds.Merge(e.DataStore) + } + chainAddresses := ds.Addresses().Filter(datastore.AddressRefByChainSelector(sel)) + if fqAddr, fqVer, fqErr := ccipshared.ResolveFeeQuoterAddressAndVersion(chainAddresses, sel); fqErr == nil && fqVer.Major() >= 2 { + if evmChain, ok := e.BlockChains.EVMChains()[sel]; ok { + if v2, bindErr := fqv2ops.NewFeeQuoterContract(fqAddr, evmChain.Client); bindErr == nil { + fqV2 = v2 + } + } } - otherOnRamps[connectedChain] = c.MustGetEVMChainState(connectedChain).OnRamp.Address() } - if err := chainState.ValidateOffRamp(e, selector, otherOnRamps, isRMNEnabledInRMNHomeBySourceChain); err != nil { - allErrs = append(allErrs, fmt.Errorf("failed to validate offramp %s for chain %d: %w", safeAddr(chainState.OffRamp), selector, err)) + var fqV2Addr common.Address + if fqV2 != nil { + fqV2Addr = fqV2.Address() } - if err := chainState.ValidateOnRamp(e, selector, connectedChains); err != nil { - allErrs = append(allErrs, fmt.Errorf("failed to validate onramp %s for chain %d: %w", safeAddr(chainState.OnRamp), selector, err)) + otherOnRamps := make(map[uint64]common.Address) + useTestRouter := true + if chainState.Router != nil { + useTestRouter = false } - if err := chainState.ValidateNonceManager(e, selector, connectedChains); err != nil { - allErrs = append(allErrs, fmt.Errorf("failed to validate nonce manager for chain %d: %w", selector, err)) + connectedChains, routerErr := chainState.ValidateRouter(e, useTestRouter, v16ActiveChains) + if routerErr != nil { + errs = append(errs, fmt.Errorf("router: %w", routerErr)) } - } - if err := chainState.ValidateFeeQuoter(e, selector, connectedChains); err != nil { - allErrs = append(allErrs, fmt.Errorf("failed to validate fee quoter %s for chain %d: %w", safeAddr(chainState.FeeQuoter), selector, err)) - } - // Validate contract ownership: all contracts should be owned by the MCMS Timelock - if validateOwnership { - if chainState.Timelock == nil { - allErrs = append(allErrs, fmt.Errorf("failed to validate contract ownership for chain %d: timelock not configured", selector)) - } else if err := chainState.ValidateContractOwnership(e); err != nil { - allErrs = append(allErrs, fmt.Errorf("failed to validate contract ownership for chain %d: %w", selector, err)) + if len(connectedChains) > 0 { + for _, connectedChain := range connectedChains { + if connectedChain == sel { + continue + } + if addr, ok := c.resolveOnRampAddress(e, connectedChain); ok { + otherOnRamps[connectedChain] = addr + } + } + if err := chainState.ValidateOffRamp(e, sel, otherOnRamps, isRMNEnabledInRMNHomeBySourceChain, fqV2Addr); err != nil { + errs = append(errs, fmt.Errorf("offramp %s: %w", safeAddr(chainState.OffRamp), err)) + } + if err := chainState.ValidateOnRamp(e, sel, connectedChains, fqV2Addr); err != nil { + errs = append(errs, fmt.Errorf("onramp %s: %w", safeAddr(chainState.OnRamp), err)) + } + if err := chainState.ValidateNonceManager(e, sel, connectedChains); err != nil { + errs = append(errs, fmt.Errorf("nonce manager: %w", err)) + } } - } - // Validate RMNProxy points to RMNRemote - if err := chainState.ValidateRMNProxy(e); err != nil { - allErrs = append(allErrs, fmt.Errorf("failed to validate RMNProxy for chain %d: %w", selector, err)) - } + if err := chainState.ValidateFeeQuoter(e, sel, connectedChains, fqV2); err != nil { + errs = append(errs, fmt.Errorf("fee quoter %s: %w", safeAddr(chainState.FeeQuoter), err)) + } + if fqV2 != nil { + if evmChain, ok := e.BlockChains.EVMChains()[sel]; ok { + if err := chainState.ValidateFeeQuoterV2(e, sel, connectedChains, fqV2, evmChain.Client); err != nil { + errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s: %w", fqV2.Address().Hex(), err)) + } + } + } + if validateOwnership { + if chainState.Timelock == nil { + errs = append(errs, errors.New("ownership: timelock not configured")) + } else if err := chainState.ValidateContractOwnership(e); err != nil { + errs = append(errs, fmt.Errorf("ownership: %w", err)) + } + } + if err := chainState.ValidateRMNProxy(e); err != nil { + errs = append(errs, fmt.Errorf("RMNProxy: %w", err)) + } + if len(errs) > 0 { + chainErrsMu.Lock() + chainErrs[sel] = append(chainErrs[sel], errs...) + chainErrsMu.Unlock() + } + }() } - return errors.Join(allErrs...) + wg.Wait() + errCount := 0 + for _, errs := range chainErrs { + errCount += len(errs) + } + e.Logger.Infow("Post-deployment validation complete", "chainsValidated", chainsToProcess, "totalErrors", errCount) + return chainErrs } -// safeAddr returns the hex address of a contract if non-nil, or "" otherwise. +// safeAddr returns the hex address of a contract, or "" if nil. func safeAddr(c interface{ Address() common.Address }) string { - if c == nil { + if c == nil || reflect.ValueOf(c).IsNil() { return "" } return c.Address().Hex() } +// unwrapErrors splits a multi-error into individual errors. +func unwrapErrors(err error) []error { + if joined, ok := err.(interface{ Unwrap() []error }); ok { + return joined.Unwrap() + } + return []error{err} +} + // HomeChainSelector returns the selector of the home chain based on the presence of RMNHome, CapabilityRegistry and CCIPHome contracts. func (c CCIPOnChainState) HomeChainSelector() (uint64, error) { for _, selector := range c.EVMChains() { From 6ce952d6699e5bcb6406f12351a713c37b8308d5 Mon Sep 17 00:00:00 2001 From: simsonraj Date: Tue, 24 Mar 2026 00:00:01 +0530 Subject: [PATCH 04/13] fixes --- deployment/ccip/shared/stateview/evm/state.go | 101 ++--- .../ccip/shared/stateview/evm/validate.go | 375 +++++++----------- deployment/ccip/shared/stateview/state.go | 2 + 3 files changed, 193 insertions(+), 285 deletions(-) diff --git a/deployment/ccip/shared/stateview/evm/state.go b/deployment/ccip/shared/stateview/evm/state.go index 2857d7e6dc2..b4d29a39496 100644 --- a/deployment/ccip/shared/stateview/evm/state.go +++ b/deployment/ccip/shared/stateview/evm/state.go @@ -212,7 +212,8 @@ func (c CCIPChainState) ValidateHomeChain(e cldf.Environment, nodes deployment.N return errors.New("no CCIP Dons found in capability registry") } - // 2. HomeChain: build DON→chain mapping, validate P2P IDs + // 2. HomeChain: build DON→chain mapping, validate P2P IDs, and validate OCR3 configs. + // Configs are fetched once per DON and reused for both chain mapping and OCR3 validation. donIDByChainSel := make(map[uint64]uint32, len(ccipDons)) var allErrs []error for _, don := range ccipDons { @@ -232,19 +233,19 @@ func (c CCIPChainState) ValidateHomeChain(e cldf.Environment, nodes deployment.N continue } - chainSel := commitConfigs.ActiveConfig.Config.ChainSelector - if chainSel == 0 { - chainSel = commitConfigs.CandidateConfig.Config.ChainSelector - } - if chainSel == 0 { - chainSel = execConfigs.ActiveConfig.Config.ChainSelector - if chainSel == 0 { - chainSel = execConfigs.CandidateConfig.Config.ChainSelector - } - } + // Build DON→chain mapping from configs. + chainSel := chainSelFromConfigs(commitConfigs, execConfigs) if chainSel != 0 { donIDByChainSel[chainSel] = don.Id } + + // OCR3 config validation — reuse the configs already fetched above. + if err := c.validateCCIPHomeVersionedActiveConfig(e, nodes, commitConfigs.ActiveConfig, offRampsByChain); err != nil { + allErrs = append(allErrs, fmt.Errorf("DON %d: active commit config validation failed: %w", don.Id, err)) + } + if err := c.validateCCIPHomeVersionedActiveConfig(e, nodes, execConfigs.ActiveConfig, offRampsByChain); err != nil { + allErrs = append(allErrs, fmt.Errorf("DON %d: active exec config validation failed: %w", don.Id, err)) + } } // 3: Per-chain validation — only validate chains that have an active DON in CCIPHome. @@ -284,26 +285,6 @@ func (c CCIPChainState) ValidateHomeChain(e cldf.Environment, nodes deployment.N } } - // 4: OCR3 config validation - for _, don := range ccipDons { - commitConfigs, err := c.CCIPHome.GetAllConfigs(callOpts, don.Id, uint8(types.PluginTypeCCIPCommit)) - if err != nil { - // Already reported in 2 - continue - } - if err := c.validateCCIPHomeVersionedActiveConfig(e, nodes, commitConfigs.ActiveConfig, offRampsByChain); err != nil { - allErrs = append(allErrs, fmt.Errorf("DON %d: active commit config validation failed: %w", don.Id, err)) - } - - execConfigs, err := c.CCIPHome.GetAllConfigs(callOpts, don.Id, uint8(types.PluginTypeCCIPExec)) - if err != nil { - continue - } - if err := c.validateCCIPHomeVersionedActiveConfig(e, nodes, execConfigs.ActiveConfig, offRampsByChain); err != nil { - allErrs = append(allErrs, fmt.Errorf("DON %d: active exec config validation failed: %w", don.Id, err)) - } - } - return errors.Join(allErrs...) } @@ -455,17 +436,20 @@ func (c CCIPChainState) ValidateOnRamp( return fmt.Errorf("onRamp %s feeQuoter mismatch in dynamic config: expected %s, got %s", c.OnRamp.Address().Hex(), expected, dynamicCfg.FeeQuoter.Hex()) } - // if the fee aggregator is set, it should match the one in the dynamic config - // otherwise the fee aggregator should be the timelock address + // if the fee aggregator is explicitly set, it should match the one in the dynamic config + // otherwise the fee aggregator should be the timelock address (production) or deployer key (test) if c.FeeAggregator != (common.Address{}) { if c.FeeAggregator != dynamicCfg.FeeAggregator { return fmt.Errorf("onRamp %s feeAggregator mismatch in dynamic config: expected %s, got %s", c.OnRamp.Address().Hex(), c.FeeAggregator.Hex(), dynamicCfg.FeeAggregator.Hex()) } } else { - if dynamicCfg.FeeAggregator != e.BlockChains.EVMChains()[selector].DeployerKey.From { - return fmt.Errorf("onRamp %s feeAggregator mismatch in dynamic config: expected deployer key %s, got %s", - c.OnRamp.Address().Hex(), e.BlockChains.EVMChains()[selector].DeployerKey.From.Hex(), dynamicCfg.FeeAggregator.Hex()) + if c.Timelock == nil { + return errors.New("no Timelock contract found in the state for fee aggregator validation") + } + if dynamicCfg.FeeAggregator != c.Timelock.Address() { + return fmt.Errorf("onRamp %s feeAggregator mismatch in dynamic config: expected Timelock %s, got %s", + c.OnRamp.Address().Hex(), c.Timelock.Address().Hex(), dynamicCfg.FeeAggregator.Hex()) } } @@ -490,6 +474,22 @@ func (c CCIPChainState) ValidateOnRamp( return nil } +// chainSelFromConfigs extracts the chain selector from CCIPHome configs, +// falling back through active→candidate for both commit and exec. +func chainSelFromConfigs(commit, exec ccip_home.GetAllConfigs) uint64 { + sel := commit.ActiveConfig.Config.ChainSelector + if sel == 0 { + sel = commit.CandidateConfig.Config.ChainSelector + } + if sel == 0 { + sel = exec.ActiveConfig.Config.ChainSelector + if sel == 0 { + sel = exec.CandidateConfig.Config.ChainSelector + } + } + return sel +} + // V16ActiveChainSelectors returns chain selectors with an active or candidate // v1.6 DON config in CCIPHome. Home chain only. func (c CCIPChainState) V16ActiveChainSelectors(ctx context.Context) (map[uint64]bool, error) { @@ -514,17 +514,7 @@ func (c CCIPChainState) V16ActiveChainSelectors(ctx context.Context) (map[uint64 if err != nil { continue } - chainSel := commitConfigs.ActiveConfig.Config.ChainSelector - if chainSel == 0 { - chainSel = commitConfigs.CandidateConfig.Config.ChainSelector - } - if chainSel == 0 { - chainSel = execConfigs.ActiveConfig.Config.ChainSelector - if chainSel == 0 { - chainSel = execConfigs.CandidateConfig.Config.ChainSelector - } - } - if chainSel != 0 { + if chainSel := chainSelFromConfigs(commitConfigs, execConfigs); chainSel != 0 { active[chainSel] = true } } @@ -544,9 +534,8 @@ func (c CCIPChainState) ValidateRouter(e cldf.Environment, isTestRouter bool, v1 if isTestRouter { routerC = c.TestRouter } - armProxy, err := routerC.GetArmProxy(&bind.CallOpts{ - Context: e.GetContext(), - }) + callOpts := &bind.CallOpts{Context: e.GetContext()} + armProxy, err := routerC.GetArmProxy(callOpts) if err != nil { return nil, fmt.Errorf("failed to get armProxy from router : %w", err) } @@ -554,9 +543,7 @@ func (c CCIPChainState) ValidateRouter(e cldf.Environment, isTestRouter bool, v1 return nil, fmt.Errorf("armProxy %s mismatch in router %s: expected %s, got %s", armProxy.Hex(), routerC.Address().Hex(), c.RMNProxy.Address().Hex(), armProxy) } - native, err := routerC.GetWrappedNative(&bind.CallOpts{ - Context: e.GetContext(), - }) + native, err := routerC.GetWrappedNative(callOpts) if err != nil { return nil, fmt.Errorf("failed to get wrapped native from router %s: %w", routerC.Address().Hex(), err) } @@ -566,9 +553,7 @@ func (c CCIPChainState) ValidateRouter(e cldf.Environment, isTestRouter bool, v1 } allConnectedChains := make([]uint64, 0) // get offRamps - offRampDetails, err := routerC.GetOffRamps(&bind.CallOpts{ - Context: context.Background(), - }) + offRampDetails, err := routerC.GetOffRamps(callOpts) if err != nil { return nil, fmt.Errorf("failed to get offRamps from router %s: %w", routerC.Address().Hex(), err) } @@ -586,9 +571,7 @@ func (c CCIPChainState) ValidateRouter(e cldf.Environment, isTestRouter bool, v1 } v16ConnectedChains := make([]uint64, 0, len(allConnectedChains)) for _, dest := range allConnectedChains { - onRamp, err := routerC.GetOnRamp(&bind.CallOpts{ - Context: context.Background(), - }, dest) + onRamp, err := routerC.GetOnRamp(callOpts, dest) if err != nil { return nil, fmt.Errorf("failed to get onRamp for dest %d from router %s: %w", dest, routerC.Address().Hex(), err) } diff --git a/deployment/ccip/shared/stateview/evm/validate.go b/deployment/ccip/shared/stateview/evm/validate.go index 36bad1759e7..b64ce94a0d0 100644 --- a/deployment/ccip/shared/stateview/evm/validate.go +++ b/deployment/ccip/shared/stateview/evm/validate.go @@ -169,8 +169,6 @@ func (c CCIPChainState) ValidateFeeQuoter( if err := c.validateTokenTransferFeeConfigs(e, callOpts, fqAddr, connectedChains, fqV2); err != nil { errs = append(errs, err) } - case 2: - // TODO: implement FeeQuoter 2.0 lane-level validation default: errs = append(errs, fmt.Errorf("FeeQuoter %s: unsupported version %s for lane-level validation", fqAddr, c.FeeQuoterVersion.String())) @@ -229,20 +227,16 @@ func (c CCIPChainState) validateDestChainConfigs( if destCfg.GasPriceStalenessThreshold == 0 { errs = append(errs, fmt.Errorf("FeeQuoter %s GasPriceStalenessThreshold is 0 for dest chain %d", fqAddr, destChainSel)) } - for _, chk := range []struct { - name string - got uint64 - expected uint64 - }{ - {"DestGasPerPayloadByteHigh", uint64(destCfg.DestGasPerPayloadByteHigh), uint64(ccipevm.CalldataGasPerByteHigh)}, - {"DestGasPerPayloadByteThreshold", uint64(destCfg.DestGasPerPayloadByteThreshold), uint64(ccipevm.CalldataGasPerByteThreshold)}, - {"DefaultTxGasLimit", uint64(destCfg.DefaultTxGasLimit), 200_000}, - {"NetworkFeeUSDCents", uint64(destCfg.NetworkFeeUSDCents), uint64(expectedNetworkFeeUSDCents(sourceChainSel, destChainSel))}, - } { - if chk.got != chk.expected { - errs = append(errs, fmt.Errorf("FeeQuoter %s %s mismatch for dest chain %d: expected %d, got %d", - fqAddr, chk.name, destChainSel, chk.expected, chk.got)) - } + if err := compareFieldChecks( + fmt.Sprintf("FeeQuoter %s dest chain %d", fqAddr, destChainSel), + []fieldCheck{ + {"DestGasPerPayloadByteHigh", uint64(destCfg.DestGasPerPayloadByteHigh), uint64(ccipevm.CalldataGasPerByteHigh)}, + {"DestGasPerPayloadByteThreshold", uint64(destCfg.DestGasPerPayloadByteThreshold), uint64(ccipevm.CalldataGasPerByteThreshold)}, + {"DefaultTxGasLimit", uint64(destCfg.DefaultTxGasLimit), uint64(200_000)}, + {"NetworkFeeUSDCents", uint64(destCfg.NetworkFeeUSDCents), uint64(expectedNetworkFeeUSDCents(sourceChainSel, destChainSel))}, + }, + ); err != nil { + errs = append(errs, err) } destFamily, _ := chain_selectors.GetSelectorFamily(destChainSel) @@ -255,6 +249,33 @@ func (c CCIPChainState) validateDestChainConfigs( return errors.Join(errs...) } +// validateFeeTokenSuperset checks that feeTokens is a superset of v1.5 PriceRegistry fee tokens. +func (c CCIPChainState) validateFeeTokenSuperset( + callOpts *bind.CallOpts, + fqAddr string, + feeTokens []common.Address, +) error { + if c.PriceRegistry == nil { + return nil + } + legacyFeeTokens, err := c.PriceRegistry.GetFeeTokens(callOpts) + if err != nil { + return fmt.Errorf("failed to get fee tokens from v1.5 PriceRegistry: %w", err) + } + feeTokenSet := make(map[common.Address]bool, len(feeTokens)) + for _, ft := range feeTokens { + feeTokenSet[ft] = true + } + var errs []error + for _, legacyFT := range legacyFeeTokens { + if !feeTokenSet[legacyFT] { + errs = append(errs, fmt.Errorf("FeeQuoter %s missing fee token %s from v1.5 PriceRegistry", + fqAddr, legacyFT.Hex())) + } + } + return errors.Join(errs...) +} + // validateFeeTokenConfigs checks fee token presence, v1.5 PriceRegistry superset, and premium multipliers func (c CCIPChainState) validateFeeTokenConfigs( callOpts *bind.CallOpts, @@ -266,22 +287,8 @@ func (c CCIPChainState) validateFeeTokenConfigs( if len(feeTokens) == 0 { errs = append(errs, fmt.Errorf("FeeQuoter %s has no fee tokens configured", fqAddr)) } - if c.PriceRegistry != nil { - legacyFeeTokens, err := c.PriceRegistry.GetFeeTokens(callOpts) - if err != nil { - errs = append(errs, fmt.Errorf("failed to get fee tokens from v1.5 PriceRegistry: %w", err)) - } else { - feeTokenSet := make(map[common.Address]bool, len(feeTokens)) - for _, ft := range feeTokens { - feeTokenSet[ft] = true - } - for _, legacyFT := range legacyFeeTokens { - if !feeTokenSet[legacyFT] { - errs = append(errs, fmt.Errorf("FeeQuoter %s missing fee token %s from v1.5 PriceRegistry", - fqAddr, legacyFT.Hex())) - } - } - } + if err := c.validateFeeTokenSuperset(callOpts, fqAddr, feeTokens); err != nil { + errs = append(errs, err) } var anyLegacyOnRamp *evm_2_evm_onramp.EVM2EVMOnRamp @@ -461,25 +468,30 @@ func (c CCIPChainState) validateTokenTransferFeeConfigs( return errors.Join(errs...) } -// validateFeeQuoterAgainstLegacyOnRamp cross-checks v1.6 dest chain config against v1.5 OnRamp DynamicConfig -func (c CCIPChainState) validateFeeQuoterAgainstLegacyOnRamp( - callOpts *bind.CallOpts, - fqAddr string, - destChainSel uint64, - destCfg fee_quoter.FeeQuoterDestChainConfig, - legacyOnRamp *evm_2_evm_onramp.EVM2EVMOnRamp, -) error { - legacyCfg, err := legacyOnRamp.GetDynamicConfig(callOpts) - if err != nil { - return fmt.Errorf("failed to get v1.5 OnRamp dynamic config for dest chain %d: %w", destChainSel, err) - } +// fieldCheck is a named expected-vs-actual pair used for table-driven validation. +type fieldCheck struct { + name string + got any + want any +} + +// compareFieldChecks runs table-driven comparison and returns any mismatches. +func compareFieldChecks(prefix string, checks []fieldCheck) error { var errs []error + for _, chk := range checks { + if chk.got != chk.want { + errs = append(errs, fmt.Errorf("%s %s mismatch: got=%v, want=%v", prefix, chk.name, chk.got, chk.want)) + } + } + return errors.Join(errs...) +} - for _, chk := range []struct { - name string - v16 any - v15 any - }{ +// v16DestCfgLegacyChecks builds the field comparison table for v1.6 FeeQuoter dest config vs v1.5 OnRamp. +func v16DestCfgLegacyChecks( + destCfg fee_quoter.FeeQuoterDestChainConfig, + legacyCfg evm_2_evm_onramp.EVM2EVMOnRampDynamicConfig, +) []fieldCheck { + return []fieldCheck{ {"MaxNumberOfTokensPerMsg", uint64(destCfg.MaxNumberOfTokensPerMsg), uint64(legacyCfg.MaxNumberOfTokensPerMsg)}, {"DestGasOverhead", uint64(destCfg.DestGasOverhead), uint64(legacyCfg.DestGasOverhead)}, {"DestDataAvailabilityOverheadGas", uint64(destCfg.DestDataAvailabilityOverheadGas), uint64(legacyCfg.DestDataAvailabilityOverheadGas)}, @@ -491,14 +503,41 @@ func (c CCIPChainState) validateFeeQuoterAgainstLegacyOnRamp( {"DefaultTokenFeeUSDCents", uint64(destCfg.DefaultTokenFeeUSDCents), uint64(legacyCfg.DefaultTokenFeeUSDCents)}, {"EnforceOutOfOrder", destCfg.EnforceOutOfOrder, legacyCfg.EnforceOutOfOrder}, {"DestGasPerPayloadByteBase", uint64(destCfg.DestGasPerPayloadByteBase), uint64(uint8(legacyCfg.DestGasPerPayloadByte))}, //nolint:gosec // G115: intentional v1.5 uint16→uint8 truncation during migration - } { - if chk.v16 != chk.v15 { - errs = append(errs, fmt.Errorf("FeeQuoter %s %s mismatch for dest chain %d: v1.6=%v, v1.5=%v", - fqAddr, chk.name, destChainSel, chk.v16, chk.v15)) - } } +} - return errors.Join(errs...) +// v20DestCfgLegacyChecks builds the field comparison table for v2.0 FeeQuoter dest config vs v1.5 OnRamp. +// v2.0 DestChainConfig has fewer fields than v1.6; only the shared subset is compared. +func v20DestCfgLegacyChecks( + destCfg fqv2ops.DestChainConfig, + legacyCfg evm_2_evm_onramp.EVM2EVMOnRampDynamicConfig, +) []fieldCheck { + return []fieldCheck{ + {"DestGasOverhead", uint64(destCfg.DestGasOverhead), uint64(legacyCfg.DestGasOverhead)}, + {"MaxDataBytes", uint64(destCfg.MaxDataBytes), uint64(legacyCfg.MaxDataBytes)}, + {"MaxPerMsgGasLimit", uint64(destCfg.MaxPerMsgGasLimit), uint64(legacyCfg.MaxPerMsgGasLimit)}, + {"DefaultTokenDestGasOverhead", uint64(destCfg.DefaultTokenDestGasOverhead), uint64(legacyCfg.DefaultTokenDestGasOverhead)}, + {"DefaultTokenFeeUSDCents", uint64(destCfg.DefaultTokenFeeUSDCents), uint64(legacyCfg.DefaultTokenFeeUSDCents)}, + {"DestGasPerPayloadByteBase", uint64(destCfg.DestGasPerPayloadByteBase), uint64(uint8(legacyCfg.DestGasPerPayloadByte))}, //nolint:gosec // G115: intentional v1.5 uint16→uint8 truncation during migration + } +} + +// validateFeeQuoterAgainstLegacyOnRamp cross-checks v1.6 dest chain config against v1.5 OnRamp DynamicConfig +func (c CCIPChainState) validateFeeQuoterAgainstLegacyOnRamp( + callOpts *bind.CallOpts, + fqAddr string, + destChainSel uint64, + destCfg fee_quoter.FeeQuoterDestChainConfig, + legacyOnRamp *evm_2_evm_onramp.EVM2EVMOnRamp, +) error { + legacyCfg, err := legacyOnRamp.GetDynamicConfig(callOpts) + if err != nil { + return fmt.Errorf("failed to get v1.5 OnRamp dynamic config for dest chain %d: %w", destChainSel, err) + } + return compareFieldChecks( + fmt.Sprintf("FeeQuoter v1.6 %s dest chain %d", fqAddr, destChainSel), + v16DestCfgLegacyChecks(destCfg, legacyCfg), + ) } // validateFeeQuoterDestCfgDefaults checks dest config against known defaults. @@ -508,32 +547,22 @@ func validateFeeQuoterDestCfgDefaults( destChainSel uint64, destCfg fee_quoter.FeeQuoterDestChainConfig, ) error { - var errs []error - - for _, chk := range []struct { - name string - got uint64 - expected uint64 - }{ - {"MaxNumberOfTokensPerMsg", uint64(destCfg.MaxNumberOfTokensPerMsg), 10}, - {"MaxDataBytes", uint64(destCfg.MaxDataBytes), 30_000}, - {"MaxPerMsgGasLimit", uint64(destCfg.MaxPerMsgGasLimit), 3_000_000}, - {"DestGasOverhead", uint64(destCfg.DestGasOverhead), uint64(ccipevm.DestGasOverhead)}, - {"DestGasPerPayloadByteBase", uint64(destCfg.DestGasPerPayloadByteBase), uint64(ccipevm.CalldataGasPerByteBase)}, - {"DefaultTokenDestGasOverhead", uint64(destCfg.DefaultTokenDestGasOverhead), 90_000}, - {"DestDataAvailabilityOverheadGas", uint64(destCfg.DestDataAvailabilityOverheadGas), 100}, - {"DestGasPerDataAvailabilityByte", uint64(destCfg.DestGasPerDataAvailabilityByte), 16}, - {"DestDataAvailabilityMultiplierBps", uint64(destCfg.DestDataAvailabilityMultiplierBps), 1}, - {"GasMultiplierWeiPerEth", destCfg.GasMultiplierWeiPerEth, uint64(11e17)}, - {"DefaultTokenFeeUSDCents", uint64(destCfg.DefaultTokenFeeUSDCents), uint64(expectedDefaultTokenFeeUSDCents(sourceChainSel, destChainSel))}, - } { - if chk.got != chk.expected { - errs = append(errs, fmt.Errorf("FeeQuoter %s %s mismatch for dest chain %d: expected %d, got %d", - fqAddr, chk.name, destChainSel, chk.expected, chk.got)) - } - } - - return errors.Join(errs...) + return compareFieldChecks( + fmt.Sprintf("FeeQuoter %s defaults dest chain %d", fqAddr, destChainSel), + []fieldCheck{ + {"MaxNumberOfTokensPerMsg", uint64(destCfg.MaxNumberOfTokensPerMsg), uint64(10)}, + {"MaxDataBytes", uint64(destCfg.MaxDataBytes), uint64(30_000)}, + {"MaxPerMsgGasLimit", uint64(destCfg.MaxPerMsgGasLimit), uint64(3_000_000)}, + {"DestGasOverhead", uint64(destCfg.DestGasOverhead), uint64(ccipevm.DestGasOverhead)}, + {"DestGasPerPayloadByteBase", uint64(destCfg.DestGasPerPayloadByteBase), uint64(ccipevm.CalldataGasPerByteBase)}, + {"DefaultTokenDestGasOverhead", uint64(destCfg.DefaultTokenDestGasOverhead), uint64(90_000)}, + {"DestDataAvailabilityOverheadGas", uint64(destCfg.DestDataAvailabilityOverheadGas), uint64(100)}, + {"DestGasPerDataAvailabilityByte", uint64(destCfg.DestGasPerDataAvailabilityByte), uint64(16)}, + {"DestDataAvailabilityMultiplierBps", uint64(destCfg.DestDataAvailabilityMultiplierBps), uint64(1)}, + {"GasMultiplierWeiPerEth", destCfg.GasMultiplierWeiPerEth, uint64(11e17)}, + {"DefaultTokenFeeUSDCents", uint64(destCfg.DefaultTokenFeeUSDCents), uint64(expectedDefaultTokenFeeUSDCents(sourceChainSel, destChainSel))}, + }, + ) } type ownableContract interface { @@ -614,83 +643,25 @@ func (c CCIPChainState) ValidateContractOwnership(e cldf.Environment) error { // — FeeQuoter v2.0 validation — -// normalizedDestChainConfig holds DestChainConfig fields shared between v1.6 and v2.0. -type normalizedDestChainConfig struct { - IsEnabled bool - MaxDataBytes uint64 - MaxPerMsgGasLimit uint64 - DestGasOverhead uint64 - DestGasPerPayloadByteBase uint64 - ChainFamilySelector [4]byte - DefaultTokenFeeUSDCents uint64 - DefaultTokenDestGasOverhead uint64 - DefaultTxGasLimit uint64 - NetworkFeeUSDCents uint64 -} - -func normalizeFromV16FQ(cfg fee_quoter.FeeQuoterDestChainConfig) normalizedDestChainConfig { - return normalizedDestChainConfig{ - IsEnabled: cfg.IsEnabled, - MaxDataBytes: uint64(cfg.MaxDataBytes), - MaxPerMsgGasLimit: uint64(cfg.MaxPerMsgGasLimit), - DestGasOverhead: uint64(cfg.DestGasOverhead), - DestGasPerPayloadByteBase: uint64(cfg.DestGasPerPayloadByteBase), - ChainFamilySelector: cfg.ChainFamilySelector, - DefaultTokenFeeUSDCents: uint64(cfg.DefaultTokenFeeUSDCents), - DefaultTokenDestGasOverhead: uint64(cfg.DefaultTokenDestGasOverhead), - DefaultTxGasLimit: uint64(cfg.DefaultTxGasLimit), - NetworkFeeUSDCents: uint64(cfg.NetworkFeeUSDCents), - } -} - -func normalizeFromV20FQ(cfg fqv2ops.DestChainConfig) normalizedDestChainConfig { - return normalizedDestChainConfig{ - IsEnabled: cfg.IsEnabled, - MaxDataBytes: uint64(cfg.MaxDataBytes), - MaxPerMsgGasLimit: uint64(cfg.MaxPerMsgGasLimit), - DestGasOverhead: uint64(cfg.DestGasOverhead), - DestGasPerPayloadByteBase: uint64(cfg.DestGasPerPayloadByteBase), - ChainFamilySelector: cfg.ChainFamilySelector, - DefaultTokenFeeUSDCents: uint64(cfg.DefaultTokenFeeUSDCents), - DefaultTokenDestGasOverhead: uint64(cfg.DefaultTokenDestGasOverhead), - DefaultTxGasLimit: uint64(cfg.DefaultTxGasLimit), - NetworkFeeUSDCents: uint64(cfg.NetworkFeeUSDCents), +// v16v20SharedFieldChecks builds the field comparison table for shared fields between v1.6 and v2.0 FeeQuoter dest configs. +func v16v20SharedFieldChecks( + v16 fee_quoter.FeeQuoterDestChainConfig, + v20 fqv2ops.DestChainConfig, +) []fieldCheck { + return []fieldCheck{ + {"IsEnabled", v16.IsEnabled, v20.IsEnabled}, + {"MaxDataBytes", uint64(v16.MaxDataBytes), uint64(v20.MaxDataBytes)}, + {"MaxPerMsgGasLimit", uint64(v16.MaxPerMsgGasLimit), uint64(v20.MaxPerMsgGasLimit)}, + {"DestGasOverhead", uint64(v16.DestGasOverhead), uint64(v20.DestGasOverhead)}, + {"DestGasPerPayloadByteBase", uint64(v16.DestGasPerPayloadByteBase), uint64(v20.DestGasPerPayloadByteBase)}, + {"ChainFamilySelector", v16.ChainFamilySelector, v20.ChainFamilySelector}, + {"DefaultTokenFeeUSDCents", uint64(v16.DefaultTokenFeeUSDCents), uint64(v20.DefaultTokenFeeUSDCents)}, + {"DefaultTokenDestGasOverhead", uint64(v16.DefaultTokenDestGasOverhead), uint64(v20.DefaultTokenDestGasOverhead)}, + {"DefaultTxGasLimit", uint64(v16.DefaultTxGasLimit), uint64(v20.DefaultTxGasLimit)}, + {"NetworkFeeUSDCents", uint64(v16.NetworkFeeUSDCents), uint64(v20.NetworkFeeUSDCents)}, } } -// compareNormalizedDestConfigs errors on any field mismatch between a and b. -func compareNormalizedDestConfigs(fqAddr string, destChainSel uint64, label string, a, b normalizedDestChainConfig) error { - var errs []error - if a.IsEnabled != b.IsEnabled { - errs = append(errs, fmt.Errorf("FeeQuoter %s dest chain %d IsEnabled mismatch (%s): %v vs %v", - fqAddr, destChainSel, label, a.IsEnabled, b.IsEnabled)) - } - for _, chk := range []struct { - name string - aVal uint64 - bVal uint64 - }{ - {"MaxDataBytes", a.MaxDataBytes, b.MaxDataBytes}, - {"MaxPerMsgGasLimit", a.MaxPerMsgGasLimit, b.MaxPerMsgGasLimit}, - {"DestGasOverhead", a.DestGasOverhead, b.DestGasOverhead}, - {"DestGasPerPayloadByteBase", a.DestGasPerPayloadByteBase, b.DestGasPerPayloadByteBase}, - {"DefaultTokenFeeUSDCents", a.DefaultTokenFeeUSDCents, b.DefaultTokenFeeUSDCents}, - {"DefaultTokenDestGasOverhead", a.DefaultTokenDestGasOverhead, b.DefaultTokenDestGasOverhead}, - {"DefaultTxGasLimit", a.DefaultTxGasLimit, b.DefaultTxGasLimit}, - {"NetworkFeeUSDCents", a.NetworkFeeUSDCents, b.NetworkFeeUSDCents}, - } { - if chk.aVal != chk.bVal { - errs = append(errs, fmt.Errorf("FeeQuoter %s dest chain %d %s mismatch (%s): %d vs %d", - fqAddr, destChainSel, chk.name, label, chk.aVal, chk.bVal)) - } - } - if a.ChainFamilySelector != b.ChainFamilySelector { - errs = append(errs, fmt.Errorf("FeeQuoter %s dest chain %d ChainFamilySelector mismatch (%s): %x vs %x", - fqAddr, destChainSel, label, a.ChainFamilySelector, b.ChainFamilySelector)) - } - return errors.Join(errs...) -} - // getFeeTokensV2 calls getFeeTokens on a FeeQuoter 2.0 via raw ABI call // (the operations-gen wrapper doesn't expose GetFeeTokens). func getFeeTokensV2(callOpts *bind.CallOpts, backend bind.ContractBackend, addr common.Address) ([]common.Address, error) { @@ -777,23 +748,8 @@ func (c CCIPChainState) validateFeeTokenConfigsV20( if len(feeTokens) == 0 { errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s has no fee tokens configured", fqAddr)) } - - if c.PriceRegistry != nil { - legacyFeeTokens, err := c.PriceRegistry.GetFeeTokens(callOpts) - if err != nil { - errs = append(errs, fmt.Errorf("failed to get fee tokens from v1.5 PriceRegistry for v2.0 FQ check: %w", err)) - } else { - feeTokenSet := make(map[common.Address]bool, len(feeTokens)) - for _, ft := range feeTokens { - feeTokenSet[ft] = true - } - for _, legacyFT := range legacyFeeTokens { - if !feeTokenSet[legacyFT] { - errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s missing fee token %s from v1.5 PriceRegistry", - fqAddr, legacyFT.Hex())) - } - } - } + if err := c.validateFeeTokenSuperset(callOpts, fqAddr, feeTokens); err != nil { + errs = append(errs, err) } return errors.Join(errs...) @@ -822,37 +778,37 @@ func (c CCIPChainState) validateDestChainConfigsV20( } // v2.0-specific fields - if destCfgV2.LinkFeeMultiplierPercent != fqv2seq.LinkFeeMultiplierPercent { - errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s dest chain %d LinkFeeMultiplierPercent: expected %d, got %d", - fqAddr, destChainSel, fqv2seq.LinkFeeMultiplierPercent, destCfgV2.LinkFeeMultiplierPercent)) - } - expectedNetworkFee := uint16(expectedNetworkFeeUSDCents(sourceChainSel, destChainSel)) //nolint:gosec // G115: max value is 50, always fits uint16 - if destCfgV2.NetworkFeeUSDCents != expectedNetworkFee { - errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s dest chain %d NetworkFeeUSDCents: expected %d, got %d", - fqAddr, destChainSel, expectedNetworkFee, destCfgV2.NetworkFeeUSDCents)) - } - if destCfgV2.DefaultTxGasLimit != 200_000 { - errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s dest chain %d DefaultTxGasLimit: expected 200000, got %d", - fqAddr, destChainSel, destCfgV2.DefaultTxGasLimit)) + if err := compareFieldChecks( + fmt.Sprintf("FeeQuoter v2.0 %s dest chain %d", fqAddr, destChainSel), + []fieldCheck{ + {"LinkFeeMultiplierPercent", uint64(destCfgV2.LinkFeeMultiplierPercent), uint64(fqv2seq.LinkFeeMultiplierPercent)}, + {"NetworkFeeUSDCents", uint64(destCfgV2.NetworkFeeUSDCents), uint64(expectedNetworkFeeUSDCents(sourceChainSel, destChainSel))}, + {"DefaultTxGasLimit", uint64(destCfgV2.DefaultTxGasLimit), uint64(200_000)}, + }, + ); err != nil { + errs = append(errs, err) } - normV2 := normalizeFromV20FQ(destCfgV2) - _ = normV2 // used in v1.6↔v2.0 comparison below - if c.FeeQuoter != nil { destCfgV16, err := c.FeeQuoter.GetDestChainConfig(callOpts, destChainSel) if err != nil { errs = append(errs, fmt.Errorf("failed to get FeeQuoter v1.6 dest chain config for chain %d: %w", destChainSel, err)) - } else { - normV16 := normalizeFromV16FQ(destCfgV16) - if err := compareNormalizedDestConfigs(fqAddr, destChainSel, "v1.6↔v2.0", normV16, normV2); err != nil { - errs = append(errs, err) - } + } else if err := compareFieldChecks( + fmt.Sprintf("FeeQuoter %s dest chain %d v1.6↔v2.0", fqAddr, destChainSel), + v16v20SharedFieldChecks(destCfgV16, destCfgV2), + ); err != nil { + errs = append(errs, err) } } if legacyOnRamp := c.EVM2EVMOnRamp[destChainSel]; legacyOnRamp != nil { - if err := compareV15OnRampWithV20DestCfg(callOpts, fqAddr, destChainSel, destCfgV2, legacyOnRamp); err != nil { + legacyCfg, err := legacyOnRamp.GetDynamicConfig(callOpts) + if err != nil { + errs = append(errs, fmt.Errorf("failed to get v1.5 OnRamp dynamic config for dest chain %d (v2.0 cross-check): %w", destChainSel, err)) + } else if err := compareFieldChecks( + fmt.Sprintf("FeeQuoter v2.0 %s dest chain %d", fqAddr, destChainSel), + v20DestCfgLegacyChecks(destCfgV2, legacyCfg), + ); err != nil { errs = append(errs, err) } } @@ -861,39 +817,6 @@ func (c CCIPChainState) validateDestChainConfigsV20( return errors.Join(errs...) } -// compareV15OnRampWithV20DestCfg compares v1.5 OnRamp DynamicConfig with v2.0 DestChainConfig. -func compareV15OnRampWithV20DestCfg( - callOpts *bind.CallOpts, - fqAddr string, - destChainSel uint64, - destCfgV2 fqv2ops.DestChainConfig, - legacyOnRamp *evm_2_evm_onramp.EVM2EVMOnRamp, -) error { - legacyCfg, err := legacyOnRamp.GetDynamicConfig(callOpts) - if err != nil { - return fmt.Errorf("failed to get v1.5 OnRamp dynamic config for dest chain %d (v2.0 cross-check): %w", destChainSel, err) - } - var errs []error - for _, chk := range []struct { - name string - v20 any - v15 any - }{ - {"DestGasOverhead", uint64(destCfgV2.DestGasOverhead), uint64(legacyCfg.DestGasOverhead)}, - {"MaxDataBytes", uint64(destCfgV2.MaxDataBytes), uint64(legacyCfg.MaxDataBytes)}, - {"MaxPerMsgGasLimit", uint64(destCfgV2.MaxPerMsgGasLimit), uint64(legacyCfg.MaxPerMsgGasLimit)}, - {"DefaultTokenDestGasOverhead", uint64(destCfgV2.DefaultTokenDestGasOverhead), uint64(legacyCfg.DefaultTokenDestGasOverhead)}, - {"DefaultTokenFeeUSDCents", uint64(destCfgV2.DefaultTokenFeeUSDCents), uint64(legacyCfg.DefaultTokenFeeUSDCents)}, - {"DestGasPerPayloadByteBase", uint64(destCfgV2.DestGasPerPayloadByteBase), uint64(uint8(legacyCfg.DestGasPerPayloadByte))}, //nolint:gosec // G115: intentional v1.5 uint16→uint8 truncation during migration - } { - if chk.v20 != chk.v15 { - errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s %s mismatch for dest chain %d: v2.0=%v, v1.5=%v", - fqAddr, chk.name, destChainSel, chk.v20, chk.v15)) - } - } - return errors.Join(errs...) -} - // validateTokenTransferFeeConfigsV20 validates per-token per-dest fee configs for v2.0. func (c CCIPChainState) validateTokenTransferFeeConfigsV20( callOpts *bind.CallOpts, diff --git a/deployment/ccip/shared/stateview/state.go b/deployment/ccip/shared/stateview/state.go index 02c175208ac..f2eecbf5429 100644 --- a/deployment/ccip/shared/stateview/state.go +++ b/deployment/ccip/shared/stateview/state.go @@ -230,6 +230,7 @@ func (c CCIPOnChainState) runPostDeploymentValidation(e cldf.Environment, valida }) if err != nil { chainErrs[homeChain] = append(chainErrs[homeChain], fmt.Errorf("failed to get active digest for RMNHome %s at home chain %d: %w", homeChainState.RMNHome.Address().Hex(), homeChain, err)) + return chainErrs } isRMNEnabledInRMNHomeBySourceChain := make(map[uint64]bool) rmnHomeConfig, err := homeChainState.RMNHome.GetConfig(&bind.CallOpts{ @@ -237,6 +238,7 @@ func (c CCIPOnChainState) runPostDeploymentValidation(e cldf.Environment, valida }, rmnHomeActiveDigest) if err != nil { chainErrs[homeChain] = append(chainErrs[homeChain], fmt.Errorf("failed to get config for RMNHome %s at home chain %d: %w", homeChainState.RMNHome.Address().Hex(), homeChain, err)) + return chainErrs } // if Fobserve is greater than 0, RMN is enabled for the source chain in RMNHome for _, rmnHomeChain := range rmnHomeConfig.VersionedConfig.DynamicConfig.SourceChains { From b79532640f91d918e43024bb49f5d6a308be827d Mon Sep 17 00:00:00 2001 From: simsonraj Date: Tue, 24 Mar 2026 13:59:17 +0530 Subject: [PATCH 05/13] refactor --- .../ccip/changeset/v1_6/cs_chain_contracts.go | 46 +- .../ccip/operation/evm/v1_6/ops_fee_quoter.go | 55 + deployment/ccip/shared/stateview/evm/state.go | 67 +- .../ccip/shared/stateview/evm/validate.go | 1186 ++++++++--------- .../shared/stateview/evm/validate_test.go | 4 +- deployment/ccip/shared/stateview/state.go | 13 +- 6 files changed, 661 insertions(+), 710 deletions(-) diff --git a/deployment/ccip/changeset/v1_6/cs_chain_contracts.go b/deployment/ccip/changeset/v1_6/cs_chain_contracts.go index a5aac988b29..8deb0bbba24 100644 --- a/deployment/ccip/changeset/v1_6/cs_chain_contracts.go +++ b/deployment/ccip/changeset/v1_6/cs_chain_contracts.go @@ -8,7 +8,6 @@ import ( "fmt" "math/big" "slices" - "strings" "golang.org/x/sync/errgroup" @@ -50,7 +49,6 @@ import ( "github.com/smartcontractkit/chainlink/deployment/ccip/changeset/internal" commoncs "github.com/smartcontractkit/chainlink/deployment/common/changeset" "github.com/smartcontractkit/chainlink/deployment/common/proposalutils" - "github.com/smartcontractkit/chainlink/v2/core/capabilities/ccip/ccipevm" cctypes "github.com/smartcontractkit/chainlink/v2/core/capabilities/ccip/types" ) @@ -1745,49 +1743,7 @@ func isOCR3ConfigSetOnOffRamp( // - Any → other: NetworkFee=10, TokenFee=25 // - Ethereum -> any: NetworkFee=50, TokenFee=50 ( Source-chain-dependent override that must be applied by the caller) func DefaultFeeQuoterDestChainConfig(configEnabled bool, destChainSelector ...uint64) fee_quoter.FeeQuoterDestChainConfig { - familySelector, _ := hex.DecodeString(EVMFamilySelector) // evm - networkFeeUSDCents := uint32(10) - defaultTokenFeeUSDCents := uint16(25) - if len(destChainSelector) > 0 { - destFamily, _ := chain_selectors.GetSelectorFamily(destChainSelector[0]) - switch destFamily { - case chain_selectors.FamilySolana: - familySelector, _ = hex.DecodeString(SVMFamilySelector) - defaultTokenFeeUSDCents = 35 - case chain_selectors.FamilyAptos: - familySelector, _ = hex.DecodeString(AptosFamilySelector) - case chain_selectors.FamilyTon: - familySelector, _ = hex.DecodeString(TVMFamilySelector) - case chain_selectors.FamilySui: - familySelector, _ = hex.DecodeString(SuiFamilySelector) - case chain_selectors.FamilyEVM: - // Ethereum destinations have higher fees - name, _ := chain_selectors.GetChainNameFromSelector(destChainSelector[0]) - if strings.HasPrefix(name, "ethereum") { - networkFeeUSDCents = 50 - defaultTokenFeeUSDCents = 150 - } - } - } - return fee_quoter.FeeQuoterDestChainConfig{ - IsEnabled: configEnabled, - MaxNumberOfTokensPerMsg: 10, - MaxDataBytes: 30_000, - MaxPerMsgGasLimit: 3_000_000, - DestGasOverhead: ccipevm.DestGasOverhead, - DefaultTokenFeeUSDCents: defaultTokenFeeUSDCents, - DestGasPerPayloadByteBase: ccipevm.CalldataGasPerByteBase, - DestGasPerPayloadByteHigh: ccipevm.CalldataGasPerByteHigh, - DestGasPerPayloadByteThreshold: ccipevm.CalldataGasPerByteThreshold, - DestDataAvailabilityOverheadGas: 100, - DestGasPerDataAvailabilityByte: 16, - DestDataAvailabilityMultiplierBps: 1, - DefaultTokenDestGasOverhead: 90_000, - DefaultTxGasLimit: 200_000, - GasMultiplierWeiPerEth: 11e17, // Gas multiplier in wei per eth is scaled by 1e18, so 11e17 is 1.1 = 110% - NetworkFeeUSDCents: networkFeeUSDCents, - ChainFamilySelector: [4]byte(familySelector), - } + return ccipops.DefaultFeeQuoterDestChainConfig(configEnabled, destChainSelector...) } type ApplyFeeTokensUpdatesConfig struct { diff --git a/deployment/ccip/operation/evm/v1_6/ops_fee_quoter.go b/deployment/ccip/operation/evm/v1_6/ops_fee_quoter.go index c8296c04629..d8cfe70fa8c 100644 --- a/deployment/ccip/operation/evm/v1_6/ops_fee_quoter.go +++ b/deployment/ccip/operation/evm/v1_6/ops_fee_quoter.go @@ -1,8 +1,10 @@ package v1_6 import ( + "encoding/hex" "errors" "math/big" + "strings" "github.com/Masterminds/semver/v3" "github.com/ethereum/go-ethereum/common" @@ -10,11 +12,14 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" + chain_selectors "github.com/smartcontractkit/chain-selectors" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_6_3/fee_quoter" "github.com/smartcontractkit/chainlink/deployment" "github.com/smartcontractkit/chainlink/deployment/ccip/shared" opsutil "github.com/smartcontractkit/chainlink/deployment/common/opsutils" + "github.com/smartcontractkit/chainlink/v2/core/capabilities/ccip/ccipevm" ) type DeployFeeQInput struct { @@ -195,4 +200,54 @@ const ( EVMFamilySelector = "2812d52c" SVMFamilySelector = "1e10bdc4" AptosFamilySelector = "ac77ffec" + TVMFamilySelector = "647e2ba9" + SuiFamilySelector = "c4e05953" ) + +// DefaultFeeQuoterDestChainConfig returns the default FeeQuoter dest chain config. +// If destChainSelector is provided, family-specific values (ChainFamilySelector, +// NetworkFeeUSDCents, DefaultTokenFeeUSDCents) are set accordingly. +func DefaultFeeQuoterDestChainConfig(configEnabled bool, destChainSelector ...uint64) fee_quoter.FeeQuoterDestChainConfig { + familySelector, _ := hex.DecodeString(EVMFamilySelector) + networkFeeUSDCents := uint32(10) + defaultTokenFeeUSDCents := uint16(25) + if len(destChainSelector) > 0 { + destFamily, _ := chain_selectors.GetSelectorFamily(destChainSelector[0]) + switch destFamily { + case chain_selectors.FamilySolana: + familySelector, _ = hex.DecodeString(SVMFamilySelector) + defaultTokenFeeUSDCents = 35 + case chain_selectors.FamilyAptos: + familySelector, _ = hex.DecodeString(AptosFamilySelector) + case chain_selectors.FamilyTon: + familySelector, _ = hex.DecodeString(TVMFamilySelector) + case chain_selectors.FamilySui: + familySelector, _ = hex.DecodeString(SuiFamilySelector) + case chain_selectors.FamilyEVM: + name, _ := chain_selectors.GetChainNameFromSelector(destChainSelector[0]) + if strings.HasPrefix(name, "ethereum") { + networkFeeUSDCents = 50 + defaultTokenFeeUSDCents = 150 + } + } + } + return fee_quoter.FeeQuoterDestChainConfig{ + IsEnabled: configEnabled, + MaxNumberOfTokensPerMsg: 10, + MaxDataBytes: 30_000, + MaxPerMsgGasLimit: 3_000_000, + DestGasOverhead: ccipevm.DestGasOverhead, + DefaultTokenFeeUSDCents: defaultTokenFeeUSDCents, + DestGasPerPayloadByteBase: ccipevm.CalldataGasPerByteBase, + DestGasPerPayloadByteHigh: ccipevm.CalldataGasPerByteHigh, + DestGasPerPayloadByteThreshold: ccipevm.CalldataGasPerByteThreshold, + DestDataAvailabilityOverheadGas: 100, + DestGasPerDataAvailabilityByte: 16, + DestDataAvailabilityMultiplierBps: 1, + DefaultTokenDestGasOverhead: 90_000, + DefaultTxGasLimit: 200_000, + GasMultiplierWeiPerEth: 11e17, + NetworkFeeUSDCents: networkFeeUSDCents, + ChainFamilySelector: [4]byte(familySelector), + } +} diff --git a/deployment/ccip/shared/stateview/evm/state.go b/deployment/ccip/shared/stateview/evm/state.go index b4d29a39496..46ee77b7d09 100644 --- a/deployment/ccip/shared/stateview/evm/state.go +++ b/deployment/ccip/shared/stateview/evm/state.go @@ -15,6 +15,8 @@ import ( "github.com/smartcontractkit/ccip-contract-examples/chains/evm/gobindings/generated/1_6_1/transparent_upgradeable_proxy" "golang.org/x/sync/errgroup" + chain_selectors "github.com/smartcontractkit/chain-selectors" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_0_0/rmn_proxy_contract" "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_2_0/price_registry" "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_2_0/router" @@ -490,37 +492,6 @@ func chainSelFromConfigs(commit, exec ccip_home.GetAllConfigs) uint64 { return sel } -// V16ActiveChainSelectors returns chain selectors with an active or candidate -// v1.6 DON config in CCIPHome. Home chain only. -func (c CCIPChainState) V16ActiveChainSelectors(ctx context.Context) (map[uint64]bool, error) { - if c.CCIPHome == nil { - return nil, errors.New("no CCIPHome contract found in the state") - } - if c.CapabilityRegistry == nil { - return nil, errors.New("no CapabilityRegistry contract found in the state") - } - ccipDons, err := shared.GetCCIPDonsFromCapRegistry(ctx, c.CapabilityRegistry) - if err != nil { - return nil, fmt.Errorf("failed to get CCIP DONs from capability registry: %w", err) - } - callOpts := &bind.CallOpts{Context: ctx} - active := make(map[uint64]bool, len(ccipDons)) - for _, don := range ccipDons { - commitConfigs, err := c.CCIPHome.GetAllConfigs(callOpts, don.Id, uint8(types.PluginTypeCCIPCommit)) - if err != nil { - continue - } - execConfigs, err := c.CCIPHome.GetAllConfigs(callOpts, don.Id, uint8(types.PluginTypeCCIPExec)) - if err != nil { - continue - } - if chainSel := chainSelFromConfigs(commitConfigs, execConfigs); chainSel != 0 { - active[chainSel] = true - } - } - return active, nil -} - // ValidateRouter validates the router contract and returns all connected v1.6 chains. // v16ActiveChains filters out legacy v1.5 lane entries in mixed environments. func (c CCIPChainState) ValidateRouter(e cldf.Environment, isTestRouter bool, v16ActiveChains map[uint64]bool) ([]uint64, error) { @@ -558,7 +529,8 @@ func (c CCIPChainState) ValidateRouter(e cldf.Environment, isTestRouter bool, v1 return nil, fmt.Errorf("failed to get offRamps from router %s: %w", routerC.Address().Hex(), err) } for _, d := range offRampDetails { - if _, exists := e.BlockChains.SolanaChains()[d.SourceChainSelector]; exists { + // skip if solana - solana state is maintained in solana + if family, err := chain_selectors.GetSelectorFamily(d.SourceChainSelector); err != nil || family != chain_selectors.FamilyEVM { continue } if len(v16ActiveChains) > 0 && !v16ActiveChains[d.SourceChainSelector] { @@ -582,6 +554,37 @@ func (c CCIPChainState) ValidateRouter(e cldf.Environment, isTestRouter bool, v1 return v16ConnectedChains, nil } +// V16ActiveChainSelectors returns chain selectors with an active or candidate +// v1.6 DON config in CCIPHome. Home chain only. +func (c CCIPChainState) V16ActiveChainSelectors(ctx context.Context) (map[uint64]bool, error) { + if c.CCIPHome == nil { + return nil, errors.New("no CCIPHome contract found in the state") + } + if c.CapabilityRegistry == nil { + return nil, errors.New("no CapabilityRegistry contract found in the state") + } + ccipDons, err := shared.GetCCIPDonsFromCapRegistry(ctx, c.CapabilityRegistry) + if err != nil { + return nil, fmt.Errorf("failed to get CCIP DONs from capability registry: %w", err) + } + callOpts := &bind.CallOpts{Context: ctx} + active := make(map[uint64]bool, len(ccipDons)) + for _, don := range ccipDons { + commitConfigs, err := c.CCIPHome.GetAllConfigs(callOpts, don.Id, uint8(types.PluginTypeCCIPCommit)) + if err != nil { + continue + } + execConfigs, err := c.CCIPHome.GetAllConfigs(callOpts, don.Id, uint8(types.PluginTypeCCIPExec)) + if err != nil { + continue + } + if chainSel := chainSelFromConfigs(commitConfigs, execConfigs); chainSel != 0 { + active[chainSel] = true + } + } + return active, nil +} + // ValidateRMNRemote validates the RMNRemote contract to check if all wired contracts are synced with state // and returns whether RMN is enabled for the chain on the RMNRemote // It validates whether RMNRemote is in sync with the RMNHome contract diff --git a/deployment/ccip/shared/stateview/evm/validate.go b/deployment/ccip/shared/stateview/evm/validate.go index b64ce94a0d0..3c53afaf706 100644 --- a/deployment/ccip/shared/stateview/evm/validate.go +++ b/deployment/ccip/shared/stateview/evm/validate.go @@ -19,6 +19,7 @@ import ( cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" "github.com/smartcontractkit/chainlink/deployment/ccip/changeset/globals" + opsv16 "github.com/smartcontractkit/chainlink/deployment/ccip/operation/evm/v1_6" viewshared "github.com/smartcontractkit/chainlink/deployment/ccip/view/shared" "github.com/smartcontractkit/chainlink/v2/core/capabilities/ccip/ccipevm" ) @@ -86,6 +87,8 @@ func (c CCIPChainState) ValidateRMNProxy(e cldf.Environment) error { return nil } +// --- Helpers --- + func isEthereumChain(selector uint64) bool { return selector == chain_selectors.ETHEREUM_MAINNET.Selector || selector == chain_selectors.ETHEREUM_TESTNET_SEPOLIA.Selector @@ -114,142 +117,54 @@ func expectedDefaultTokenFeeUSDCents(srcSel, destSel uint64) uint16 { return 25 } -// ValidateFeeQuoter performs chain-level and lane-level validation. -func (c CCIPChainState) ValidateFeeQuoter( - e cldf.Environment, - sourceChainSel uint64, - connectedChains []uint64, - fqV2 *fqv2ops.FeeQuoterContract, -) error { - if c.FeeQuoter == nil { - return errors.New("no FeeQuoter contract found in the state") - } - callOpts := &bind.CallOpts{Context: e.GetContext()} - fqAddr := c.FeeQuoter.Address().Hex() - e.Logger.Debugw("Validating FeeQuoter", "chain", sourceChainSel, "feeQuoter", fqAddr, "connectedChains", len(connectedChains)) - var errs []error - - staticConfig, err := c.FeeQuoter.GetStaticConfig(callOpts) - if err != nil { - return fmt.Errorf("failed to get static config for FeeQuoter %s: %w", fqAddr, err) - } - linktokenAddr, err := c.LinkTokenAddress() - if err != nil { - return fmt.Errorf("failed to get link token address from state: %w", err) - } - if staticConfig.LinkToken != linktokenAddr { - errs = append(errs, fmt.Errorf("FeeQuoter %s LinkToken mismatch: expected %s, got %s", - fqAddr, linktokenAddr.Hex(), staticConfig.LinkToken.Hex())) - } - - feeTokens, err := c.FeeQuoter.GetFeeTokens(callOpts) - if err != nil { - errs = append(errs, fmt.Errorf("failed to get fee tokens from FeeQuoter %s: %w", fqAddr, err)) - return errors.Join(errs...) - } +type fieldCheck struct { + name string + got any + want any +} - if err := c.validateFeeTokenConfigs(callOpts, fqAddr, feeTokens); err != nil { - errs = append(errs, err) +func compareFieldChecks(section string, checks []fieldCheck) error { + var lines []string + for _, chk := range checks { + if chk.got != chk.want { + lines = append(lines, fmt.Sprintf("%s: got=%v, want=%v", chk.name, chk.got, chk.want)) + } } - - if len(connectedChains) == 0 { - // No lanes wired yet — skip lane-level validation (valid during early deployment) - return errors.Join(errs...) + if len(lines) == 0 { + return nil } + return fmt.Errorf("%s:\n %s", section, strings.Join(lines, "\n ")) +} - if c.FeeQuoterVersion == nil { - errs = append(errs, fmt.Errorf("FeeQuoter %s: version not set, cannot perform lane-level validation", fqAddr)) - return errors.Join(errs...) +// groupErrors wraps sub-errors under a single header line to avoid repeating context. +func groupErrors(header string, errs []error) error { + if len(errs) == 0 { + return nil } - switch c.FeeQuoterVersion.Major() { - case 1: - if err := c.validateDestChainConfigs(callOpts, fqAddr, sourceChainSel, connectedChains, feeTokens); err != nil { - errs = append(errs, err) - } - if err := c.validateTokenTransferFeeConfigs(e, callOpts, fqAddr, connectedChains, fqV2); err != nil { - errs = append(errs, err) + var lines []string + for _, e := range errs { + for _, line := range strings.Split(e.Error(), "\n") { + if line != "" { + lines = append(lines, line) + } } - default: - errs = append(errs, fmt.Errorf("FeeQuoter %s: unsupported version %s for lane-level validation", - fqAddr, c.FeeQuoterVersion.String())) } - - return errors.Join(errs...) + return fmt.Errorf("%s:\n %s", header, strings.Join(lines, "\n ")) } -// validateDestChainConfigs cross-checks against v1.5 OnRamp if migrated, or validates defaults if fresh -func (c CCIPChainState) validateDestChainConfigs( - callOpts *bind.CallOpts, - fqAddr string, - sourceChainSel uint64, - connectedChains []uint64, - feeTokens []common.Address, -) error { - var errs []error - - for _, destChainSel := range connectedChains { - destCfg, err := c.FeeQuoter.GetDestChainConfig(callOpts, destChainSel) - if err != nil { - errs = append(errs, fmt.Errorf("failed to get FeeQuoter dest chain config for chain %d: %w", destChainSel, err)) - continue - } - if !destCfg.IsEnabled { - errs = append(errs, fmt.Errorf("FeeQuoter %s dest chain config not enabled for chain %d", fqAddr, destChainSel)) - } - - legacyOnRamp := c.EVM2EVMOnRamp[destChainSel] - if legacyOnRamp != nil { - if err := c.validateFeeQuoterAgainstLegacyOnRamp(callOpts, fqAddr, destChainSel, destCfg, legacyOnRamp); err != nil { - errs = append(errs, err) - } - // GasMultiplierWeiPerEth moved from per-token to per-dest in v1.6 - for _, ft := range feeTokens { - legacyFTCfg, err := legacyOnRamp.GetFeeTokenConfig(callOpts, ft) - if err != nil || !legacyFTCfg.Enabled { - continue - } - if destCfg.GasMultiplierWeiPerEth != legacyFTCfg.GasMultiplierWeiPerEth { - errs = append(errs, fmt.Errorf("FeeQuoter %s GasMultiplierWeiPerEth mismatch for dest chain %d: "+ - "v1.6=%d, v1.5 FeeTokenConfig=%d", - fqAddr, destChainSel, destCfg.GasMultiplierWeiPerEth, legacyFTCfg.GasMultiplierWeiPerEth)) - } - break - } - } else { - if err := validateFeeQuoterDestCfgDefaults(fqAddr, sourceChainSel, destChainSel, destCfg); err != nil { - errs = append(errs, err) - } - } - - if destCfg.ChainFamilySelector == [4]byte{} { - errs = append(errs, fmt.Errorf("FeeQuoter %s ChainFamilySelector is empty for dest chain %d", fqAddr, destChainSel)) - } - if destCfg.GasPriceStalenessThreshold == 0 { - errs = append(errs, fmt.Errorf("FeeQuoter %s GasPriceStalenessThreshold is 0 for dest chain %d", fqAddr, destChainSel)) - } - if err := compareFieldChecks( - fmt.Sprintf("FeeQuoter %s dest chain %d", fqAddr, destChainSel), - []fieldCheck{ - {"DestGasPerPayloadByteHigh", uint64(destCfg.DestGasPerPayloadByteHigh), uint64(ccipevm.CalldataGasPerByteHigh)}, - {"DestGasPerPayloadByteThreshold", uint64(destCfg.DestGasPerPayloadByteThreshold), uint64(ccipevm.CalldataGasPerByteThreshold)}, - {"DefaultTxGasLimit", uint64(destCfg.DefaultTxGasLimit), uint64(200_000)}, - {"NetworkFeeUSDCents", uint64(destCfg.NetworkFeeUSDCents), uint64(expectedNetworkFeeUSDCents(sourceChainSel, destChainSel))}, - }, - ); err != nil { - errs = append(errs, err) - } - - destFamily, _ := chain_selectors.GetSelectorFamily(destChainSel) - if destFamily != chain_selectors.FamilyEVM && !destCfg.EnforceOutOfOrder { - errs = append(errs, fmt.Errorf("FeeQuoter %s EnforceOutOfOrder must be true for non-EVM dest chain %d (family %s)", - fqAddr, destChainSel, destFamily)) - } +func getFeeTokensV2(callOpts *bind.CallOpts, backend bind.ContractBackend, addr common.Address) ([]common.Address, error) { + parsed, err := abi.JSON(strings.NewReader(fqv2ops.FeeQuoterABI)) + if err != nil { + return nil, fmt.Errorf("failed to parse FeeQuoter v2.0 ABI: %w", err) } - - return errors.Join(errs...) + bc := bind.NewBoundContract(addr, parsed, backend, backend, backend) + var out []any + if err := bc.Call(callOpts, &out, "getFeeTokens"); err != nil { + return nil, fmt.Errorf("failed to call getFeeTokens on FeeQuoter v2.0 %s: %w", addr.Hex(), err) + } + return *abi.ConvertType(out[0], new([]common.Address)).(*[]common.Address), nil } -// validateFeeTokenSuperset checks that feeTokens is a superset of v1.5 PriceRegistry fee tokens. func (c CCIPChainState) validateFeeTokenSuperset( callOpts *bind.CallOpts, fqAddr string, @@ -276,294 +191,7 @@ func (c CCIPChainState) validateFeeTokenSuperset( return errors.Join(errs...) } -// validateFeeTokenConfigs checks fee token presence, v1.5 PriceRegistry superset, and premium multipliers -func (c CCIPChainState) validateFeeTokenConfigs( - callOpts *bind.CallOpts, - fqAddr string, - feeTokens []common.Address, -) error { - var errs []error - - if len(feeTokens) == 0 { - errs = append(errs, fmt.Errorf("FeeQuoter %s has no fee tokens configured", fqAddr)) - } - if err := c.validateFeeTokenSuperset(callOpts, fqAddr, feeTokens); err != nil { - errs = append(errs, err) - } - - var anyLegacyOnRamp *evm_2_evm_onramp.EVM2EVMOnRamp - if c.EVM2EVMOnRamp != nil { - for _, onRamp := range c.EVM2EVMOnRamp { - if onRamp != nil { - anyLegacyOnRamp = onRamp - break - } - } - } - - for _, feeToken := range feeTokens { - premium, err := c.FeeQuoter.GetPremiumMultiplierWeiPerEth(callOpts, feeToken) - if err != nil { - errs = append(errs, fmt.Errorf("failed to get PremiumMultiplierWeiPerEth for token %s on FeeQuoter %s: %w", - feeToken.Hex(), fqAddr, err)) - continue - } - if premium == 0 { - errs = append(errs, fmt.Errorf("FeeQuoter %s PremiumMultiplierWeiPerEth is 0 for fee token %s", - fqAddr, feeToken.Hex())) - } - if anyLegacyOnRamp != nil { - legacyFeeTokenCfg, err := anyLegacyOnRamp.GetFeeTokenConfig(callOpts, feeToken) - if err == nil && legacyFeeTokenCfg.Enabled && premium != legacyFeeTokenCfg.PremiumMultiplierWeiPerEth { - errs = append(errs, fmt.Errorf("FeeQuoter %s PremiumMultiplierWeiPerEth mismatch for fee token %s: "+ - "v1.6 has %d, v1.5 OnRamp had %d", - fqAddr, feeToken.Hex(), premium, legacyFeeTokenCfg.PremiumMultiplierWeiPerEth)) - } - } - } - - return errors.Join(errs...) -} - -// validateTokenTransferFeeConfigs checks per-token-per-dest fee invariants and v1.5 cross-checks. -// When fqV2 is non-nil, also validates v2.0 token transfer fees in the same pass to avoid duplicate RPC calls. -func (c CCIPChainState) validateTokenTransferFeeConfigs( - e cldf.Environment, - callOpts *bind.CallOpts, - fqAddr string, - connectedChains []uint64, - fqV2 *fqv2ops.FeeQuoterContract, -) error { - if c.TokenAdminRegistry == nil { - return errors.New("no TokenAdminRegistry contract found, cannot validate token transfer fee configs") - } - - allTokens, err := viewshared.GetSupportedTokens(c.TokenAdminRegistry) - if err != nil { - return fmt.Errorf("failed to get configured tokens from TokenAdminRegistry: %w", err) - } - - addrToSymbol := make(map[common.Address]string) - if symbolMap, symErr := c.TokenAddressBySymbol(); symErr == nil { - for symbol, addr := range symbolMap { - addrToSymbol[addr] = string(symbol) - } - } - - e.Logger.Debugw("Validating TokenTransferFeeConfigs", "tokens", len(allTokens), "connectedChains", len(connectedChains)) - var mu sync.Mutex - var errs []error - var wg sync.WaitGroup - sem := make(chan struct{}, 20) // max 20 concurrent RPC calls - for _, tokenAddr := range allTokens { - token := tokenAddr - tokenLabel := token.Hex() - if sym, ok := addrToSymbol[token]; ok { - tokenLabel = fmt.Sprintf("%s (%s)", sym, token.Hex()) - } - wg.Add(1) - sem <- struct{}{} - go func() { - defer wg.Done() - defer func() { <-sem }() - var tokenErrs []error - for _, destChainSel := range connectedChains { - ttfCfg, err := c.FeeQuoter.GetTokenTransferFeeConfig(callOpts, destChainSel, token) - if err != nil { - continue - } - if !ttfCfg.IsEnabled { - continue - } - if ttfCfg.MinFeeUSDCents >= ttfCfg.MaxFeeUSDCents { - tokenErrs = append(tokenErrs, fmt.Errorf("FeeQuoter %s TokenTransferFeeConfig for token %s to dest chain %d: "+ - "MinFeeUSDCents (%d) must be less than MaxFeeUSDCents (%d)", - fqAddr, tokenLabel, destChainSel, - ttfCfg.MinFeeUSDCents, ttfCfg.MaxFeeUSDCents)) - } - if ttfCfg.DestBytesOverhead < globals.CCIPLockOrBurnV1RetBytes { - tokenErrs = append(tokenErrs, fmt.Errorf("FeeQuoter %s TokenTransferFeeConfig for token %s to dest chain %d: "+ - "DestBytesOverhead (%d) must be at least %d", - fqAddr, tokenLabel, destChainSel, - ttfCfg.DestBytesOverhead, globals.CCIPLockOrBurnV1RetBytes)) - } - - // v1.5 legacy cross-check - legacyOnRamp := c.EVM2EVMOnRamp[destChainSel] - if legacyOnRamp != nil { - legacyTTF, legacyErr := legacyOnRamp.GetTokenTransferFeeConfig(callOpts, token) - if legacyErr == nil && legacyTTF.IsEnabled { - for _, chk := range []struct { - name string - v16Val uint64 - v15Val uint64 - }{ - {"MinFeeUSDCents", uint64(ttfCfg.MinFeeUSDCents), uint64(legacyTTF.MinFeeUSDCents)}, - {"MaxFeeUSDCents", uint64(ttfCfg.MaxFeeUSDCents), uint64(legacyTTF.MaxFeeUSDCents)}, - {"DeciBps", uint64(ttfCfg.DeciBps), uint64(legacyTTF.DeciBps)}, - {"DestGasOverhead", uint64(ttfCfg.DestGasOverhead), uint64(legacyTTF.DestGasOverhead)}, - {"DestBytesOverhead", uint64(ttfCfg.DestBytesOverhead), uint64(legacyTTF.DestBytesOverhead)}, - } { - if chk.v16Val != chk.v15Val { - tokenErrs = append(tokenErrs, fmt.Errorf("FeeQuoter %s TokenTransferFeeConfig for token %s to dest %d: "+ - "%s mismatch: v1.6=%d, v1.5=%d", - fqAddr, tokenLabel, destChainSel, chk.name, chk.v16Val, chk.v15Val)) - } - } - } - } - - // v2.0 cross-check (reuses v1.6 ttfCfg already fetched above) - if fqV2 != nil { - ttfCfgV2, v2Err := fqV2.GetTokenTransferFeeConfig(callOpts, destChainSel, token) - if v2Err == nil && ttfCfgV2.IsEnabled { - fqV2Addr := fqV2.Address().Hex() - if ttfCfgV2.DestBytesOverhead < globals.CCIPLockOrBurnV1RetBytes { - tokenErrs = append(tokenErrs, fmt.Errorf("FeeQuoter v2.0 %s TokenTransferFeeConfig for token %s to dest chain %d: "+ - "DestBytesOverhead (%d) must be at least %d", - fqV2Addr, tokenLabel, destChainSel, - ttfCfgV2.DestBytesOverhead, globals.CCIPLockOrBurnV1RetBytes)) - } - if ttfCfgV2.FeeUSDCents != ttfCfg.MinFeeUSDCents { - tokenErrs = append(tokenErrs, fmt.Errorf("FeeQuoter v2.0 %s TokenTransferFeeConfig for token %s to dest %d: "+ - "FeeUSDCents (%d) != v1.6 MinFeeUSDCents (%d)", - fqV2Addr, tokenLabel, destChainSel, ttfCfgV2.FeeUSDCents, ttfCfg.MinFeeUSDCents)) - } - if ttfCfg.DeciBps > 0 { - tokenErrs = append(tokenErrs, fmt.Errorf("FeeQuoter v2.0 %s TokenTransferFeeConfig for token %s to dest %d: "+ - "v1.6 DeciBps=%d is non-zero but DeciBps is removed in v2.0 (percentage fee lost)", - fqV2Addr, tokenLabel, destChainSel, ttfCfg.DeciBps)) - } - if ttfCfg.MaxFeeUSDCents > ttfCfg.MinFeeUSDCents { - tokenErrs = append(tokenErrs, fmt.Errorf("FeeQuoter v2.0 %s TokenTransferFeeConfig for token %s to dest %d: "+ - "v1.6 MaxFeeUSDCents (%d) > MinFeeUSDCents (%d) — fee cap is not present in v2.0", - fqV2Addr, tokenLabel, destChainSel, ttfCfg.MaxFeeUSDCents, ttfCfg.MinFeeUSDCents)) - } - for _, chk := range []struct { - name string - v20Val uint64 - v16Val uint64 - }{ - {"DestGasOverhead", uint64(ttfCfgV2.DestGasOverhead), uint64(ttfCfg.DestGasOverhead)}, - {"DestBytesOverhead", uint64(ttfCfgV2.DestBytesOverhead), uint64(ttfCfg.DestBytesOverhead)}, - } { - if chk.v20Val != chk.v16Val { - tokenErrs = append(tokenErrs, fmt.Errorf("FeeQuoter v2.0 %s TokenTransferFeeConfig for token %s to dest %d: "+ - "%s mismatch: v2.0=%d, v1.6=%d", - fqV2Addr, tokenLabel, destChainSel, chk.name, chk.v20Val, chk.v16Val)) - } - } - } - } - } - if len(tokenErrs) > 0 { - mu.Lock() - errs = append(errs, tokenErrs...) - mu.Unlock() - } - }() - } - wg.Wait() - - return errors.Join(errs...) -} - -// fieldCheck is a named expected-vs-actual pair used for table-driven validation. -type fieldCheck struct { - name string - got any - want any -} - -// compareFieldChecks runs table-driven comparison and returns any mismatches. -func compareFieldChecks(prefix string, checks []fieldCheck) error { - var errs []error - for _, chk := range checks { - if chk.got != chk.want { - errs = append(errs, fmt.Errorf("%s %s mismatch: got=%v, want=%v", prefix, chk.name, chk.got, chk.want)) - } - } - return errors.Join(errs...) -} - -// v16DestCfgLegacyChecks builds the field comparison table for v1.6 FeeQuoter dest config vs v1.5 OnRamp. -func v16DestCfgLegacyChecks( - destCfg fee_quoter.FeeQuoterDestChainConfig, - legacyCfg evm_2_evm_onramp.EVM2EVMOnRampDynamicConfig, -) []fieldCheck { - return []fieldCheck{ - {"MaxNumberOfTokensPerMsg", uint64(destCfg.MaxNumberOfTokensPerMsg), uint64(legacyCfg.MaxNumberOfTokensPerMsg)}, - {"DestGasOverhead", uint64(destCfg.DestGasOverhead), uint64(legacyCfg.DestGasOverhead)}, - {"DestDataAvailabilityOverheadGas", uint64(destCfg.DestDataAvailabilityOverheadGas), uint64(legacyCfg.DestDataAvailabilityOverheadGas)}, - {"DestGasPerDataAvailabilityByte", uint64(destCfg.DestGasPerDataAvailabilityByte), uint64(legacyCfg.DestGasPerDataAvailabilityByte)}, - {"DestDataAvailabilityMultiplierBps", uint64(destCfg.DestDataAvailabilityMultiplierBps), uint64(legacyCfg.DestDataAvailabilityMultiplierBps)}, - {"MaxDataBytes", uint64(destCfg.MaxDataBytes), uint64(legacyCfg.MaxDataBytes)}, - {"MaxPerMsgGasLimit", uint64(destCfg.MaxPerMsgGasLimit), uint64(legacyCfg.MaxPerMsgGasLimit)}, - {"DefaultTokenDestGasOverhead", uint64(destCfg.DefaultTokenDestGasOverhead), uint64(legacyCfg.DefaultTokenDestGasOverhead)}, - {"DefaultTokenFeeUSDCents", uint64(destCfg.DefaultTokenFeeUSDCents), uint64(legacyCfg.DefaultTokenFeeUSDCents)}, - {"EnforceOutOfOrder", destCfg.EnforceOutOfOrder, legacyCfg.EnforceOutOfOrder}, - {"DestGasPerPayloadByteBase", uint64(destCfg.DestGasPerPayloadByteBase), uint64(uint8(legacyCfg.DestGasPerPayloadByte))}, //nolint:gosec // G115: intentional v1.5 uint16→uint8 truncation during migration - } -} - -// v20DestCfgLegacyChecks builds the field comparison table for v2.0 FeeQuoter dest config vs v1.5 OnRamp. -// v2.0 DestChainConfig has fewer fields than v1.6; only the shared subset is compared. -func v20DestCfgLegacyChecks( - destCfg fqv2ops.DestChainConfig, - legacyCfg evm_2_evm_onramp.EVM2EVMOnRampDynamicConfig, -) []fieldCheck { - return []fieldCheck{ - {"DestGasOverhead", uint64(destCfg.DestGasOverhead), uint64(legacyCfg.DestGasOverhead)}, - {"MaxDataBytes", uint64(destCfg.MaxDataBytes), uint64(legacyCfg.MaxDataBytes)}, - {"MaxPerMsgGasLimit", uint64(destCfg.MaxPerMsgGasLimit), uint64(legacyCfg.MaxPerMsgGasLimit)}, - {"DefaultTokenDestGasOverhead", uint64(destCfg.DefaultTokenDestGasOverhead), uint64(legacyCfg.DefaultTokenDestGasOverhead)}, - {"DefaultTokenFeeUSDCents", uint64(destCfg.DefaultTokenFeeUSDCents), uint64(legacyCfg.DefaultTokenFeeUSDCents)}, - {"DestGasPerPayloadByteBase", uint64(destCfg.DestGasPerPayloadByteBase), uint64(uint8(legacyCfg.DestGasPerPayloadByte))}, //nolint:gosec // G115: intentional v1.5 uint16→uint8 truncation during migration - } -} - -// validateFeeQuoterAgainstLegacyOnRamp cross-checks v1.6 dest chain config against v1.5 OnRamp DynamicConfig -func (c CCIPChainState) validateFeeQuoterAgainstLegacyOnRamp( - callOpts *bind.CallOpts, - fqAddr string, - destChainSel uint64, - destCfg fee_quoter.FeeQuoterDestChainConfig, - legacyOnRamp *evm_2_evm_onramp.EVM2EVMOnRamp, -) error { - legacyCfg, err := legacyOnRamp.GetDynamicConfig(callOpts) - if err != nil { - return fmt.Errorf("failed to get v1.5 OnRamp dynamic config for dest chain %d: %w", destChainSel, err) - } - return compareFieldChecks( - fmt.Sprintf("FeeQuoter v1.6 %s dest chain %d", fqAddr, destChainSel), - v16DestCfgLegacyChecks(destCfg, legacyCfg), - ) -} - -// validateFeeQuoterDestCfgDefaults checks dest config against known defaults. -func validateFeeQuoterDestCfgDefaults( - fqAddr string, - sourceChainSel uint64, - destChainSel uint64, - destCfg fee_quoter.FeeQuoterDestChainConfig, -) error { - return compareFieldChecks( - fmt.Sprintf("FeeQuoter %s defaults dest chain %d", fqAddr, destChainSel), - []fieldCheck{ - {"MaxNumberOfTokensPerMsg", uint64(destCfg.MaxNumberOfTokensPerMsg), uint64(10)}, - {"MaxDataBytes", uint64(destCfg.MaxDataBytes), uint64(30_000)}, - {"MaxPerMsgGasLimit", uint64(destCfg.MaxPerMsgGasLimit), uint64(3_000_000)}, - {"DestGasOverhead", uint64(destCfg.DestGasOverhead), uint64(ccipevm.DestGasOverhead)}, - {"DestGasPerPayloadByteBase", uint64(destCfg.DestGasPerPayloadByteBase), uint64(ccipevm.CalldataGasPerByteBase)}, - {"DefaultTokenDestGasOverhead", uint64(destCfg.DefaultTokenDestGasOverhead), uint64(90_000)}, - {"DestDataAvailabilityOverheadGas", uint64(destCfg.DestDataAvailabilityOverheadGas), uint64(100)}, - {"DestGasPerDataAvailabilityByte", uint64(destCfg.DestGasPerDataAvailabilityByte), uint64(16)}, - {"DestDataAvailabilityMultiplierBps", uint64(destCfg.DestDataAvailabilityMultiplierBps), uint64(1)}, - {"GasMultiplierWeiPerEth", destCfg.GasMultiplierWeiPerEth, uint64(11e17)}, - {"DefaultTokenFeeUSDCents", uint64(destCfg.DefaultTokenFeeUSDCents), uint64(expectedDefaultTokenFeeUSDCents(sourceChainSel, destChainSel))}, - }, - ) -} +// --- Ownership --- type ownableContract interface { Owner(opts *bind.CallOpts) (common.Address, error) @@ -591,246 +219,486 @@ func (c CCIPChainState) ValidateContractOwnership(e cldf.Environment) error { callOpts := &bind.CallOpts{Context: e.GetContext()} var errs []error - if c.FeeQuoter != nil { - if err := checkOwnership(callOpts, "FeeQuoter", c.FeeQuoter, timelockAddr); err != nil { - errs = append(errs, err) - } - } - if c.NonceManager != nil { - if err := checkOwnership(callOpts, "NonceManager", c.NonceManager, timelockAddr); err != nil { - errs = append(errs, err) - } - } - /* if c.RMNRemote != nil { - if err := checkOwnership(callOpts, "RMNRemote", c.RMNRemote, timelockAddr); err != nil { - errs = append(errs, err) - } - } */ - if c.OnRamp != nil { - if err := checkOwnership(callOpts, "OnRamp", c.OnRamp, timelockAddr); err != nil { - errs = append(errs, err) - } - } - if c.OffRamp != nil { - if err := checkOwnership(callOpts, "OffRamp", c.OffRamp, timelockAddr); err != nil { - errs = append(errs, err) - } - } - if c.Router != nil { - if err := checkOwnership(callOpts, "Router", c.Router, timelockAddr); err != nil { - errs = append(errs, err) - } - } - - /* if c.ProposerMcm != nil { - if err := checkOwnership(callOpts, "ProposerMcm", c.ProposerMcm, c.ProposerMcm.Address()); err != nil { - errs = append(errs, err) + for _, ct := range []struct { + name string + contract ownableContract + present bool + }{ + {"FeeQuoter", c.FeeQuoter, c.FeeQuoter != nil}, + {"NonceManager", c.NonceManager, c.NonceManager != nil}, + {"OnRamp", c.OnRamp, c.OnRamp != nil}, + {"OffRamp", c.OffRamp, c.OffRamp != nil}, + {"Router", c.Router, c.Router != nil}, + } { + if !ct.present { + continue } - } - if c.CancellerMcm != nil { - if err := checkOwnership(callOpts, "CancellerMcm", c.CancellerMcm, c.CancellerMcm.Address()); err != nil { + if err := checkOwnership(callOpts, ct.name, ct.contract, timelockAddr); err != nil { errs = append(errs, err) } } - if c.BypasserMcm != nil { - if err := checkOwnership(callOpts, "BypasserMcm", c.BypasserMcm, c.BypasserMcm.Address()); err != nil { - errs = append(errs, err) - } - } */ return errors.Join(errs...) } -// — FeeQuoter v2.0 validation — - -// v16v20SharedFieldChecks builds the field comparison table for shared fields between v1.6 and v2.0 FeeQuoter dest configs. -func v16v20SharedFieldChecks( - v16 fee_quoter.FeeQuoterDestChainConfig, - v20 fqv2ops.DestChainConfig, -) []fieldCheck { - return []fieldCheck{ - {"IsEnabled", v16.IsEnabled, v20.IsEnabled}, - {"MaxDataBytes", uint64(v16.MaxDataBytes), uint64(v20.MaxDataBytes)}, - {"MaxPerMsgGasLimit", uint64(v16.MaxPerMsgGasLimit), uint64(v20.MaxPerMsgGasLimit)}, - {"DestGasOverhead", uint64(v16.DestGasOverhead), uint64(v20.DestGasOverhead)}, - {"DestGasPerPayloadByteBase", uint64(v16.DestGasPerPayloadByteBase), uint64(v20.DestGasPerPayloadByteBase)}, - {"ChainFamilySelector", v16.ChainFamilySelector, v20.ChainFamilySelector}, - {"DefaultTokenFeeUSDCents", uint64(v16.DefaultTokenFeeUSDCents), uint64(v20.DefaultTokenFeeUSDCents)}, - {"DefaultTokenDestGasOverhead", uint64(v16.DefaultTokenDestGasOverhead), uint64(v20.DefaultTokenDestGasOverhead)}, - {"DefaultTxGasLimit", uint64(v16.DefaultTxGasLimit), uint64(v20.DefaultTxGasLimit)}, - {"NetworkFeeUSDCents", uint64(v16.NetworkFeeUSDCents), uint64(v20.NetworkFeeUSDCents)}, - } -} - -// getFeeTokensV2 calls getFeeTokens on a FeeQuoter 2.0 via raw ABI call -// (the operations-gen wrapper doesn't expose GetFeeTokens). -func getFeeTokensV2(callOpts *bind.CallOpts, backend bind.ContractBackend, addr common.Address) ([]common.Address, error) { - parsed, err := abi.JSON(strings.NewReader(fqv2ops.FeeQuoterABI)) - if err != nil { - return nil, fmt.Errorf("failed to parse FeeQuoter v2.0 ABI: %w", err) - } - bc := bind.NewBoundContract(addr, parsed, backend, backend, backend) - var out []any - if err := bc.Call(callOpts, &out, "getFeeTokens"); err != nil { - return nil, fmt.Errorf("failed to call getFeeTokens on FeeQuoter v2.0 %s: %w", addr.Hex(), err) - } - return *abi.ConvertType(out[0], new([]common.Address)).(*[]common.Address), nil -} - -// ValidateFeeQuoterV2 validates a FeeQuoter v2.0 deployment against on-chain state. -func (c CCIPChainState) ValidateFeeQuoterV2( +// ValidateFeeQuoter validates all FeeQuoter contracts (v1.6 and/or v2.0) for a chain +func (c CCIPChainState) ValidateFeeQuoter( e cldf.Environment, sourceChainSel uint64, connectedChains []uint64, fqV2 *fqv2ops.FeeQuoterContract, backend bind.ContractBackend, ) error { + if c.FeeQuoter == nil && fqV2 == nil { + return errors.New("no FeeQuoter contract (v1.6 or v2.0) found in the state") + } callOpts := &bind.CallOpts{Context: e.GetContext()} - fqAddr := fqV2.Address().Hex() - e.Logger.Debugw("Validating FeeQuoter v2.0", "chain", sourceChainSel, "feeQuoterV2", fqAddr, "connectedChains", len(connectedChains)) var errs []error - staticConfig, err := fqV2.GetStaticConfig(callOpts) - if err != nil { - return fmt.Errorf("failed to get static config for FeeQuoter v2.0 %s: %w", fqAddr, err) + // v1.6 static config checks + var v16FeeTokens []common.Address + v16LaneReady := false + if c.FeeQuoter != nil { + fqAddr := c.FeeQuoter.Address().Hex() + e.Logger.Debugw("Validating FeeQuoter v1.6", "chain", sourceChainSel, "feeQuoter", fqAddr, "connectedChains", len(connectedChains)) + staticConfig, err := c.FeeQuoter.GetStaticConfig(callOpts) + if err != nil { + errs = append(errs, fmt.Errorf("failed to get static config for FeeQuoter %s: %w", fqAddr, err)) + } else { + linktokenAddr, err := c.LinkTokenAddress() + if err != nil { + errs = append(errs, fmt.Errorf("failed to get link token address from state: %w", err)) + } else if staticConfig.LinkToken != linktokenAddr { + errs = append(errs, fmt.Errorf("FeeQuoter %s LinkToken mismatch: expected %s, got %s", + fqAddr, linktokenAddr.Hex(), staticConfig.LinkToken.Hex())) + } + if staticConfig.TokenPriceStalenessThreshold == 0 { + errs = append(errs, fmt.Errorf("FeeQuoter %s: TokenPriceStalenessThreshold is 0", fqAddr)) + } + } + feeTokens, err := c.FeeQuoter.GetFeeTokens(callOpts) + if err != nil { + errs = append(errs, fmt.Errorf("failed to get fee tokens from FeeQuoter %s: %w", fqAddr, err)) + } else { + v16FeeTokens = feeTokens + } + + switch { + case c.FeeQuoterVersion == nil: + errs = append(errs, fmt.Errorf("FeeQuoter %s: version not set, cannot perform lane-level validation", fqAddr)) + case c.FeeQuoterVersion.Major() != 1: + errs = append(errs, fmt.Errorf("FeeQuoter %s: unsupported version %s for lane-level validation", + fqAddr, c.FeeQuoterVersion.String())) + default: + v16LaneReady = len(v16FeeTokens) > 0 + } } - linktokenAddr, err := c.LinkTokenAddress() - if err != nil { - return fmt.Errorf("failed to get link token address from state: %w", err) + + // v2.0 static config + owner checks + v20Ready := fqV2 != nil + if fqV2 != nil { + fqAddr := fqV2.Address().Hex() + e.Logger.Debugw("Validating FeeQuoter v2.0", "chain", sourceChainSel, "feeQuoterV2", fqAddr, "connectedChains", len(connectedChains)) + staticConfig, err := fqV2.GetStaticConfig(callOpts) + if err != nil { + errs = append(errs, fmt.Errorf("failed to get static config for FeeQuoter v2.0 %s: %w", fqAddr, err)) + v20Ready = false + } else { + linktokenAddr, err := c.LinkTokenAddress() + if err != nil { + errs = append(errs, fmt.Errorf("failed to get link token address from state: %w", err)) + } else if staticConfig.LinkToken != linktokenAddr { + errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s LinkToken mismatch: expected %s, got %s", + fqAddr, linktokenAddr.Hex(), staticConfig.LinkToken.Hex())) + } + } + owner, err := fqV2.Owner(callOpts) + if err != nil { + errs = append(errs, fmt.Errorf("failed to get owner from FeeQuoter v2.0 %s: %w", fqAddr, err)) + } else if c.Timelock != nil && owner != c.Timelock.Address() { + errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s not owned by Timelock %s, actual owner: %s", + fqAddr, c.Timelock.Address().Hex(), owner.Hex())) + } } - if staticConfig.LinkToken != linktokenAddr { - errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s LinkToken mismatch: expected %s, got %s", - fqAddr, linktokenAddr.Hex(), staticConfig.LinkToken.Hex())) + + var effectiveFqV2 *fqv2ops.FeeQuoterContract + if v20Ready { + effectiveFqV2 = fqV2 } - owner, err := fqV2.Owner(callOpts) - if err != nil { - errs = append(errs, fmt.Errorf("failed to get owner from FeeQuoter v2.0 %s: %w", fqAddr, err)) - } else if c.Timelock != nil && owner != c.Timelock.Address() { - errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s not owned by Timelock %s, actual owner: %s", - fqAddr, c.Timelock.Address().Hex(), owner.Hex())) + // Fee token validation (version-aware) + if err := c.validateAllFeeTokenConfigs(callOpts, v16FeeTokens, effectiveFqV2, backend); err != nil { + errs = append(errs, err) } if len(connectedChains) == 0 { return errors.Join(errs...) } - if err := c.validateFeeTokenConfigsV20(callOpts, fqAddr, backend, fqV2.Address()); err != nil { - errs = append(errs, err) + // Dest chain config validation (version-aware). + var laneV16FeeTokens []common.Address + if v16LaneReady { + laneV16FeeTokens = v16FeeTokens } - if err := c.validateDestChainConfigsV20(callOpts, fqAddr, sourceChainSel, connectedChains, fqV2); err != nil { + if err := c.validateAllDestChainConfigs(callOpts, sourceChainSel, connectedChains, laneV16FeeTokens, effectiveFqV2); err != nil { errs = append(errs, err) } - // When v1.6 FQ exists, token transfer fees are validated in the combined pass (ValidateFeeQuoter). - // When v1.6 FQ is absent, run standalone v2.0 token fee validation. - if c.FeeQuoter == nil { - if err := c.validateTokenTransferFeeConfigsV20(callOpts, fqAddr, connectedChains, fqV2); err != nil { - errs = append(errs, err) - } + + // Token transfer fee validation (version-aware) + if err := c.validateAllTokenTransferFeeConfigs(e, callOpts, connectedChains, effectiveFqV2); err != nil { + errs = append(errs, err) } return errors.Join(errs...) } -// validateFeeTokenConfigsV20 checks fee token presence and v1.5 PriceRegistry superset for v2.0. -func (c CCIPChainState) validateFeeTokenConfigsV20( +// validateAllFeeTokenConfigs validates fee tokens for all present FeeQuoter versions +// v1.6: non-empty, v1.5 PriceRegistry superset, premium multiplier per token, v1.5 cross-check +// v2.0: non-empty, v1.5 PriceRegistry superset +func (c CCIPChainState) validateAllFeeTokenConfigs( callOpts *bind.CallOpts, - fqAddr string, + v16FeeTokens []common.Address, + fqV2 *fqv2ops.FeeQuoterContract, backend bind.ContractBackend, - addr common.Address, ) error { - feeTokens, err := getFeeTokensV2(callOpts, backend, addr) - if err != nil { - return fmt.Errorf("failed to get fee tokens from FeeQuoter v2.0 %s: %w", fqAddr, err) - } - var errs []error - if len(feeTokens) == 0 { - errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s has no fee tokens configured", fqAddr)) + + // v1.6 fee token checks + if c.FeeQuoter != nil && v16FeeTokens != nil { + fqAddr := c.FeeQuoter.Address().Hex() + if len(v16FeeTokens) == 0 { + errs = append(errs, fmt.Errorf("FeeQuoter %s has no fee tokens configured", fqAddr)) + } + if err := c.validateFeeTokenSuperset(callOpts, fqAddr, v16FeeTokens); err != nil { + errs = append(errs, err) + } + + // Premium multiplier validation + v1.5 cross-check + var anyLegacyOnRamp *evm_2_evm_onramp.EVM2EVMOnRamp + if c.EVM2EVMOnRamp != nil { + for _, onRamp := range c.EVM2EVMOnRamp { + if onRamp != nil { + anyLegacyOnRamp = onRamp + break + } + } + } + for _, feeToken := range v16FeeTokens { + premium, err := c.FeeQuoter.GetPremiumMultiplierWeiPerEth(callOpts, feeToken) + if err != nil { + errs = append(errs, fmt.Errorf("failed to get PremiumMultiplierWeiPerEth for token %s on FeeQuoter %s: %w", + feeToken.Hex(), fqAddr, err)) + continue + } + if premium == 0 { + errs = append(errs, fmt.Errorf("FeeQuoter %s PremiumMultiplierWeiPerEth is 0 for fee token %s", + fqAddr, feeToken.Hex())) + } + if anyLegacyOnRamp != nil { + legacyFeeTokenCfg, err := anyLegacyOnRamp.GetFeeTokenConfig(callOpts, feeToken) + if err == nil && legacyFeeTokenCfg.Enabled && premium != legacyFeeTokenCfg.PremiumMultiplierWeiPerEth { + errs = append(errs, fmt.Errorf("FeeQuoter %s PremiumMultiplierWeiPerEth mismatch for fee token %s: "+ + "v1.6 has %d, v1.5 OnRamp had %d", + fqAddr, feeToken.Hex(), premium, legacyFeeTokenCfg.PremiumMultiplierWeiPerEth)) + } + } + } } - if err := c.validateFeeTokenSuperset(callOpts, fqAddr, feeTokens); err != nil { - errs = append(errs, err) + + // v2.0 fee token checks + if fqV2 != nil { + fqAddr := fqV2.Address().Hex() + feeTokens, err := getFeeTokensV2(callOpts, backend, fqV2.Address()) + if err != nil { + errs = append(errs, fmt.Errorf("failed to get fee tokens from FeeQuoter v2.0 %s: %w", fqAddr, err)) + } else { + if len(feeTokens) == 0 { + errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s has no fee tokens configured", fqAddr)) + } + if err := c.validateFeeTokenSuperset(callOpts, fqAddr, feeTokens); err != nil { + errs = append(errs, err) + } + + // v1.6 <-> v2.0 fee token set comparison: both FeeQuoters must have the same fee tokens + if v16FeeTokens != nil { + v16Set := make(map[common.Address]bool, len(v16FeeTokens)) + for _, ft := range v16FeeTokens { + v16Set[ft] = true + } + v20Set := make(map[common.Address]bool, len(feeTokens)) + for _, ft := range feeTokens { + v20Set[ft] = true + } + for _, ft := range feeTokens { + if !v16Set[ft] { + errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s has fee token %s not present in v1.6 FeeQuoter", + fqAddr, ft.Hex())) + } + } + for _, ft := range v16FeeTokens { + if !v20Set[ft] { + errs = append(errs, fmt.Errorf("FeeQuoter v1.6 has fee token %s not present in v2.0 FeeQuoter %s", + ft.Hex(), fqAddr)) + } + } + } + } } return errors.Join(errs...) } -// validateDestChainConfigsV20 validates v2.0 dest chain configs against v1.6 and v1.5 state. -// Case B (c.FeeQuoter != nil): cross-checks v1.6↔v2.0 and v1.5↔v2.0 for each dest. -// Case C (c.FeeQuoter == nil): cross-checks v1.5↔v2.0 directly where a v1.5 OnRamp exists. -func (c CCIPChainState) validateDestChainConfigsV20( +// --- Dest Chain Config Validation --- + +// validateAllDestChainConfigs validates dest chain configs across v1.5, v1.6, and v2.0. +// Cross-version field counts: v1.5↔v1.6 (11), v1.5↔v2.0 (6+NetworkFeeUSDCents), v1.6↔v2.0 (10). +func (c CCIPChainState) validateAllDestChainConfigs( callOpts *bind.CallOpts, - fqAddr string, sourceChainSel uint64, connectedChains []uint64, + v16FeeTokens []common.Address, fqV2 *fqv2ops.FeeQuoterContract, ) error { var errs []error for _, destChainSel := range connectedChains { - destCfgV2, err := fqV2.GetDestChainConfig(callOpts, destChainSel) - if err != nil { - errs = append(errs, fmt.Errorf("failed to get FeeQuoter v2.0 dest chain config for chain %d: %w", destChainSel, err)) - continue + var v16Cfg *fee_quoter.FeeQuoterDestChainConfig + var v20Cfg *fqv2ops.DestChainConfig + var legacyCfg *evm_2_evm_onramp.EVM2EVMOnRampDynamicConfig + + if c.FeeQuoter != nil && v16FeeTokens != nil { + cfg, err := c.FeeQuoter.GetDestChainConfig(callOpts, destChainSel) + if err != nil { + errs = append(errs, fmt.Errorf("failed to get FeeQuoter v1.6 dest chain config for chain %d: %w", destChainSel, err)) + } else { + v16Cfg = &cfg + } + } + if fqV2 != nil { + cfg, err := fqV2.GetDestChainConfig(callOpts, destChainSel) + if err != nil { + errs = append(errs, fmt.Errorf("failed to get FeeQuoter v2.0 dest chain config for chain %d: %w", destChainSel, err)) + } else { + v20Cfg = &cfg + } } - if !destCfgV2.IsEnabled { - errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s dest chain config not enabled for chain %d", fqAddr, destChainSel)) + if legacyOnRamp := c.EVM2EVMOnRamp[destChainSel]; legacyOnRamp != nil { + cfg, err := legacyOnRamp.GetDynamicConfig(callOpts) + if err != nil { + errs = append(errs, fmt.Errorf("failed to get v1.5 OnRamp dynamic config for dest chain %d: %w", destChainSel, err)) + } else { + legacyCfg = &cfg + } + } + + if v16Cfg != nil { + if err := c.validateV16DestChainConfig(callOpts, sourceChainSel, destChainSel, *v16Cfg, legacyCfg, v16FeeTokens); err != nil { + errs = append(errs, err) + } } + if v20Cfg != nil { + if err := c.validateV20DestChainConfig(callOpts, sourceChainSel, destChainSel, *v20Cfg, v16Cfg, legacyCfg, fqV2); err != nil { + errs = append(errs, err) + } + } + } + + return errors.Join(errs...) +} - // v2.0-specific fields - if err := compareFieldChecks( - fmt.Sprintf("FeeQuoter v2.0 %s dest chain %d", fqAddr, destChainSel), - []fieldCheck{ - {"LinkFeeMultiplierPercent", uint64(destCfgV2.LinkFeeMultiplierPercent), uint64(fqv2seq.LinkFeeMultiplierPercent)}, - {"NetworkFeeUSDCents", uint64(destCfgV2.NetworkFeeUSDCents), uint64(expectedNetworkFeeUSDCents(sourceChainSel, destChainSel))}, - {"DefaultTxGasLimit", uint64(destCfgV2.DefaultTxGasLimit), uint64(200_000)}, - }, - ); err != nil { +// validateV16DestChainConfig validates a single v1.6 dest chain config. +func (c CCIPChainState) validateV16DestChainConfig( + callOpts *bind.CallOpts, + sourceChainSel, destChainSel uint64, + destCfg fee_quoter.FeeQuoterDestChainConfig, + legacyCfg *evm_2_evm_onramp.EVM2EVMOnRampDynamicConfig, + feeTokens []common.Address, +) error { + header := fmt.Sprintf("FeeQuoter v1.6 %s dest chain %d", c.FeeQuoter.Address().Hex(), destChainSel) + var errs []error + + if !destCfg.IsEnabled { + errs = append(errs, errors.New("not enabled")) + } + + // Cross-version field mapping: v1.6 <-> v1.5 + if legacyCfg != nil { + if err := compareFieldChecks("v1.5<->v1.6", []fieldCheck{ + {"MaxNumberOfTokensPerMsg", uint64(destCfg.MaxNumberOfTokensPerMsg), uint64(legacyCfg.MaxNumberOfTokensPerMsg)}, + {"DestGasOverhead", uint64(destCfg.DestGasOverhead), uint64(legacyCfg.DestGasOverhead)}, + {"DestDataAvailabilityOverheadGas", uint64(destCfg.DestDataAvailabilityOverheadGas), uint64(legacyCfg.DestDataAvailabilityOverheadGas)}, + {"DestGasPerDataAvailabilityByte", uint64(destCfg.DestGasPerDataAvailabilityByte), uint64(legacyCfg.DestGasPerDataAvailabilityByte)}, + {"DestDataAvailabilityMultiplierBps", uint64(destCfg.DestDataAvailabilityMultiplierBps), uint64(legacyCfg.DestDataAvailabilityMultiplierBps)}, + {"MaxDataBytes", uint64(destCfg.MaxDataBytes), uint64(legacyCfg.MaxDataBytes)}, + {"MaxPerMsgGasLimit", uint64(destCfg.MaxPerMsgGasLimit), uint64(legacyCfg.MaxPerMsgGasLimit)}, + {"DefaultTokenDestGasOverhead", uint64(destCfg.DefaultTokenDestGasOverhead), uint64(legacyCfg.DefaultTokenDestGasOverhead)}, + {"DefaultTokenFeeUSDCents", uint64(destCfg.DefaultTokenFeeUSDCents), uint64(legacyCfg.DefaultTokenFeeUSDCents)}, + {"EnforceOutOfOrder", destCfg.EnforceOutOfOrder, legacyCfg.EnforceOutOfOrder}, + {"DestGasPerPayloadByteBase", uint64(destCfg.DestGasPerPayloadByteBase), uint64(uint8(legacyCfg.DestGasPerPayloadByte))}, //nolint:gosec // G115: intentional v1.5 uint16->uint8 truncation during migration + }); err != nil { errs = append(errs, err) } - if c.FeeQuoter != nil { - destCfgV16, err := c.FeeQuoter.GetDestChainConfig(callOpts, destChainSel) - if err != nil { - errs = append(errs, fmt.Errorf("failed to get FeeQuoter v1.6 dest chain config for chain %d: %w", destChainSel, err)) - } else if err := compareFieldChecks( - fmt.Sprintf("FeeQuoter %s dest chain %d v1.6↔v2.0", fqAddr, destChainSel), - v16v20SharedFieldChecks(destCfgV16, destCfgV2), - ); err != nil { - errs = append(errs, err) + // GasMultiplierWeiPerEth moved from per-token to per-dest in v1.6 + for _, ft := range feeTokens { + legacyOnRamp := c.EVM2EVMOnRamp[destChainSel] + if legacyOnRamp == nil { + break + } + legacyFTCfg, err := legacyOnRamp.GetFeeTokenConfig(callOpts, ft) + if err != nil || !legacyFTCfg.Enabled { + continue } + if destCfg.GasMultiplierWeiPerEth != legacyFTCfg.GasMultiplierWeiPerEth { + errs = append(errs, fmt.Errorf("GasMultiplierWeiPerEth: v1.6=%d, v1.5 FeeTokenConfig=%d", + destCfg.GasMultiplierWeiPerEth, legacyFTCfg.GasMultiplierWeiPerEth)) + } + break + } + } else { + // No legacy -- validate against canonical defaults. + expected := opsv16.DefaultFeeQuoterDestChainConfig(true, destChainSel) + if err := compareFieldChecks("defaults", []fieldCheck{ + {"MaxNumberOfTokensPerMsg", uint64(destCfg.MaxNumberOfTokensPerMsg), uint64(expected.MaxNumberOfTokensPerMsg)}, + {"MaxDataBytes", uint64(destCfg.MaxDataBytes), uint64(expected.MaxDataBytes)}, + {"MaxPerMsgGasLimit", uint64(destCfg.MaxPerMsgGasLimit), uint64(expected.MaxPerMsgGasLimit)}, + {"DestGasOverhead", uint64(destCfg.DestGasOverhead), uint64(expected.DestGasOverhead)}, + {"DestGasPerPayloadByteBase", uint64(destCfg.DestGasPerPayloadByteBase), uint64(expected.DestGasPerPayloadByteBase)}, + {"DefaultTokenDestGasOverhead", uint64(destCfg.DefaultTokenDestGasOverhead), uint64(expected.DefaultTokenDestGasOverhead)}, + {"DestDataAvailabilityOverheadGas", uint64(destCfg.DestDataAvailabilityOverheadGas), uint64(expected.DestDataAvailabilityOverheadGas)}, + {"DestGasPerDataAvailabilityByte", uint64(destCfg.DestGasPerDataAvailabilityByte), uint64(expected.DestGasPerDataAvailabilityByte)}, + {"DestDataAvailabilityMultiplierBps", uint64(destCfg.DestDataAvailabilityMultiplierBps), uint64(expected.DestDataAvailabilityMultiplierBps)}, + {"GasMultiplierWeiPerEth", destCfg.GasMultiplierWeiPerEth, expected.GasMultiplierWeiPerEth}, + {"DefaultTokenFeeUSDCents", uint64(destCfg.DefaultTokenFeeUSDCents), uint64(expectedDefaultTokenFeeUSDCents(sourceChainSel, destChainSel))}, + }); err != nil { + errs = append(errs, err) } + } + + // v1.6 business-rule fields (always checked) + if destCfg.ChainFamilySelector == [4]byte{} { + errs = append(errs, errors.New("ChainFamilySelector is empty")) + } + if destCfg.GasPriceStalenessThreshold == 0 { + errs = append(errs, errors.New("GasPriceStalenessThreshold is 0")) + } + if err := compareFieldChecks("business rules", []fieldCheck{ + {"DestGasPerPayloadByteHigh", uint64(destCfg.DestGasPerPayloadByteHigh), uint64(ccipevm.CalldataGasPerByteHigh)}, + {"DestGasPerPayloadByteThreshold", uint64(destCfg.DestGasPerPayloadByteThreshold), uint64(ccipevm.CalldataGasPerByteThreshold)}, + {"DefaultTxGasLimit", uint64(destCfg.DefaultTxGasLimit), uint64(200_000)}, + {"NetworkFeeUSDCents", uint64(destCfg.NetworkFeeUSDCents), uint64(expectedNetworkFeeUSDCents(sourceChainSel, destChainSel))}, + }); err != nil { + errs = append(errs, err) + } + + destFamily, _ := chain_selectors.GetSelectorFamily(destChainSel) + if destFamily != chain_selectors.FamilyEVM && !destCfg.EnforceOutOfOrder { + errs = append(errs, fmt.Errorf("EnforceOutOfOrder must be true for non-EVM dest (family %s)", destFamily)) + } + + return groupErrors(header, errs) +} +// validateV20DestChainConfig validates a single v2.0 dest chain config. +func (c CCIPChainState) validateV20DestChainConfig( + callOpts *bind.CallOpts, + sourceChainSel, destChainSel uint64, + destCfgV2 fqv2ops.DestChainConfig, + v16Cfg *fee_quoter.FeeQuoterDestChainConfig, + legacyCfg *evm_2_evm_onramp.EVM2EVMOnRampDynamicConfig, + fqV2 *fqv2ops.FeeQuoterContract, +) error { + header := fmt.Sprintf("FeeQuoter v2.0 %s dest chain %d", fqV2.Address().Hex(), destChainSel) + var errs []error + + if !destCfgV2.IsEnabled { + errs = append(errs, errors.New("not enabled")) + } + + // v2.0 business-rule fields + if destCfgV2.ChainFamilySelector == [4]byte{} { + errs = append(errs, errors.New("ChainFamilySelector is empty")) + } + if err := compareFieldChecks("business rules", []fieldCheck{ + {"LinkFeeMultiplierPercent", uint64(destCfgV2.LinkFeeMultiplierPercent), uint64(fqv2seq.LinkFeeMultiplierPercent)}, + {"NetworkFeeUSDCents", uint64(destCfgV2.NetworkFeeUSDCents), uint64(expectedNetworkFeeUSDCents(sourceChainSel, destChainSel))}, + {"DefaultTxGasLimit", uint64(destCfgV2.DefaultTxGasLimit), uint64(200_000)}, + }); err != nil { + errs = append(errs, err) + } + + // Cross-version field mapping: v1.6 <-> v2.0 + if v16Cfg != nil { + if err := compareFieldChecks("v1.6<->v2.0", []fieldCheck{ + {"IsEnabled", v16Cfg.IsEnabled, destCfgV2.IsEnabled}, + {"MaxDataBytes", uint64(v16Cfg.MaxDataBytes), uint64(destCfgV2.MaxDataBytes)}, + {"MaxPerMsgGasLimit", uint64(v16Cfg.MaxPerMsgGasLimit), uint64(destCfgV2.MaxPerMsgGasLimit)}, + {"DestGasOverhead", uint64(v16Cfg.DestGasOverhead), uint64(destCfgV2.DestGasOverhead)}, + {"DestGasPerPayloadByteBase", uint64(v16Cfg.DestGasPerPayloadByteBase), uint64(destCfgV2.DestGasPerPayloadByteBase)}, + {"ChainFamilySelector", v16Cfg.ChainFamilySelector, destCfgV2.ChainFamilySelector}, + {"DefaultTokenFeeUSDCents", uint64(v16Cfg.DefaultTokenFeeUSDCents), uint64(destCfgV2.DefaultTokenFeeUSDCents)}, + {"DefaultTokenDestGasOverhead", uint64(v16Cfg.DefaultTokenDestGasOverhead), uint64(destCfgV2.DefaultTokenDestGasOverhead)}, + {"DefaultTxGasLimit", uint64(v16Cfg.DefaultTxGasLimit), uint64(destCfgV2.DefaultTxGasLimit)}, + {"NetworkFeeUSDCents", uint64(v16Cfg.NetworkFeeUSDCents), uint64(destCfgV2.NetworkFeeUSDCents)}, + }); err != nil { + errs = append(errs, err) + } + } + + // Cross-version field mapping: v2.0 <-> v1.5 + if legacyCfg != nil { + v15Checks := []fieldCheck{ + {"DestGasOverhead", uint64(destCfgV2.DestGasOverhead), uint64(legacyCfg.DestGasOverhead)}, + {"MaxDataBytes", uint64(destCfgV2.MaxDataBytes), uint64(legacyCfg.MaxDataBytes)}, + {"MaxPerMsgGasLimit", uint64(destCfgV2.MaxPerMsgGasLimit), uint64(legacyCfg.MaxPerMsgGasLimit)}, + {"DefaultTokenDestGasOverhead", uint64(destCfgV2.DefaultTokenDestGasOverhead), uint64(legacyCfg.DefaultTokenDestGasOverhead)}, + {"DefaultTokenFeeUSDCents", uint64(destCfgV2.DefaultTokenFeeUSDCents), uint64(legacyCfg.DefaultTokenFeeUSDCents)}, + {"DestGasPerPayloadByteBase", uint64(destCfgV2.DestGasPerPayloadByteBase), uint64(uint8(legacyCfg.DestGasPerPayloadByte))}, //nolint:gosec // G115: intentional v1.5 uint16->uint8 truncation during migration + } + // NetworkFeeUSDCents: per-token in v1.5, per-dest in v2.0. Fetch from v1.5 FeeTokenConfig. if legacyOnRamp := c.EVM2EVMOnRamp[destChainSel]; legacyOnRamp != nil { - legacyCfg, err := legacyOnRamp.GetDynamicConfig(callOpts) - if err != nil { - errs = append(errs, fmt.Errorf("failed to get v1.5 OnRamp dynamic config for dest chain %d (v2.0 cross-check): %w", destChainSel, err)) - } else if err := compareFieldChecks( - fmt.Sprintf("FeeQuoter v2.0 %s dest chain %d", fqAddr, destChainSel), - v20DestCfgLegacyChecks(destCfgV2, legacyCfg), - ); err != nil { - errs = append(errs, err) + v16FeeTokens, ftErr := c.FeeQuoter.GetFeeTokens(callOpts) + if ftErr == nil { + for _, ft := range v16FeeTokens { + legacyFTCfg, err := legacyOnRamp.GetFeeTokenConfig(callOpts, ft) + if err != nil || !legacyFTCfg.Enabled { + continue + } + v15Checks = append(v15Checks, + fieldCheck{"NetworkFeeUSDCents", uint64(destCfgV2.NetworkFeeUSDCents), uint64(legacyFTCfg.NetworkFeeUSDCents)}, + ) + break + } } } + if err := compareFieldChecks("v1.5<->v2.0", v15Checks); err != nil { + errs = append(errs, err) + } } - return errors.Join(errs...) + return groupErrors(header, errs) } -// validateTokenTransferFeeConfigsV20 validates per-token per-dest fee configs for v2.0. -func (c CCIPChainState) validateTokenTransferFeeConfigsV20( +// --- Token Transfer Fee Validation --- + +// validateAllTokenTransferFeeConfigs validates token transfer fees across v1.5, v1.6, and v2.0. +// Cross-version field counts: v1.5↔v1.6 (5), v1.6→v2.0 (FeeUSDCents, DestGasOverhead, DestBytesOverhead; DeciBps+MaxFeeUSDCents dropped). +func (c CCIPChainState) validateAllTokenTransferFeeConfigs( + e cldf.Environment, callOpts *bind.CallOpts, - fqAddr string, connectedChains []uint64, fqV2 *fqv2ops.FeeQuoterContract, ) error { + if c.FeeQuoter == nil && fqV2 == nil { + return nil + } if c.TokenAdminRegistry == nil { - return errors.New("no TokenAdminRegistry contract found, cannot validate v2.0 token transfer fee configs") + return errors.New("no TokenAdminRegistry contract found, cannot validate token transfer fee configs") } allTokens, err := viewshared.GetSupportedTokens(c.TokenAdminRegistry) if err != nil { - return fmt.Errorf("failed to get configured tokens from TokenAdminRegistry for v2.0 validation: %w", err) + return fmt.Errorf("failed to get configured tokens from TokenAdminRegistry: %w", err) } addrToSymbol := make(map[common.Address]string) @@ -840,72 +708,142 @@ func (c CCIPChainState) validateTokenTransferFeeConfigsV20( } } + e.Logger.Debugw("Validating TokenTransferFeeConfigs", "tokens", len(allTokens), "connectedChains", len(connectedChains)) + var mu sync.Mutex var errs []error + var wg sync.WaitGroup + sem := make(chan struct{}, 20) for _, tokenAddr := range allTokens { - tokenLabel := tokenAddr.Hex() - if sym, ok := addrToSymbol[tokenAddr]; ok { - tokenLabel = fmt.Sprintf("%s (%s)", sym, tokenAddr.Hex()) + token := tokenAddr + tokenLabel := token.Hex() + if sym, ok := addrToSymbol[token]; ok { + tokenLabel = fmt.Sprintf("%s (%s)", sym, token.Hex()) } - - for _, destChainSel := range connectedChains { - ttfCfgV2, err := fqV2.GetTokenTransferFeeConfig(callOpts, destChainSel, tokenAddr) - if err != nil { - continue + wg.Add(1) + sem <- struct{}{} + go func() { + defer wg.Done() + defer func() { <-sem }() + var tokenErrs []error + for _, destChainSel := range connectedChains { + if err := c.validateTokenTransferFee(callOpts, destChainSel, token, tokenLabel, fqV2); err != nil { + tokenErrs = append(tokenErrs, err) + } } - if !ttfCfgV2.IsEnabled { - continue + if len(tokenErrs) > 0 { + mu.Lock() + errs = append(errs, tokenErrs...) + mu.Unlock() } - if ttfCfgV2.DestBytesOverhead < globals.CCIPLockOrBurnV1RetBytes { - errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s TokenTransferFeeConfig for token %s to dest chain %d: "+ - "DestBytesOverhead (%d) must be at least %d", - fqAddr, tokenLabel, destChainSel, - ttfCfgV2.DestBytesOverhead, globals.CCIPLockOrBurnV1RetBytes)) + }() + } + wg.Wait() + + return errors.Join(errs...) +} + +// validateTokenTransferFee validates a single token+dest pair across all present FeeQuoter versions. +func (c CCIPChainState) validateTokenTransferFee( + callOpts *bind.CallOpts, + destChainSel uint64, + token common.Address, + tokenLabel string, + fqV2 *fqv2ops.FeeQuoterContract, +) error { + var errs []error + + var v16Cfg *fee_quoter.FeeQuoterTokenTransferFeeConfig + v16Enabled := false + if c.FeeQuoter != nil { + cfg, err := c.FeeQuoter.GetTokenTransferFeeConfig(callOpts, destChainSel, token) + if err == nil { + v16Enabled = cfg.IsEnabled + if cfg.IsEnabled { + v16Cfg = &cfg } + } + } - if c.FeeQuoter == nil { - continue + var v20Cfg *fqv2ops.TokenTransferFeeConfig + v20Enabled := false + if fqV2 != nil { + cfg, err := fqV2.GetTokenTransferFeeConfig(callOpts, destChainSel, token) + if err == nil { + v20Enabled = cfg.IsEnabled + if cfg.IsEnabled { + v20Cfg = &cfg } + } + } - ttfCfgV16, err := c.FeeQuoter.GetTokenTransferFeeConfig(callOpts, destChainSel, tokenAddr) - if err != nil || !ttfCfgV16.IsEnabled { - continue + // Cross-version IsEnabled consistency + if c.FeeQuoter != nil && fqV2 != nil && v16Enabled != v20Enabled { + errs = append(errs, fmt.Errorf("IsEnabled mismatch: v1.6=%v, v2.0=%v", v16Enabled, v20Enabled)) + } + + if v16Cfg == nil && v20Cfg == nil { + header := fmt.Sprintf("token %s dest %d", tokenLabel, destChainSel) + return groupErrors(header, errs) + } + + // v1.6 invariants + v1.5 cross-check + if v16Cfg != nil { + if v16Cfg.MinFeeUSDCents >= v16Cfg.MaxFeeUSDCents { + errs = append(errs, fmt.Errorf("v1.6: MinFeeUSDCents (%d) must be less than MaxFeeUSDCents (%d)", + v16Cfg.MinFeeUSDCents, v16Cfg.MaxFeeUSDCents)) + } + if v16Cfg.DestBytesOverhead < globals.CCIPLockOrBurnV1RetBytes { + errs = append(errs, fmt.Errorf("v1.6: DestBytesOverhead (%d) must be at least %d", + v16Cfg.DestBytesOverhead, globals.CCIPLockOrBurnV1RetBytes)) + } + + // v1.6 <-> v1.5 field mapping + if legacyOnRamp := c.EVM2EVMOnRamp[destChainSel]; legacyOnRamp != nil { + legacyTTF, legacyErr := legacyOnRamp.GetTokenTransferFeeConfig(callOpts, token) + if legacyErr == nil && legacyTTF.IsEnabled { + if err := compareFieldChecks("v1.5<->v1.6", []fieldCheck{ + {"MinFeeUSDCents", uint64(v16Cfg.MinFeeUSDCents), uint64(legacyTTF.MinFeeUSDCents)}, + {"MaxFeeUSDCents", uint64(v16Cfg.MaxFeeUSDCents), uint64(legacyTTF.MaxFeeUSDCents)}, + {"DeciBps", uint64(v16Cfg.DeciBps), uint64(legacyTTF.DeciBps)}, + {"DestGasOverhead", uint64(v16Cfg.DestGasOverhead), uint64(legacyTTF.DestGasOverhead)}, + {"DestBytesOverhead", uint64(v16Cfg.DestBytesOverhead), uint64(legacyTTF.DestBytesOverhead)}, + }); err != nil { + errs = append(errs, err) + } } + } + } + + // v2.0 invariants + cross-checks + if v20Cfg != nil { + if v20Cfg.DestBytesOverhead < globals.CCIPLockOrBurnV1RetBytes { + errs = append(errs, fmt.Errorf("v2.0: DestBytesOverhead (%d) must be at least %d", + v20Cfg.DestBytesOverhead, globals.CCIPLockOrBurnV1RetBytes)) + } - // FeeUSDCents replaces MinFeeUSDCents — values must match - if ttfCfgV2.FeeUSDCents != ttfCfgV16.MinFeeUSDCents { - errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s TokenTransferFeeConfig for token %s to dest %d: "+ - "FeeUSDCents (%d) != v1.6 MinFeeUSDCents (%d)", - fqAddr, tokenLabel, destChainSel, ttfCfgV2.FeeUSDCents, ttfCfgV16.MinFeeUSDCents)) + // v2.0 <-> v1.6 field mapping + if v16Cfg != nil { + if v20Cfg.FeeUSDCents != v16Cfg.MinFeeUSDCents { + errs = append(errs, fmt.Errorf("v2.0: FeeUSDCents (%d) != v1.6 MinFeeUSDCents (%d)", + v20Cfg.FeeUSDCents, v16Cfg.MinFeeUSDCents)) } - // DeciBps removed in v2.0; a non-zero v1.6 value means percentage fee was active and is now lost - if ttfCfgV16.DeciBps > 0 { - errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s TokenTransferFeeConfig for token %s to dest %d: "+ - "v1.6 DeciBps=%d is non-zero but DeciBps is removed in v2.0 (percentage fee lost)", - fqAddr, tokenLabel, destChainSel, ttfCfgV16.DeciBps)) + if v16Cfg.DeciBps > 0 { + errs = append(errs, fmt.Errorf("v2.0: v1.6 DeciBps=%d is non-zero but removed in v2.0 (percentage fee lost)", + v16Cfg.DeciBps)) } - // MaxFeeUSDCents removed in v2.0; a non-trivial cap means fee ceiling would be lost - if ttfCfgV16.MaxFeeUSDCents > ttfCfgV16.MinFeeUSDCents { - errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s TokenTransferFeeConfig for token %s to dest %d: "+ - "v1.6 MaxFeeUSDCents (%d) > MinFeeUSDCents (%d) — fee cap is not present in v2.0", - fqAddr, tokenLabel, destChainSel, ttfCfgV16.MaxFeeUSDCents, ttfCfgV16.MinFeeUSDCents)) + if v16Cfg.MaxFeeUSDCents > v16Cfg.MinFeeUSDCents { + errs = append(errs, fmt.Errorf("v2.0: v1.6 MaxFeeUSDCents (%d) > MinFeeUSDCents (%d) -- fee cap not present in v2.0", + v16Cfg.MaxFeeUSDCents, v16Cfg.MinFeeUSDCents)) } - // Overhead fields must match - for _, chk := range []struct { - name string - v20Val uint64 - v16Val uint64 - }{ - {"DestGasOverhead", uint64(ttfCfgV2.DestGasOverhead), uint64(ttfCfgV16.DestGasOverhead)}, - {"DestBytesOverhead", uint64(ttfCfgV2.DestBytesOverhead), uint64(ttfCfgV16.DestBytesOverhead)}, - } { - if chk.v20Val != chk.v16Val { - errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s TokenTransferFeeConfig for token %s to dest %d: "+ - "%s mismatch: v2.0=%d, v1.6=%d", - fqAddr, tokenLabel, destChainSel, chk.name, chk.v20Val, chk.v16Val)) - } + if err := compareFieldChecks("v1.6<->v2.0", []fieldCheck{ + {"DestGasOverhead", uint64(v20Cfg.DestGasOverhead), uint64(v16Cfg.DestGasOverhead)}, + {"DestBytesOverhead", uint64(v20Cfg.DestBytesOverhead), uint64(v16Cfg.DestBytesOverhead)}, + }); err != nil { + errs = append(errs, err) } } } - return errors.Join(errs...) + header := fmt.Sprintf("token %s dest %d", tokenLabel, destChainSel) + return groupErrors(header, errs) } diff --git a/deployment/ccip/shared/stateview/evm/validate_test.go b/deployment/ccip/shared/stateview/evm/validate_test.go index b13ffd0379a..71209bcf576 100644 --- a/deployment/ccip/shared/stateview/evm/validate_test.go +++ b/deployment/ccip/shared/stateview/evm/validate_test.go @@ -208,7 +208,7 @@ func TestValidateFeeQuoter_HappyPath(t *testing.T) { connectedChains, err := chainState.ValidateRouter(tenv.Env, false, v16Active) require.NoError(t, err, "router validation failed for chain %d", sel) - err = chainState.ValidateFeeQuoter(tenv.Env, sel, connectedChains, nil) + err = chainState.ValidateFeeQuoter(tenv.Env, sel, connectedChains, nil, nil) require.NoError(t, err, "FeeQuoter validation failed for chain %d", sel) } } @@ -222,7 +222,7 @@ func TestValidateFeeQuoter_NilFeeQuoter(t *testing.T) { evmChains := tenv.Env.BlockChains.ListChainSelectors(cldf_chain.WithFamily(chain_selectors.FamilyEVM)) chainState := state.MustGetEVMChainState(evmChains[0]) chainState.FeeQuoter = nil - err = chainState.ValidateFeeQuoter(tenv.Env, evmChains[0], evmChains[1:], nil) + err = chainState.ValidateFeeQuoter(tenv.Env, evmChains[0], evmChains[1:], nil, nil) require.Error(t, err) assert.Contains(t, err.Error(), "no FeeQuoter") } diff --git a/deployment/ccip/shared/stateview/state.go b/deployment/ccip/shared/stateview/state.go index f2eecbf5429..e17d500004f 100644 --- a/deployment/ccip/shared/stateview/state.go +++ b/deployment/ccip/shared/stateview/state.go @@ -318,14 +318,13 @@ func (c CCIPOnChainState) runPostDeploymentValidation(e cldf.Environment, valida errs = append(errs, fmt.Errorf("nonce manager: %w", err)) } } - if err := chainState.ValidateFeeQuoter(e, sel, connectedChains, fqV2); err != nil { - errs = append(errs, fmt.Errorf("fee quoter %s: %w", safeAddr(chainState.FeeQuoter), err)) - } - if fqV2 != nil { + { + var backend bind.ContractBackend if evmChain, ok := e.BlockChains.EVMChains()[sel]; ok { - if err := chainState.ValidateFeeQuoterV2(e, sel, connectedChains, fqV2, evmChain.Client); err != nil { - errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s: %w", fqV2.Address().Hex(), err)) - } + backend = evmChain.Client + } + if err := chainState.ValidateFeeQuoter(e, sel, connectedChains, fqV2, backend); err != nil { + errs = append(errs, fmt.Errorf("fee quoter: %w", err)) } } if validateOwnership { From ea7fc3306a44080f9ae87b77504b94900549c5ca Mon Sep 17 00:00:00 2001 From: simsonraj Date: Tue, 24 Mar 2026 15:23:39 +0530 Subject: [PATCH 06/13] Fixes --- .../ccip/shared/stateview/evm/validate.go | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/deployment/ccip/shared/stateview/evm/validate.go b/deployment/ccip/shared/stateview/evm/validate.go index 3c53afaf706..42a0d5da643 100644 --- a/deployment/ccip/shared/stateview/evm/validate.go +++ b/deployment/ccip/shared/stateview/evm/validate.go @@ -390,17 +390,18 @@ func (c CCIPChainState) validateAllFeeTokenConfigs( feeToken.Hex(), fqAddr, err)) continue } - if premium == 0 { - errs = append(errs, fmt.Errorf("FeeQuoter %s PremiumMultiplierWeiPerEth is 0 for fee token %s", - fqAddr, feeToken.Hex())) - } if anyLegacyOnRamp != nil { + // Cross-check against v1.5 first — legacy mismatch is the most actionable signal. legacyFeeTokenCfg, err := anyLegacyOnRamp.GetFeeTokenConfig(callOpts, feeToken) if err == nil && legacyFeeTokenCfg.Enabled && premium != legacyFeeTokenCfg.PremiumMultiplierWeiPerEth { errs = append(errs, fmt.Errorf("FeeQuoter %s PremiumMultiplierWeiPerEth mismatch for fee token %s: "+ "v1.6 has %d, v1.5 OnRamp had %d", fqAddr, feeToken.Hex(), premium, legacyFeeTokenCfg.PremiumMultiplierWeiPerEth)) } + } else if premium == 0 { + // No legacy to compare — flag zero as standalone issue. + errs = append(errs, fmt.Errorf("FeeQuoter %s PremiumMultiplierWeiPerEth is 0 for fee token %s", + fqAddr, feeToken.Hex())) } } } @@ -515,12 +516,13 @@ func (c CCIPChainState) validateV16DestChainConfig( feeTokens []common.Address, ) error { header := fmt.Sprintf("FeeQuoter v1.6 %s dest chain %d", c.FeeQuoter.Address().Hex(), destChainSel) - var errs []error if !destCfg.IsEnabled { - errs = append(errs, errors.New("not enabled")) + return groupErrors(header, []error{errors.New("not enabled — dest chain not configured, skipping field checks")}) } + var errs []error + // Cross-version field mapping: v1.6 <-> v1.5 if legacyCfg != nil { if err := compareFieldChecks("v1.5<->v1.6", []fieldCheck{ @@ -609,12 +611,13 @@ func (c CCIPChainState) validateV20DestChainConfig( fqV2 *fqv2ops.FeeQuoterContract, ) error { header := fmt.Sprintf("FeeQuoter v2.0 %s dest chain %d", fqV2.Address().Hex(), destChainSel) - var errs []error if !destCfgV2.IsEnabled { - errs = append(errs, errors.New("not enabled")) + return groupErrors(header, []error{errors.New("not enabled — dest chain not configured, skipping field checks")}) } + var errs []error + // v2.0 business-rule fields if destCfgV2.ChainFamilySelector == [4]byte{} { errs = append(errs, errors.New("ChainFamilySelector is empty")) From 7bdd75f251228c09c06ca98598d600290603f9f0 Mon Sep 17 00:00:00 2001 From: simsonraj Date: Tue, 24 Mar 2026 16:50:10 +0530 Subject: [PATCH 07/13] Lint fixes & others --- .../ccip/shared/stateview/evm/validate.go | 21 ++++--------------- deployment/ccip/shared/stateview/state.go | 5 +---- 2 files changed, 5 insertions(+), 21 deletions(-) diff --git a/deployment/ccip/shared/stateview/evm/validate.go b/deployment/ccip/shared/stateview/evm/validate.go index 42a0d5da643..5ba9087b1b0 100644 --- a/deployment/ccip/shared/stateview/evm/validate.go +++ b/deployment/ccip/shared/stateview/evm/validate.go @@ -19,7 +19,6 @@ import ( cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" "github.com/smartcontractkit/chainlink/deployment/ccip/changeset/globals" - opsv16 "github.com/smartcontractkit/chainlink/deployment/ccip/operation/evm/v1_6" viewshared "github.com/smartcontractkit/chainlink/deployment/ccip/view/shared" "github.com/smartcontractkit/chainlink/v2/core/capabilities/ccip/ccipevm" ) @@ -558,22 +557,10 @@ func (c CCIPChainState) validateV16DestChainConfig( break } } else { - // No legacy -- validate against canonical defaults. - expected := opsv16.DefaultFeeQuoterDestChainConfig(true, destChainSel) - if err := compareFieldChecks("defaults", []fieldCheck{ - {"MaxNumberOfTokensPerMsg", uint64(destCfg.MaxNumberOfTokensPerMsg), uint64(expected.MaxNumberOfTokensPerMsg)}, - {"MaxDataBytes", uint64(destCfg.MaxDataBytes), uint64(expected.MaxDataBytes)}, - {"MaxPerMsgGasLimit", uint64(destCfg.MaxPerMsgGasLimit), uint64(expected.MaxPerMsgGasLimit)}, - {"DestGasOverhead", uint64(destCfg.DestGasOverhead), uint64(expected.DestGasOverhead)}, - {"DestGasPerPayloadByteBase", uint64(destCfg.DestGasPerPayloadByteBase), uint64(expected.DestGasPerPayloadByteBase)}, - {"DefaultTokenDestGasOverhead", uint64(destCfg.DefaultTokenDestGasOverhead), uint64(expected.DefaultTokenDestGasOverhead)}, - {"DestDataAvailabilityOverheadGas", uint64(destCfg.DestDataAvailabilityOverheadGas), uint64(expected.DestDataAvailabilityOverheadGas)}, - {"DestGasPerDataAvailabilityByte", uint64(destCfg.DestGasPerDataAvailabilityByte), uint64(expected.DestGasPerDataAvailabilityByte)}, - {"DestDataAvailabilityMultiplierBps", uint64(destCfg.DestDataAvailabilityMultiplierBps), uint64(expected.DestDataAvailabilityMultiplierBps)}, - {"GasMultiplierWeiPerEth", destCfg.GasMultiplierWeiPerEth, expected.GasMultiplierWeiPerEth}, - {"DefaultTokenFeeUSDCents", uint64(destCfg.DefaultTokenFeeUSDCents), uint64(expectedDefaultTokenFeeUSDCents(sourceChainSel, destChainSel))}, - }); err != nil { - errs = append(errs, err) + // No legacy to cross-check — validate fee-related fields against expected values. + expectedFee := expectedDefaultTokenFeeUSDCents(sourceChainSel, destChainSel) + if uint64(destCfg.DefaultTokenFeeUSDCents) != uint64(expectedFee) { + errs = append(errs, fmt.Errorf("DefaultTokenFeeUSDCents: got=%d, want=%d", destCfg.DefaultTokenFeeUSDCents, expectedFee)) } } diff --git a/deployment/ccip/shared/stateview/state.go b/deployment/ccip/shared/stateview/state.go index e17d500004f..826d336712b 100644 --- a/deployment/ccip/shared/stateview/state.go +++ b/deployment/ccip/shared/stateview/state.go @@ -291,10 +291,7 @@ func (c CCIPOnChainState) runPostDeploymentValidation(e cldf.Environment, valida fqV2Addr = fqV2.Address() } otherOnRamps := make(map[uint64]common.Address) - useTestRouter := true - if chainState.Router != nil { - useTestRouter = false - } + useTestRouter := chainState.Router == nil connectedChains, routerErr := chainState.ValidateRouter(e, useTestRouter, v16ActiveChains) if routerErr != nil { errs = append(errs, fmt.Errorf("router: %w", routerErr)) From df2dfd018a9c4594f9fda0d52063f55104f1e8e0 Mon Sep 17 00:00:00 2001 From: simsonraj Date: Tue, 24 Mar 2026 17:59:19 +0530 Subject: [PATCH 08/13] run go modtidy --- core/scripts/go.mod | 1 + system-tests/lib/go.mod | 1 + system-tests/tests/go.mod | 1 + 3 files changed, 3 insertions(+) diff --git a/core/scripts/go.mod b/core/scripts/go.mod index 91ac620e082..6369fd93b54 100644 --- a/core/scripts/go.mod +++ b/core/scripts/go.mod @@ -488,6 +488,7 @@ require ( github.com/smartcontractkit/chain-selectors v1.0.97 // indirect github.com/smartcontractkit/chainlink-aptos v0.0.0-20260318173523-755cafb24200 // indirect github.com/smartcontractkit/chainlink-ccip/ccv/chains/evm v0.0.0-20260323224438-d819cb3228e1 // indirect + github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment v0.0.0-20260317185256-d5f7db87ae70 // indirect github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260310183131-8d0f0e383288 // indirect github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260310183131-8d0f0e383288 // indirect github.com/smartcontractkit/chainlink-ccip/deployment v0.0.0-20260317185256-d5f7db87ae70 // indirect diff --git a/system-tests/lib/go.mod b/system-tests/lib/go.mod index 0f160122227..f7968fadcf3 100644 --- a/system-tests/lib/go.mod +++ b/system-tests/lib/go.mod @@ -453,6 +453,7 @@ require ( github.com/smartcontractkit/chainlink-automation v0.8.1 // indirect github.com/smartcontractkit/chainlink-ccip v0.1.1-solana.0.20260317185256-d5f7db87ae70 // indirect github.com/smartcontractkit/chainlink-ccip/ccv/chains/evm v0.0.0-20260323224438-d819cb3228e1 // indirect + github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment v0.0.0-20260317185256-d5f7db87ae70 // indirect github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260310183131-8d0f0e383288 // indirect github.com/smartcontractkit/chainlink-ccip/deployment v0.0.0-20260317185256-d5f7db87ae70 // indirect github.com/smartcontractkit/chainlink-ccv v0.0.0-20260324000441-d4cfddc9f7d2 // indirect diff --git a/system-tests/tests/go.mod b/system-tests/tests/go.mod index fe7d81913a6..628a26ea7d7 100644 --- a/system-tests/tests/go.mod +++ b/system-tests/tests/go.mod @@ -127,6 +127,7 @@ require ( github.com/sigstore/sigstore v1.10.4 // indirect github.com/sigstore/sigstore-go v1.1.4-0.20251124094504-b5fe07a5a7d7 // indirect github.com/smartcontractkit/chainlink-ccip/ccv/chains/evm v0.0.0-20260323224438-d819cb3228e1 // indirect + github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment v0.0.0-20260317185256-d5f7db87ae70 // indirect github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260310183131-8d0f0e383288 // indirect github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260310183131-8d0f0e383288 // indirect github.com/smartcontractkit/chainlink-ccip/deployment v0.0.0-20260317185256-d5f7db87ae70 // indirect From 87146c32d638c6caa65898745f252d7d282d82aa Mon Sep 17 00:00:00 2001 From: simsonraj Date: Wed, 25 Mar 2026 22:50:42 +0530 Subject: [PATCH 09/13] refactor --- deployment/ccip/shared/stateview/evm/state.go | 48 -- .../ccip/shared/stateview/evm/validate.go | 697 +----------------- .../stateview/evm/validate_feequoter.go | 689 +++++++++++++++++ .../stateview/evm/validate_feequoter_test.go | 457 ++++++++++++ .../shared/stateview/evm/validate_test.go | 32 - 5 files changed, 1181 insertions(+), 742 deletions(-) create mode 100644 deployment/ccip/shared/stateview/evm/validate_feequoter.go create mode 100644 deployment/ccip/shared/stateview/evm/validate_feequoter_test.go diff --git a/deployment/ccip/shared/stateview/evm/state.go b/deployment/ccip/shared/stateview/evm/state.go index 46ee77b7d09..7aa0e286d15 100644 --- a/deployment/ccip/shared/stateview/evm/state.go +++ b/deployment/ccip/shared/stateview/evm/state.go @@ -1,7 +1,6 @@ package evm import ( - "context" "errors" "fmt" @@ -476,22 +475,6 @@ func (c CCIPChainState) ValidateOnRamp( return nil } -// chainSelFromConfigs extracts the chain selector from CCIPHome configs, -// falling back through active→candidate for both commit and exec. -func chainSelFromConfigs(commit, exec ccip_home.GetAllConfigs) uint64 { - sel := commit.ActiveConfig.Config.ChainSelector - if sel == 0 { - sel = commit.CandidateConfig.Config.ChainSelector - } - if sel == 0 { - sel = exec.ActiveConfig.Config.ChainSelector - if sel == 0 { - sel = exec.CandidateConfig.Config.ChainSelector - } - } - return sel -} - // ValidateRouter validates the router contract and returns all connected v1.6 chains. // v16ActiveChains filters out legacy v1.5 lane entries in mixed environments. func (c CCIPChainState) ValidateRouter(e cldf.Environment, isTestRouter bool, v16ActiveChains map[uint64]bool) ([]uint64, error) { @@ -554,37 +537,6 @@ func (c CCIPChainState) ValidateRouter(e cldf.Environment, isTestRouter bool, v1 return v16ConnectedChains, nil } -// V16ActiveChainSelectors returns chain selectors with an active or candidate -// v1.6 DON config in CCIPHome. Home chain only. -func (c CCIPChainState) V16ActiveChainSelectors(ctx context.Context) (map[uint64]bool, error) { - if c.CCIPHome == nil { - return nil, errors.New("no CCIPHome contract found in the state") - } - if c.CapabilityRegistry == nil { - return nil, errors.New("no CapabilityRegistry contract found in the state") - } - ccipDons, err := shared.GetCCIPDonsFromCapRegistry(ctx, c.CapabilityRegistry) - if err != nil { - return nil, fmt.Errorf("failed to get CCIP DONs from capability registry: %w", err) - } - callOpts := &bind.CallOpts{Context: ctx} - active := make(map[uint64]bool, len(ccipDons)) - for _, don := range ccipDons { - commitConfigs, err := c.CCIPHome.GetAllConfigs(callOpts, don.Id, uint8(types.PluginTypeCCIPCommit)) - if err != nil { - continue - } - execConfigs, err := c.CCIPHome.GetAllConfigs(callOpts, don.Id, uint8(types.PluginTypeCCIPExec)) - if err != nil { - continue - } - if chainSel := chainSelFromConfigs(commitConfigs, execConfigs); chainSel != 0 { - active[chainSel] = true - } - } - return active, nil -} - // ValidateRMNRemote validates the RMNRemote contract to check if all wired contracts are synced with state // and returns whether RMN is enabled for the chain on the RMNRemote // It validates whether RMNRemote is in sync with the RMNHome contract diff --git a/deployment/ccip/shared/stateview/evm/validate.go b/deployment/ccip/shared/stateview/evm/validate.go index 5ba9087b1b0..40196ea0a64 100644 --- a/deployment/ccip/shared/stateview/evm/validate.go +++ b/deployment/ccip/shared/stateview/evm/validate.go @@ -1,26 +1,18 @@ package evm import ( + "context" "errors" "fmt" "strings" - "sync" - "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" - chain_selectors "github.com/smartcontractkit/chain-selectors" - - fqv2ops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/fee_quoter" - fqv2seq "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/sequences" - "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_5_0/evm_2_evm_onramp" - "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_6_3/fee_quoter" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_6_0/ccip_home" cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" - - "github.com/smartcontractkit/chainlink/deployment/ccip/changeset/globals" - viewshared "github.com/smartcontractkit/chainlink/deployment/ccip/view/shared" - "github.com/smartcontractkit/chainlink/v2/core/capabilities/ccip/ccipevm" + "github.com/smartcontractkit/chainlink/deployment/ccip/shared" + "github.com/smartcontractkit/chainlink/v2/core/capabilities/ccip/types" ) // ValidateNonceManager checks NonceManager previous ramps against v1.5 contracts. @@ -88,34 +80,6 @@ func (c CCIPChainState) ValidateRMNProxy(e cldf.Environment) error { // --- Helpers --- -func isEthereumChain(selector uint64) bool { - return selector == chain_selectors.ETHEREUM_MAINNET.Selector || - selector == chain_selectors.ETHEREUM_TESTNET_SEPOLIA.Selector -} - -// expectedNetworkFeeUSDCents: Ethereum involvement → 50, otherwise → 10 -func expectedNetworkFeeUSDCents(srcSel, destSel uint64) uint32 { - if isEthereumChain(destSel) || isEthereumChain(srcSel) { - return 50 - } - return 10 -} - -// expectedDefaultTokenFeeUSDCents: →ETH=150, ETH→=50, →SOL=35, other=25 -func expectedDefaultTokenFeeUSDCents(srcSel, destSel uint64) uint16 { - if isEthereumChain(destSel) { - return 150 - } - if isEthereumChain(srcSel) { - return 50 - } - destFamily, _ := chain_selectors.GetSelectorFamily(destSel) - if destFamily == chain_selectors.FamilySolana { - return 35 - } - return 25 -} - type fieldCheck struct { name string got any @@ -151,45 +115,6 @@ func groupErrors(header string, errs []error) error { return fmt.Errorf("%s:\n %s", header, strings.Join(lines, "\n ")) } -func getFeeTokensV2(callOpts *bind.CallOpts, backend bind.ContractBackend, addr common.Address) ([]common.Address, error) { - parsed, err := abi.JSON(strings.NewReader(fqv2ops.FeeQuoterABI)) - if err != nil { - return nil, fmt.Errorf("failed to parse FeeQuoter v2.0 ABI: %w", err) - } - bc := bind.NewBoundContract(addr, parsed, backend, backend, backend) - var out []any - if err := bc.Call(callOpts, &out, "getFeeTokens"); err != nil { - return nil, fmt.Errorf("failed to call getFeeTokens on FeeQuoter v2.0 %s: %w", addr.Hex(), err) - } - return *abi.ConvertType(out[0], new([]common.Address)).(*[]common.Address), nil -} - -func (c CCIPChainState) validateFeeTokenSuperset( - callOpts *bind.CallOpts, - fqAddr string, - feeTokens []common.Address, -) error { - if c.PriceRegistry == nil { - return nil - } - legacyFeeTokens, err := c.PriceRegistry.GetFeeTokens(callOpts) - if err != nil { - return fmt.Errorf("failed to get fee tokens from v1.5 PriceRegistry: %w", err) - } - feeTokenSet := make(map[common.Address]bool, len(feeTokens)) - for _, ft := range feeTokens { - feeTokenSet[ft] = true - } - var errs []error - for _, legacyFT := range legacyFeeTokens { - if !feeTokenSet[legacyFT] { - errs = append(errs, fmt.Errorf("FeeQuoter %s missing fee token %s from v1.5 PriceRegistry", - fqAddr, legacyFT.Hex())) - } - } - return errors.Join(errs...) -} - // --- Ownership --- type ownableContract interface { @@ -240,600 +165,48 @@ func (c CCIPChainState) ValidateContractOwnership(e cldf.Environment) error { return errors.Join(errs...) } -// ValidateFeeQuoter validates all FeeQuoter contracts (v1.6 and/or v2.0) for a chain -func (c CCIPChainState) ValidateFeeQuoter( - e cldf.Environment, - sourceChainSel uint64, - connectedChains []uint64, - fqV2 *fqv2ops.FeeQuoterContract, - backend bind.ContractBackend, -) error { - if c.FeeQuoter == nil && fqV2 == nil { - return errors.New("no FeeQuoter contract (v1.6 or v2.0) found in the state") - } - callOpts := &bind.CallOpts{Context: e.GetContext()} - var errs []error - - // v1.6 static config checks - var v16FeeTokens []common.Address - v16LaneReady := false - if c.FeeQuoter != nil { - fqAddr := c.FeeQuoter.Address().Hex() - e.Logger.Debugw("Validating FeeQuoter v1.6", "chain", sourceChainSel, "feeQuoter", fqAddr, "connectedChains", len(connectedChains)) - staticConfig, err := c.FeeQuoter.GetStaticConfig(callOpts) - if err != nil { - errs = append(errs, fmt.Errorf("failed to get static config for FeeQuoter %s: %w", fqAddr, err)) - } else { - linktokenAddr, err := c.LinkTokenAddress() - if err != nil { - errs = append(errs, fmt.Errorf("failed to get link token address from state: %w", err)) - } else if staticConfig.LinkToken != linktokenAddr { - errs = append(errs, fmt.Errorf("FeeQuoter %s LinkToken mismatch: expected %s, got %s", - fqAddr, linktokenAddr.Hex(), staticConfig.LinkToken.Hex())) - } - if staticConfig.TokenPriceStalenessThreshold == 0 { - errs = append(errs, fmt.Errorf("FeeQuoter %s: TokenPriceStalenessThreshold is 0", fqAddr)) - } - } - feeTokens, err := c.FeeQuoter.GetFeeTokens(callOpts) - if err != nil { - errs = append(errs, fmt.Errorf("failed to get fee tokens from FeeQuoter %s: %w", fqAddr, err)) - } else { - v16FeeTokens = feeTokens - } - - switch { - case c.FeeQuoterVersion == nil: - errs = append(errs, fmt.Errorf("FeeQuoter %s: version not set, cannot perform lane-level validation", fqAddr)) - case c.FeeQuoterVersion.Major() != 1: - errs = append(errs, fmt.Errorf("FeeQuoter %s: unsupported version %s for lane-level validation", - fqAddr, c.FeeQuoterVersion.String())) - default: - v16LaneReady = len(v16FeeTokens) > 0 - } +// V16ActiveChainSelectors returns chain selectors with an active or candidate v1.6 DON config in CCIPHome +func (c CCIPChainState) V16ActiveChainSelectors(ctx context.Context) (map[uint64]bool, error) { + if c.CCIPHome == nil { + return nil, errors.New("no CCIPHome contract found in the state") } - - // v2.0 static config + owner checks - v20Ready := fqV2 != nil - if fqV2 != nil { - fqAddr := fqV2.Address().Hex() - e.Logger.Debugw("Validating FeeQuoter v2.0", "chain", sourceChainSel, "feeQuoterV2", fqAddr, "connectedChains", len(connectedChains)) - staticConfig, err := fqV2.GetStaticConfig(callOpts) - if err != nil { - errs = append(errs, fmt.Errorf("failed to get static config for FeeQuoter v2.0 %s: %w", fqAddr, err)) - v20Ready = false - } else { - linktokenAddr, err := c.LinkTokenAddress() - if err != nil { - errs = append(errs, fmt.Errorf("failed to get link token address from state: %w", err)) - } else if staticConfig.LinkToken != linktokenAddr { - errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s LinkToken mismatch: expected %s, got %s", - fqAddr, linktokenAddr.Hex(), staticConfig.LinkToken.Hex())) - } - } - owner, err := fqV2.Owner(callOpts) - if err != nil { - errs = append(errs, fmt.Errorf("failed to get owner from FeeQuoter v2.0 %s: %w", fqAddr, err)) - } else if c.Timelock != nil && owner != c.Timelock.Address() { - errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s not owned by Timelock %s, actual owner: %s", - fqAddr, c.Timelock.Address().Hex(), owner.Hex())) - } - } - - var effectiveFqV2 *fqv2ops.FeeQuoterContract - if v20Ready { - effectiveFqV2 = fqV2 + if c.CapabilityRegistry == nil { + return nil, errors.New("no CapabilityRegistry contract found in the state") } - - // Fee token validation (version-aware) - if err := c.validateAllFeeTokenConfigs(callOpts, v16FeeTokens, effectiveFqV2, backend); err != nil { - errs = append(errs, err) - } - - if len(connectedChains) == 0 { - return errors.Join(errs...) - } - - // Dest chain config validation (version-aware). - var laneV16FeeTokens []common.Address - if v16LaneReady { - laneV16FeeTokens = v16FeeTokens - } - if err := c.validateAllDestChainConfigs(callOpts, sourceChainSel, connectedChains, laneV16FeeTokens, effectiveFqV2); err != nil { - errs = append(errs, err) - } - - // Token transfer fee validation (version-aware) - if err := c.validateAllTokenTransferFeeConfigs(e, callOpts, connectedChains, effectiveFqV2); err != nil { - errs = append(errs, err) - } - - return errors.Join(errs...) -} - -// validateAllFeeTokenConfigs validates fee tokens for all present FeeQuoter versions -// v1.6: non-empty, v1.5 PriceRegistry superset, premium multiplier per token, v1.5 cross-check -// v2.0: non-empty, v1.5 PriceRegistry superset -func (c CCIPChainState) validateAllFeeTokenConfigs( - callOpts *bind.CallOpts, - v16FeeTokens []common.Address, - fqV2 *fqv2ops.FeeQuoterContract, - backend bind.ContractBackend, -) error { - var errs []error - - // v1.6 fee token checks - if c.FeeQuoter != nil && v16FeeTokens != nil { - fqAddr := c.FeeQuoter.Address().Hex() - if len(v16FeeTokens) == 0 { - errs = append(errs, fmt.Errorf("FeeQuoter %s has no fee tokens configured", fqAddr)) - } - if err := c.validateFeeTokenSuperset(callOpts, fqAddr, v16FeeTokens); err != nil { - errs = append(errs, err) - } - - // Premium multiplier validation + v1.5 cross-check - var anyLegacyOnRamp *evm_2_evm_onramp.EVM2EVMOnRamp - if c.EVM2EVMOnRamp != nil { - for _, onRamp := range c.EVM2EVMOnRamp { - if onRamp != nil { - anyLegacyOnRamp = onRamp - break - } - } - } - for _, feeToken := range v16FeeTokens { - premium, err := c.FeeQuoter.GetPremiumMultiplierWeiPerEth(callOpts, feeToken) - if err != nil { - errs = append(errs, fmt.Errorf("failed to get PremiumMultiplierWeiPerEth for token %s on FeeQuoter %s: %w", - feeToken.Hex(), fqAddr, err)) - continue - } - if anyLegacyOnRamp != nil { - // Cross-check against v1.5 first — legacy mismatch is the most actionable signal. - legacyFeeTokenCfg, err := anyLegacyOnRamp.GetFeeTokenConfig(callOpts, feeToken) - if err == nil && legacyFeeTokenCfg.Enabled && premium != legacyFeeTokenCfg.PremiumMultiplierWeiPerEth { - errs = append(errs, fmt.Errorf("FeeQuoter %s PremiumMultiplierWeiPerEth mismatch for fee token %s: "+ - "v1.6 has %d, v1.5 OnRamp had %d", - fqAddr, feeToken.Hex(), premium, legacyFeeTokenCfg.PremiumMultiplierWeiPerEth)) - } - } else if premium == 0 { - // No legacy to compare — flag zero as standalone issue. - errs = append(errs, fmt.Errorf("FeeQuoter %s PremiumMultiplierWeiPerEth is 0 for fee token %s", - fqAddr, feeToken.Hex())) - } - } + ccipDons, err := shared.GetCCIPDonsFromCapRegistry(ctx, c.CapabilityRegistry) + if err != nil { + return nil, fmt.Errorf("failed to get CCIP DONs from capability registry: %w", err) } - - // v2.0 fee token checks - if fqV2 != nil { - fqAddr := fqV2.Address().Hex() - feeTokens, err := getFeeTokensV2(callOpts, backend, fqV2.Address()) + callOpts := &bind.CallOpts{Context: ctx} + active := make(map[uint64]bool, len(ccipDons)) + for _, don := range ccipDons { + commitConfigs, err := c.CCIPHome.GetAllConfigs(callOpts, don.Id, uint8(types.PluginTypeCCIPCommit)) if err != nil { - errs = append(errs, fmt.Errorf("failed to get fee tokens from FeeQuoter v2.0 %s: %w", fqAddr, err)) - } else { - if len(feeTokens) == 0 { - errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s has no fee tokens configured", fqAddr)) - } - if err := c.validateFeeTokenSuperset(callOpts, fqAddr, feeTokens); err != nil { - errs = append(errs, err) - } - - // v1.6 <-> v2.0 fee token set comparison: both FeeQuoters must have the same fee tokens - if v16FeeTokens != nil { - v16Set := make(map[common.Address]bool, len(v16FeeTokens)) - for _, ft := range v16FeeTokens { - v16Set[ft] = true - } - v20Set := make(map[common.Address]bool, len(feeTokens)) - for _, ft := range feeTokens { - v20Set[ft] = true - } - for _, ft := range feeTokens { - if !v16Set[ft] { - errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s has fee token %s not present in v1.6 FeeQuoter", - fqAddr, ft.Hex())) - } - } - for _, ft := range v16FeeTokens { - if !v20Set[ft] { - errs = append(errs, fmt.Errorf("FeeQuoter v1.6 has fee token %s not present in v2.0 FeeQuoter %s", - ft.Hex(), fqAddr)) - } - } - } - } - } - - return errors.Join(errs...) -} - -// --- Dest Chain Config Validation --- - -// validateAllDestChainConfigs validates dest chain configs across v1.5, v1.6, and v2.0. -// Cross-version field counts: v1.5↔v1.6 (11), v1.5↔v2.0 (6+NetworkFeeUSDCents), v1.6↔v2.0 (10). -func (c CCIPChainState) validateAllDestChainConfigs( - callOpts *bind.CallOpts, - sourceChainSel uint64, - connectedChains []uint64, - v16FeeTokens []common.Address, - fqV2 *fqv2ops.FeeQuoterContract, -) error { - var errs []error - - for _, destChainSel := range connectedChains { - var v16Cfg *fee_quoter.FeeQuoterDestChainConfig - var v20Cfg *fqv2ops.DestChainConfig - var legacyCfg *evm_2_evm_onramp.EVM2EVMOnRampDynamicConfig - - if c.FeeQuoter != nil && v16FeeTokens != nil { - cfg, err := c.FeeQuoter.GetDestChainConfig(callOpts, destChainSel) - if err != nil { - errs = append(errs, fmt.Errorf("failed to get FeeQuoter v1.6 dest chain config for chain %d: %w", destChainSel, err)) - } else { - v16Cfg = &cfg - } - } - if fqV2 != nil { - cfg, err := fqV2.GetDestChainConfig(callOpts, destChainSel) - if err != nil { - errs = append(errs, fmt.Errorf("failed to get FeeQuoter v2.0 dest chain config for chain %d: %w", destChainSel, err)) - } else { - v20Cfg = &cfg - } - } - if legacyOnRamp := c.EVM2EVMOnRamp[destChainSel]; legacyOnRamp != nil { - cfg, err := legacyOnRamp.GetDynamicConfig(callOpts) - if err != nil { - errs = append(errs, fmt.Errorf("failed to get v1.5 OnRamp dynamic config for dest chain %d: %w", destChainSel, err)) - } else { - legacyCfg = &cfg - } - } - - if v16Cfg != nil { - if err := c.validateV16DestChainConfig(callOpts, sourceChainSel, destChainSel, *v16Cfg, legacyCfg, v16FeeTokens); err != nil { - errs = append(errs, err) - } - } - if v20Cfg != nil { - if err := c.validateV20DestChainConfig(callOpts, sourceChainSel, destChainSel, *v20Cfg, v16Cfg, legacyCfg, fqV2); err != nil { - errs = append(errs, err) - } - } - } - - return errors.Join(errs...) -} - -// validateV16DestChainConfig validates a single v1.6 dest chain config. -func (c CCIPChainState) validateV16DestChainConfig( - callOpts *bind.CallOpts, - sourceChainSel, destChainSel uint64, - destCfg fee_quoter.FeeQuoterDestChainConfig, - legacyCfg *evm_2_evm_onramp.EVM2EVMOnRampDynamicConfig, - feeTokens []common.Address, -) error { - header := fmt.Sprintf("FeeQuoter v1.6 %s dest chain %d", c.FeeQuoter.Address().Hex(), destChainSel) - - if !destCfg.IsEnabled { - return groupErrors(header, []error{errors.New("not enabled — dest chain not configured, skipping field checks")}) - } - - var errs []error - - // Cross-version field mapping: v1.6 <-> v1.5 - if legacyCfg != nil { - if err := compareFieldChecks("v1.5<->v1.6", []fieldCheck{ - {"MaxNumberOfTokensPerMsg", uint64(destCfg.MaxNumberOfTokensPerMsg), uint64(legacyCfg.MaxNumberOfTokensPerMsg)}, - {"DestGasOverhead", uint64(destCfg.DestGasOverhead), uint64(legacyCfg.DestGasOverhead)}, - {"DestDataAvailabilityOverheadGas", uint64(destCfg.DestDataAvailabilityOverheadGas), uint64(legacyCfg.DestDataAvailabilityOverheadGas)}, - {"DestGasPerDataAvailabilityByte", uint64(destCfg.DestGasPerDataAvailabilityByte), uint64(legacyCfg.DestGasPerDataAvailabilityByte)}, - {"DestDataAvailabilityMultiplierBps", uint64(destCfg.DestDataAvailabilityMultiplierBps), uint64(legacyCfg.DestDataAvailabilityMultiplierBps)}, - {"MaxDataBytes", uint64(destCfg.MaxDataBytes), uint64(legacyCfg.MaxDataBytes)}, - {"MaxPerMsgGasLimit", uint64(destCfg.MaxPerMsgGasLimit), uint64(legacyCfg.MaxPerMsgGasLimit)}, - {"DefaultTokenDestGasOverhead", uint64(destCfg.DefaultTokenDestGasOverhead), uint64(legacyCfg.DefaultTokenDestGasOverhead)}, - {"DefaultTokenFeeUSDCents", uint64(destCfg.DefaultTokenFeeUSDCents), uint64(legacyCfg.DefaultTokenFeeUSDCents)}, - {"EnforceOutOfOrder", destCfg.EnforceOutOfOrder, legacyCfg.EnforceOutOfOrder}, - {"DestGasPerPayloadByteBase", uint64(destCfg.DestGasPerPayloadByteBase), uint64(uint8(legacyCfg.DestGasPerPayloadByte))}, //nolint:gosec // G115: intentional v1.5 uint16->uint8 truncation during migration - }); err != nil { - errs = append(errs, err) - } - - // GasMultiplierWeiPerEth moved from per-token to per-dest in v1.6 - for _, ft := range feeTokens { - legacyOnRamp := c.EVM2EVMOnRamp[destChainSel] - if legacyOnRamp == nil { - break - } - legacyFTCfg, err := legacyOnRamp.GetFeeTokenConfig(callOpts, ft) - if err != nil || !legacyFTCfg.Enabled { - continue - } - if destCfg.GasMultiplierWeiPerEth != legacyFTCfg.GasMultiplierWeiPerEth { - errs = append(errs, fmt.Errorf("GasMultiplierWeiPerEth: v1.6=%d, v1.5 FeeTokenConfig=%d", - destCfg.GasMultiplierWeiPerEth, legacyFTCfg.GasMultiplierWeiPerEth)) - } - break - } - } else { - // No legacy to cross-check — validate fee-related fields against expected values. - expectedFee := expectedDefaultTokenFeeUSDCents(sourceChainSel, destChainSel) - if uint64(destCfg.DefaultTokenFeeUSDCents) != uint64(expectedFee) { - errs = append(errs, fmt.Errorf("DefaultTokenFeeUSDCents: got=%d, want=%d", destCfg.DefaultTokenFeeUSDCents, expectedFee)) - } - } - - // v1.6 business-rule fields (always checked) - if destCfg.ChainFamilySelector == [4]byte{} { - errs = append(errs, errors.New("ChainFamilySelector is empty")) - } - if destCfg.GasPriceStalenessThreshold == 0 { - errs = append(errs, errors.New("GasPriceStalenessThreshold is 0")) - } - if err := compareFieldChecks("business rules", []fieldCheck{ - {"DestGasPerPayloadByteHigh", uint64(destCfg.DestGasPerPayloadByteHigh), uint64(ccipevm.CalldataGasPerByteHigh)}, - {"DestGasPerPayloadByteThreshold", uint64(destCfg.DestGasPerPayloadByteThreshold), uint64(ccipevm.CalldataGasPerByteThreshold)}, - {"DefaultTxGasLimit", uint64(destCfg.DefaultTxGasLimit), uint64(200_000)}, - {"NetworkFeeUSDCents", uint64(destCfg.NetworkFeeUSDCents), uint64(expectedNetworkFeeUSDCents(sourceChainSel, destChainSel))}, - }); err != nil { - errs = append(errs, err) - } - - destFamily, _ := chain_selectors.GetSelectorFamily(destChainSel) - if destFamily != chain_selectors.FamilyEVM && !destCfg.EnforceOutOfOrder { - errs = append(errs, fmt.Errorf("EnforceOutOfOrder must be true for non-EVM dest (family %s)", destFamily)) - } - - return groupErrors(header, errs) -} - -// validateV20DestChainConfig validates a single v2.0 dest chain config. -func (c CCIPChainState) validateV20DestChainConfig( - callOpts *bind.CallOpts, - sourceChainSel, destChainSel uint64, - destCfgV2 fqv2ops.DestChainConfig, - v16Cfg *fee_quoter.FeeQuoterDestChainConfig, - legacyCfg *evm_2_evm_onramp.EVM2EVMOnRampDynamicConfig, - fqV2 *fqv2ops.FeeQuoterContract, -) error { - header := fmt.Sprintf("FeeQuoter v2.0 %s dest chain %d", fqV2.Address().Hex(), destChainSel) - - if !destCfgV2.IsEnabled { - return groupErrors(header, []error{errors.New("not enabled — dest chain not configured, skipping field checks")}) - } - - var errs []error - - // v2.0 business-rule fields - if destCfgV2.ChainFamilySelector == [4]byte{} { - errs = append(errs, errors.New("ChainFamilySelector is empty")) - } - if err := compareFieldChecks("business rules", []fieldCheck{ - {"LinkFeeMultiplierPercent", uint64(destCfgV2.LinkFeeMultiplierPercent), uint64(fqv2seq.LinkFeeMultiplierPercent)}, - {"NetworkFeeUSDCents", uint64(destCfgV2.NetworkFeeUSDCents), uint64(expectedNetworkFeeUSDCents(sourceChainSel, destChainSel))}, - {"DefaultTxGasLimit", uint64(destCfgV2.DefaultTxGasLimit), uint64(200_000)}, - }); err != nil { - errs = append(errs, err) - } - - // Cross-version field mapping: v1.6 <-> v2.0 - if v16Cfg != nil { - if err := compareFieldChecks("v1.6<->v2.0", []fieldCheck{ - {"IsEnabled", v16Cfg.IsEnabled, destCfgV2.IsEnabled}, - {"MaxDataBytes", uint64(v16Cfg.MaxDataBytes), uint64(destCfgV2.MaxDataBytes)}, - {"MaxPerMsgGasLimit", uint64(v16Cfg.MaxPerMsgGasLimit), uint64(destCfgV2.MaxPerMsgGasLimit)}, - {"DestGasOverhead", uint64(v16Cfg.DestGasOverhead), uint64(destCfgV2.DestGasOverhead)}, - {"DestGasPerPayloadByteBase", uint64(v16Cfg.DestGasPerPayloadByteBase), uint64(destCfgV2.DestGasPerPayloadByteBase)}, - {"ChainFamilySelector", v16Cfg.ChainFamilySelector, destCfgV2.ChainFamilySelector}, - {"DefaultTokenFeeUSDCents", uint64(v16Cfg.DefaultTokenFeeUSDCents), uint64(destCfgV2.DefaultTokenFeeUSDCents)}, - {"DefaultTokenDestGasOverhead", uint64(v16Cfg.DefaultTokenDestGasOverhead), uint64(destCfgV2.DefaultTokenDestGasOverhead)}, - {"DefaultTxGasLimit", uint64(v16Cfg.DefaultTxGasLimit), uint64(destCfgV2.DefaultTxGasLimit)}, - {"NetworkFeeUSDCents", uint64(v16Cfg.NetworkFeeUSDCents), uint64(destCfgV2.NetworkFeeUSDCents)}, - }); err != nil { - errs = append(errs, err) - } - } - - // Cross-version field mapping: v2.0 <-> v1.5 - if legacyCfg != nil { - v15Checks := []fieldCheck{ - {"DestGasOverhead", uint64(destCfgV2.DestGasOverhead), uint64(legacyCfg.DestGasOverhead)}, - {"MaxDataBytes", uint64(destCfgV2.MaxDataBytes), uint64(legacyCfg.MaxDataBytes)}, - {"MaxPerMsgGasLimit", uint64(destCfgV2.MaxPerMsgGasLimit), uint64(legacyCfg.MaxPerMsgGasLimit)}, - {"DefaultTokenDestGasOverhead", uint64(destCfgV2.DefaultTokenDestGasOverhead), uint64(legacyCfg.DefaultTokenDestGasOverhead)}, - {"DefaultTokenFeeUSDCents", uint64(destCfgV2.DefaultTokenFeeUSDCents), uint64(legacyCfg.DefaultTokenFeeUSDCents)}, - {"DestGasPerPayloadByteBase", uint64(destCfgV2.DestGasPerPayloadByteBase), uint64(uint8(legacyCfg.DestGasPerPayloadByte))}, //nolint:gosec // G115: intentional v1.5 uint16->uint8 truncation during migration - } - // NetworkFeeUSDCents: per-token in v1.5, per-dest in v2.0. Fetch from v1.5 FeeTokenConfig. - if legacyOnRamp := c.EVM2EVMOnRamp[destChainSel]; legacyOnRamp != nil { - v16FeeTokens, ftErr := c.FeeQuoter.GetFeeTokens(callOpts) - if ftErr == nil { - for _, ft := range v16FeeTokens { - legacyFTCfg, err := legacyOnRamp.GetFeeTokenConfig(callOpts, ft) - if err != nil || !legacyFTCfg.Enabled { - continue - } - v15Checks = append(v15Checks, - fieldCheck{"NetworkFeeUSDCents", uint64(destCfgV2.NetworkFeeUSDCents), uint64(legacyFTCfg.NetworkFeeUSDCents)}, - ) - break - } - } - } - if err := compareFieldChecks("v1.5<->v2.0", v15Checks); err != nil { - errs = append(errs, err) + continue } - } - - return groupErrors(header, errs) -} - -// --- Token Transfer Fee Validation --- - -// validateAllTokenTransferFeeConfigs validates token transfer fees across v1.5, v1.6, and v2.0. -// Cross-version field counts: v1.5↔v1.6 (5), v1.6→v2.0 (FeeUSDCents, DestGasOverhead, DestBytesOverhead; DeciBps+MaxFeeUSDCents dropped). -func (c CCIPChainState) validateAllTokenTransferFeeConfigs( - e cldf.Environment, - callOpts *bind.CallOpts, - connectedChains []uint64, - fqV2 *fqv2ops.FeeQuoterContract, -) error { - if c.FeeQuoter == nil && fqV2 == nil { - return nil - } - if c.TokenAdminRegistry == nil { - return errors.New("no TokenAdminRegistry contract found, cannot validate token transfer fee configs") - } - - allTokens, err := viewshared.GetSupportedTokens(c.TokenAdminRegistry) - if err != nil { - return fmt.Errorf("failed to get configured tokens from TokenAdminRegistry: %w", err) - } - - addrToSymbol := make(map[common.Address]string) - if symbolMap, symErr := c.TokenAddressBySymbol(); symErr == nil { - for symbol, addr := range symbolMap { - addrToSymbol[addr] = string(symbol) + execConfigs, err := c.CCIPHome.GetAllConfigs(callOpts, don.Id, uint8(types.PluginTypeCCIPExec)) + if err != nil { + continue } - } - - e.Logger.Debugw("Validating TokenTransferFeeConfigs", "tokens", len(allTokens), "connectedChains", len(connectedChains)) - var mu sync.Mutex - var errs []error - var wg sync.WaitGroup - sem := make(chan struct{}, 20) - for _, tokenAddr := range allTokens { - token := tokenAddr - tokenLabel := token.Hex() - if sym, ok := addrToSymbol[token]; ok { - tokenLabel = fmt.Sprintf("%s (%s)", sym, token.Hex()) + if chainSel := chainSelFromConfigs(commitConfigs, execConfigs); chainSel != 0 { + active[chainSel] = true } - wg.Add(1) - sem <- struct{}{} - go func() { - defer wg.Done() - defer func() { <-sem }() - var tokenErrs []error - for _, destChainSel := range connectedChains { - if err := c.validateTokenTransferFee(callOpts, destChainSel, token, tokenLabel, fqV2); err != nil { - tokenErrs = append(tokenErrs, err) - } - } - if len(tokenErrs) > 0 { - mu.Lock() - errs = append(errs, tokenErrs...) - mu.Unlock() - } - }() } - wg.Wait() - - return errors.Join(errs...) + return active, nil } -// validateTokenTransferFee validates a single token+dest pair across all present FeeQuoter versions. -func (c CCIPChainState) validateTokenTransferFee( - callOpts *bind.CallOpts, - destChainSel uint64, - token common.Address, - tokenLabel string, - fqV2 *fqv2ops.FeeQuoterContract, -) error { - var errs []error - - var v16Cfg *fee_quoter.FeeQuoterTokenTransferFeeConfig - v16Enabled := false - if c.FeeQuoter != nil { - cfg, err := c.FeeQuoter.GetTokenTransferFeeConfig(callOpts, destChainSel, token) - if err == nil { - v16Enabled = cfg.IsEnabled - if cfg.IsEnabled { - v16Cfg = &cfg - } - } +// chainSelFromConfigs extracts the chain selector from CCIPHome configs, +// falling back through active→candidate for both commit and exec +func chainSelFromConfigs(commit, exec ccip_home.GetAllConfigs) uint64 { + sel := commit.ActiveConfig.Config.ChainSelector + if sel == 0 { + sel = commit.CandidateConfig.Config.ChainSelector } - - var v20Cfg *fqv2ops.TokenTransferFeeConfig - v20Enabled := false - if fqV2 != nil { - cfg, err := fqV2.GetTokenTransferFeeConfig(callOpts, destChainSel, token) - if err == nil { - v20Enabled = cfg.IsEnabled - if cfg.IsEnabled { - v20Cfg = &cfg - } + if sel == 0 { + sel = exec.ActiveConfig.Config.ChainSelector + if sel == 0 { + sel = exec.CandidateConfig.Config.ChainSelector } } - - // Cross-version IsEnabled consistency - if c.FeeQuoter != nil && fqV2 != nil && v16Enabled != v20Enabled { - errs = append(errs, fmt.Errorf("IsEnabled mismatch: v1.6=%v, v2.0=%v", v16Enabled, v20Enabled)) - } - - if v16Cfg == nil && v20Cfg == nil { - header := fmt.Sprintf("token %s dest %d", tokenLabel, destChainSel) - return groupErrors(header, errs) - } - - // v1.6 invariants + v1.5 cross-check - if v16Cfg != nil { - if v16Cfg.MinFeeUSDCents >= v16Cfg.MaxFeeUSDCents { - errs = append(errs, fmt.Errorf("v1.6: MinFeeUSDCents (%d) must be less than MaxFeeUSDCents (%d)", - v16Cfg.MinFeeUSDCents, v16Cfg.MaxFeeUSDCents)) - } - if v16Cfg.DestBytesOverhead < globals.CCIPLockOrBurnV1RetBytes { - errs = append(errs, fmt.Errorf("v1.6: DestBytesOverhead (%d) must be at least %d", - v16Cfg.DestBytesOverhead, globals.CCIPLockOrBurnV1RetBytes)) - } - - // v1.6 <-> v1.5 field mapping - if legacyOnRamp := c.EVM2EVMOnRamp[destChainSel]; legacyOnRamp != nil { - legacyTTF, legacyErr := legacyOnRamp.GetTokenTransferFeeConfig(callOpts, token) - if legacyErr == nil && legacyTTF.IsEnabled { - if err := compareFieldChecks("v1.5<->v1.6", []fieldCheck{ - {"MinFeeUSDCents", uint64(v16Cfg.MinFeeUSDCents), uint64(legacyTTF.MinFeeUSDCents)}, - {"MaxFeeUSDCents", uint64(v16Cfg.MaxFeeUSDCents), uint64(legacyTTF.MaxFeeUSDCents)}, - {"DeciBps", uint64(v16Cfg.DeciBps), uint64(legacyTTF.DeciBps)}, - {"DestGasOverhead", uint64(v16Cfg.DestGasOverhead), uint64(legacyTTF.DestGasOverhead)}, - {"DestBytesOverhead", uint64(v16Cfg.DestBytesOverhead), uint64(legacyTTF.DestBytesOverhead)}, - }); err != nil { - errs = append(errs, err) - } - } - } - } - - // v2.0 invariants + cross-checks - if v20Cfg != nil { - if v20Cfg.DestBytesOverhead < globals.CCIPLockOrBurnV1RetBytes { - errs = append(errs, fmt.Errorf("v2.0: DestBytesOverhead (%d) must be at least %d", - v20Cfg.DestBytesOverhead, globals.CCIPLockOrBurnV1RetBytes)) - } - - // v2.0 <-> v1.6 field mapping - if v16Cfg != nil { - if v20Cfg.FeeUSDCents != v16Cfg.MinFeeUSDCents { - errs = append(errs, fmt.Errorf("v2.0: FeeUSDCents (%d) != v1.6 MinFeeUSDCents (%d)", - v20Cfg.FeeUSDCents, v16Cfg.MinFeeUSDCents)) - } - if v16Cfg.DeciBps > 0 { - errs = append(errs, fmt.Errorf("v2.0: v1.6 DeciBps=%d is non-zero but removed in v2.0 (percentage fee lost)", - v16Cfg.DeciBps)) - } - if v16Cfg.MaxFeeUSDCents > v16Cfg.MinFeeUSDCents { - errs = append(errs, fmt.Errorf("v2.0: v1.6 MaxFeeUSDCents (%d) > MinFeeUSDCents (%d) -- fee cap not present in v2.0", - v16Cfg.MaxFeeUSDCents, v16Cfg.MinFeeUSDCents)) - } - if err := compareFieldChecks("v1.6<->v2.0", []fieldCheck{ - {"DestGasOverhead", uint64(v20Cfg.DestGasOverhead), uint64(v16Cfg.DestGasOverhead)}, - {"DestBytesOverhead", uint64(v20Cfg.DestBytesOverhead), uint64(v16Cfg.DestBytesOverhead)}, - }); err != nil { - errs = append(errs, err) - } - } - } - - header := fmt.Sprintf("token %s dest %d", tokenLabel, destChainSel) - return groupErrors(header, errs) + return sel } diff --git a/deployment/ccip/shared/stateview/evm/validate_feequoter.go b/deployment/ccip/shared/stateview/evm/validate_feequoter.go new file mode 100644 index 00000000000..187538b249c --- /dev/null +++ b/deployment/ccip/shared/stateview/evm/validate_feequoter.go @@ -0,0 +1,689 @@ +package evm + +import ( + "errors" + "fmt" + "strings" + "sync" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + + chain_selectors "github.com/smartcontractkit/chain-selectors" + + fqv2ops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/fee_quoter" + fqv2seq "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/sequences" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_5_0/evm_2_evm_onramp" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_6_3/fee_quoter" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + + "github.com/smartcontractkit/chainlink/deployment/ccip/changeset/globals" + viewshared "github.com/smartcontractkit/chainlink/deployment/ccip/view/shared" + "github.com/smartcontractkit/chainlink/v2/core/capabilities/ccip/ccipevm" +) + +// --- FeeQuoter Helpers --- + +func isEthereumChain(selector uint64) bool { + return selector == chain_selectors.ETHEREUM_MAINNET.Selector || + selector == chain_selectors.ETHEREUM_TESTNET_SEPOLIA.Selector +} + +// expectedNetworkFeeUSDCents: Ethereum involvement → 50, otherwise → 10 +func expectedNetworkFeeUSDCents(srcSel, destSel uint64) uint32 { + if isEthereumChain(destSel) || isEthereumChain(srcSel) { + return 50 + } + return 10 +} + +// expectedDefaultTokenFeeUSDCents: →ETH=150, ETH→=50, →SOL=35, other=25 +func expectedDefaultTokenFeeUSDCents(srcSel, destSel uint64) uint16 { + if isEthereumChain(destSel) { + return 150 + } + if isEthereumChain(srcSel) { + return 50 + } + destFamily, _ := chain_selectors.GetSelectorFamily(destSel) + if destFamily == chain_selectors.FamilySolana { + return 35 + } + return 25 +} + +func getFeeTokensV2(callOpts *bind.CallOpts, backend bind.ContractBackend, addr common.Address) ([]common.Address, error) { + parsed, err := abi.JSON(strings.NewReader(fqv2ops.FeeQuoterABI)) + if err != nil { + return nil, fmt.Errorf("failed to parse FeeQuoter v2.0 ABI: %w", err) + } + bc := bind.NewBoundContract(addr, parsed, backend, backend, backend) + var out []any + if err := bc.Call(callOpts, &out, "getFeeTokens"); err != nil { + return nil, fmt.Errorf("failed to call getFeeTokens on FeeQuoter v2.0 %s: %w", addr.Hex(), err) + } + return *abi.ConvertType(out[0], new([]common.Address)).(*[]common.Address), nil +} + +func (c CCIPChainState) validateFeeTokenSuperset( + callOpts *bind.CallOpts, + fqAddr string, + feeTokens []common.Address, +) error { + if c.PriceRegistry == nil { + return nil + } + legacyFeeTokens, err := c.PriceRegistry.GetFeeTokens(callOpts) + if err != nil { + return fmt.Errorf("failed to get fee tokens from v1.5 PriceRegistry: %w", err) + } + feeTokenSet := make(map[common.Address]bool, len(feeTokens)) + for _, ft := range feeTokens { + feeTokenSet[ft] = true + } + var errs []error + for _, legacyFT := range legacyFeeTokens { + if !feeTokenSet[legacyFT] { + errs = append(errs, fmt.Errorf("FeeQuoter %s missing fee token %s from v1.5 PriceRegistry", + fqAddr, legacyFT.Hex())) + } + } + return errors.Join(errs...) +} + +// --- FeeQuoter Validation --- + +// ValidateFeeQuoter validates all FeeQuoter contracts (v1.6 and/or v2.0) for a chain +func (c CCIPChainState) ValidateFeeQuoter( + e cldf.Environment, + sourceChainSel uint64, + connectedChains []uint64, + fqV2 *fqv2ops.FeeQuoterContract, + backend bind.ContractBackend, +) error { + if c.FeeQuoter == nil && fqV2 == nil { + return errors.New("no FeeQuoter contract (v1.6 or v2.0) found in the state") + } + callOpts := &bind.CallOpts{Context: e.GetContext()} + var errs []error + + // v1.6 static config checks + var v16FeeTokens []common.Address + v16LaneReady := false + if c.FeeQuoter != nil { + fqAddr := c.FeeQuoter.Address().Hex() + e.Logger.Debugw("Validating FeeQuoter v1.6", "chain", sourceChainSel, "feeQuoter", fqAddr, "connectedChains", len(connectedChains)) + staticConfig, err := c.FeeQuoter.GetStaticConfig(callOpts) + if err != nil { + errs = append(errs, fmt.Errorf("failed to get static config for FeeQuoter %s: %w", fqAddr, err)) + } else { + linktokenAddr, err := c.LinkTokenAddress() + if err != nil { + errs = append(errs, fmt.Errorf("failed to get link token address from state: %w", err)) + } else if staticConfig.LinkToken != linktokenAddr { + errs = append(errs, fmt.Errorf("FeeQuoter %s LinkToken mismatch: expected %s, got %s", + fqAddr, linktokenAddr.Hex(), staticConfig.LinkToken.Hex())) + } + if staticConfig.TokenPriceStalenessThreshold == 0 { + errs = append(errs, fmt.Errorf("FeeQuoter %s: TokenPriceStalenessThreshold is 0", fqAddr)) + } + } + feeTokens, err := c.FeeQuoter.GetFeeTokens(callOpts) + if err != nil { + errs = append(errs, fmt.Errorf("failed to get fee tokens from FeeQuoter %s: %w", fqAddr, err)) + } else { + v16FeeTokens = feeTokens + } + + switch { + case c.FeeQuoterVersion == nil: + errs = append(errs, fmt.Errorf("FeeQuoter %s: version not set, cannot perform lane-level validation", fqAddr)) + case c.FeeQuoterVersion.Major() != 1: + errs = append(errs, fmt.Errorf("FeeQuoter %s: unsupported version %s for lane-level validation", + fqAddr, c.FeeQuoterVersion.String())) + default: + v16LaneReady = len(v16FeeTokens) > 0 + } + } + + // v2.0 static config + owner checks + v20Ready := fqV2 != nil + if fqV2 != nil { + fqAddr := fqV2.Address().Hex() + e.Logger.Debugw("Validating FeeQuoter v2.0", "chain", sourceChainSel, "feeQuoterV2", fqAddr, "connectedChains", len(connectedChains)) + staticConfig, err := fqV2.GetStaticConfig(callOpts) + if err != nil { + errs = append(errs, fmt.Errorf("failed to get static config for FeeQuoter v2.0 %s: %w", fqAddr, err)) + v20Ready = false + } else { + linktokenAddr, err := c.LinkTokenAddress() + if err != nil { + errs = append(errs, fmt.Errorf("failed to get link token address from state: %w", err)) + } else if staticConfig.LinkToken != linktokenAddr { + errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s LinkToken mismatch: expected %s, got %s", + fqAddr, linktokenAddr.Hex(), staticConfig.LinkToken.Hex())) + } + } + owner, err := fqV2.Owner(callOpts) + if err != nil { + errs = append(errs, fmt.Errorf("failed to get owner from FeeQuoter v2.0 %s: %w", fqAddr, err)) + } else if c.Timelock != nil && owner != c.Timelock.Address() { + errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s not owned by Timelock %s, actual owner: %s", + fqAddr, c.Timelock.Address().Hex(), owner.Hex())) + } + } + + var effectiveFqV2 *fqv2ops.FeeQuoterContract + if v20Ready { + effectiveFqV2 = fqV2 + } + + // Fee token validation (version-aware) + if err := c.validateAllFeeTokenConfigs(callOpts, v16FeeTokens, effectiveFqV2, backend); err != nil { + errs = append(errs, err) + } + + if len(connectedChains) == 0 { + return errors.Join(errs...) + } + + // Dest chain config validation (version-aware). + var laneV16FeeTokens []common.Address + if v16LaneReady { + laneV16FeeTokens = v16FeeTokens + } + if err := c.validateAllDestChainConfigs(callOpts, sourceChainSel, connectedChains, laneV16FeeTokens, effectiveFqV2); err != nil { + errs = append(errs, err) + } + + // Token transfer fee validation (version-aware) + if err := c.validateAllTokenTransferFeeConfigs(e, callOpts, connectedChains, effectiveFqV2); err != nil { + errs = append(errs, err) + } + + return errors.Join(errs...) +} + +// validateAllFeeTokenConfigs validates fee tokens for all present FeeQuoter versions +// v1.6: non-empty, v1.5 PriceRegistry superset, premium multiplier per token, v1.5 cross-check +// v2.0: non-empty, v1.5 PriceRegistry superset +func (c CCIPChainState) validateAllFeeTokenConfigs( + callOpts *bind.CallOpts, + v16FeeTokens []common.Address, + fqV2 *fqv2ops.FeeQuoterContract, + backend bind.ContractBackend, +) error { + var errs []error + + // v1.6 fee token checks + if c.FeeQuoter != nil && v16FeeTokens != nil { + fqAddr := c.FeeQuoter.Address().Hex() + if len(v16FeeTokens) == 0 { + errs = append(errs, fmt.Errorf("FeeQuoter %s has no fee tokens configured", fqAddr)) + } + if err := c.validateFeeTokenSuperset(callOpts, fqAddr, v16FeeTokens); err != nil { + errs = append(errs, err) + } + + // Premium multiplier validation + v1.5 cross-check + var anyLegacyOnRamp *evm_2_evm_onramp.EVM2EVMOnRamp + if c.EVM2EVMOnRamp != nil { + for _, onRamp := range c.EVM2EVMOnRamp { + if onRamp != nil { + anyLegacyOnRamp = onRamp + break + } + } + } + for _, feeToken := range v16FeeTokens { + premium, err := c.FeeQuoter.GetPremiumMultiplierWeiPerEth(callOpts, feeToken) + if err != nil { + errs = append(errs, fmt.Errorf("failed to get PremiumMultiplierWeiPerEth for token %s on FeeQuoter %s: %w", + feeToken.Hex(), fqAddr, err)) + continue + } + if anyLegacyOnRamp != nil { + // Cross-check against v1.5 first — legacy mismatch is the most actionable signal. + legacyFeeTokenCfg, err := anyLegacyOnRamp.GetFeeTokenConfig(callOpts, feeToken) + if err == nil && legacyFeeTokenCfg.Enabled && premium != legacyFeeTokenCfg.PremiumMultiplierWeiPerEth { + errs = append(errs, fmt.Errorf("FeeQuoter %s PremiumMultiplierWeiPerEth mismatch for fee token %s: "+ + "v1.6 has %d, v1.5 OnRamp had %d", + fqAddr, feeToken.Hex(), premium, legacyFeeTokenCfg.PremiumMultiplierWeiPerEth)) + } + } else if premium == 0 { + // No legacy to compare — flag zero as standalone issue. + errs = append(errs, fmt.Errorf("FeeQuoter %s PremiumMultiplierWeiPerEth is 0 for fee token %s", + fqAddr, feeToken.Hex())) + } + } + } + + // v2.0 fee token checks + if fqV2 != nil { + fqAddr := fqV2.Address().Hex() + feeTokens, err := getFeeTokensV2(callOpts, backend, fqV2.Address()) + if err != nil { + errs = append(errs, fmt.Errorf("failed to get fee tokens from FeeQuoter v2.0 %s: %w", fqAddr, err)) + } else { + if len(feeTokens) == 0 { + errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s has no fee tokens configured", fqAddr)) + } + if err := c.validateFeeTokenSuperset(callOpts, fqAddr, feeTokens); err != nil { + errs = append(errs, err) + } + + // v1.6 <-> v2.0 fee token set comparison: both FeeQuoters must have the same fee tokens + if v16FeeTokens != nil { + v16Set := make(map[common.Address]bool, len(v16FeeTokens)) + for _, ft := range v16FeeTokens { + v16Set[ft] = true + } + v20Set := make(map[common.Address]bool, len(feeTokens)) + for _, ft := range feeTokens { + v20Set[ft] = true + } + for _, ft := range feeTokens { + if !v16Set[ft] { + errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s has fee token %s not present in v1.6 FeeQuoter", + fqAddr, ft.Hex())) + } + } + for _, ft := range v16FeeTokens { + if !v20Set[ft] { + errs = append(errs, fmt.Errorf("FeeQuoter v1.6 has fee token %s not present in v2.0 FeeQuoter %s", + ft.Hex(), fqAddr)) + } + } + } + } + } + + return errors.Join(errs...) +} + +// validateAllDestChainConfigs validates dest chain configs across v1.5, v1.6, and v2.0 +// Cross-version field counts: v1.5↔v1.6 (11), v1.5↔v2.0 (6+NetworkFeeUSDCents), v1.6↔v2.0 (10) +func (c CCIPChainState) validateAllDestChainConfigs( + callOpts *bind.CallOpts, + sourceChainSel uint64, + connectedChains []uint64, + v16FeeTokens []common.Address, + fqV2 *fqv2ops.FeeQuoterContract, +) error { + var errs []error + + for _, destChainSel := range connectedChains { + var v16Cfg *fee_quoter.FeeQuoterDestChainConfig + var v20Cfg *fqv2ops.DestChainConfig + var legacyCfg *evm_2_evm_onramp.EVM2EVMOnRampDynamicConfig + + if c.FeeQuoter != nil && v16FeeTokens != nil { + cfg, err := c.FeeQuoter.GetDestChainConfig(callOpts, destChainSel) + if err != nil { + errs = append(errs, fmt.Errorf("failed to get FeeQuoter v1.6 dest chain config for chain %d: %w", destChainSel, err)) + } else { + v16Cfg = &cfg + } + } + if fqV2 != nil { + cfg, err := fqV2.GetDestChainConfig(callOpts, destChainSel) + if err != nil { + errs = append(errs, fmt.Errorf("failed to get FeeQuoter v2.0 dest chain config for chain %d: %w", destChainSel, err)) + } else { + v20Cfg = &cfg + } + } + if legacyOnRamp := c.EVM2EVMOnRamp[destChainSel]; legacyOnRamp != nil { + cfg, err := legacyOnRamp.GetDynamicConfig(callOpts) + if err != nil { + errs = append(errs, fmt.Errorf("failed to get v1.5 OnRamp dynamic config for dest chain %d: %w", destChainSel, err)) + } else { + legacyCfg = &cfg + } + } + + if v16Cfg != nil { + if err := c.validateV16DestChainConfig(callOpts, sourceChainSel, destChainSel, *v16Cfg, legacyCfg, v16FeeTokens); err != nil { + errs = append(errs, err) + } + } + if v20Cfg != nil { + if err := c.validateV20DestChainConfig(callOpts, sourceChainSel, destChainSel, *v20Cfg, v16Cfg, legacyCfg, fqV2); err != nil { + errs = append(errs, err) + } + } + } + + return errors.Join(errs...) +} + +// validateV16DestChainConfig validates a single v1.6 dest chain config +func (c CCIPChainState) validateV16DestChainConfig( + callOpts *bind.CallOpts, + sourceChainSel, destChainSel uint64, + destCfg fee_quoter.FeeQuoterDestChainConfig, + legacyCfg *evm_2_evm_onramp.EVM2EVMOnRampDynamicConfig, + feeTokens []common.Address, +) error { + header := fmt.Sprintf("FeeQuoter v1.6 %s dest chain %d", c.FeeQuoter.Address().Hex(), destChainSel) + + if !destCfg.IsEnabled { + return groupErrors(header, []error{errors.New("not enabled — dest chain not configured, skipping field checks")}) + } + + var errs []error + + // Cross-version field mapping: v1.6 <-> v1.5 + if legacyCfg != nil { + if err := compareFieldChecks("v1.5<->v1.6", []fieldCheck{ + {"MaxNumberOfTokensPerMsg", uint64(destCfg.MaxNumberOfTokensPerMsg), uint64(legacyCfg.MaxNumberOfTokensPerMsg)}, + {"DestGasOverhead", uint64(destCfg.DestGasOverhead), uint64(legacyCfg.DestGasOverhead)}, + {"DestDataAvailabilityOverheadGas", uint64(destCfg.DestDataAvailabilityOverheadGas), uint64(legacyCfg.DestDataAvailabilityOverheadGas)}, + {"DestGasPerDataAvailabilityByte", uint64(destCfg.DestGasPerDataAvailabilityByte), uint64(legacyCfg.DestGasPerDataAvailabilityByte)}, + {"DestDataAvailabilityMultiplierBps", uint64(destCfg.DestDataAvailabilityMultiplierBps), uint64(legacyCfg.DestDataAvailabilityMultiplierBps)}, + {"MaxDataBytes", uint64(destCfg.MaxDataBytes), uint64(legacyCfg.MaxDataBytes)}, + {"MaxPerMsgGasLimit", uint64(destCfg.MaxPerMsgGasLimit), uint64(legacyCfg.MaxPerMsgGasLimit)}, + {"DefaultTokenDestGasOverhead", uint64(destCfg.DefaultTokenDestGasOverhead), uint64(legacyCfg.DefaultTokenDestGasOverhead)}, + {"DefaultTokenFeeUSDCents", uint64(destCfg.DefaultTokenFeeUSDCents), uint64(legacyCfg.DefaultTokenFeeUSDCents)}, + {"EnforceOutOfOrder", destCfg.EnforceOutOfOrder, legacyCfg.EnforceOutOfOrder}, + {"DestGasPerPayloadByteBase", uint64(destCfg.DestGasPerPayloadByteBase), uint64(uint8(legacyCfg.DestGasPerPayloadByte))}, //nolint:gosec // G115: intentional v1.5 uint16->uint8 truncation during migration + }); err != nil { + errs = append(errs, err) + } + + // GasMultiplierWeiPerEth moved from per-token to per-dest in v1.6 + for _, ft := range feeTokens { + legacyOnRamp := c.EVM2EVMOnRamp[destChainSel] + if legacyOnRamp == nil { + break + } + legacyFTCfg, err := legacyOnRamp.GetFeeTokenConfig(callOpts, ft) + if err != nil || !legacyFTCfg.Enabled { + continue + } + if destCfg.GasMultiplierWeiPerEth != legacyFTCfg.GasMultiplierWeiPerEth { + errs = append(errs, fmt.Errorf("GasMultiplierWeiPerEth: v1.6=%d, v1.5 FeeTokenConfig=%d", + destCfg.GasMultiplierWeiPerEth, legacyFTCfg.GasMultiplierWeiPerEth)) + } + break + } + } else { + // No legacy to cross-check — validate fee-related fields against expected values + expectedFee := expectedDefaultTokenFeeUSDCents(sourceChainSel, destChainSel) + if uint64(destCfg.DefaultTokenFeeUSDCents) != uint64(expectedFee) { + errs = append(errs, fmt.Errorf("DefaultTokenFeeUSDCents: got=%d, want=%d", destCfg.DefaultTokenFeeUSDCents, expectedFee)) + } + } + + // v1.6 business-rule fields (always checked) + if destCfg.ChainFamilySelector == [4]byte{} { + errs = append(errs, errors.New("ChainFamilySelector is empty")) + } + if destCfg.GasPriceStalenessThreshold == 0 { + errs = append(errs, errors.New("GasPriceStalenessThreshold is 0")) + } + if err := compareFieldChecks("business rules", []fieldCheck{ + {"DestGasPerPayloadByteHigh", uint64(destCfg.DestGasPerPayloadByteHigh), uint64(ccipevm.CalldataGasPerByteHigh)}, + {"DestGasPerPayloadByteThreshold", uint64(destCfg.DestGasPerPayloadByteThreshold), uint64(ccipevm.CalldataGasPerByteThreshold)}, + {"DefaultTxGasLimit", uint64(destCfg.DefaultTxGasLimit), uint64(200_000)}, + {"NetworkFeeUSDCents", uint64(destCfg.NetworkFeeUSDCents), uint64(expectedNetworkFeeUSDCents(sourceChainSel, destChainSel))}, + }); err != nil { + errs = append(errs, err) + } + + destFamily, _ := chain_selectors.GetSelectorFamily(destChainSel) + if destFamily != chain_selectors.FamilyEVM && !destCfg.EnforceOutOfOrder { + errs = append(errs, fmt.Errorf("EnforceOutOfOrder must be true for non-EVM dest (family %s)", destFamily)) + } + + return groupErrors(header, errs) +} + +// validateV20DestChainConfig validates a single v2.0 dest chain config +func (c CCIPChainState) validateV20DestChainConfig( + callOpts *bind.CallOpts, + sourceChainSel, destChainSel uint64, + destCfgV2 fqv2ops.DestChainConfig, + v16Cfg *fee_quoter.FeeQuoterDestChainConfig, + legacyCfg *evm_2_evm_onramp.EVM2EVMOnRampDynamicConfig, + fqV2 *fqv2ops.FeeQuoterContract, +) error { + header := fmt.Sprintf("FeeQuoter v2.0 %s dest chain %d", fqV2.Address().Hex(), destChainSel) + + if !destCfgV2.IsEnabled { + return groupErrors(header, []error{errors.New("not enabled — dest chain not configured, skipping field checks")}) + } + + var errs []error + + // v2.0 business-rule fields + if destCfgV2.ChainFamilySelector == [4]byte{} { + errs = append(errs, errors.New("ChainFamilySelector is empty")) + } + if err := compareFieldChecks("business rules", []fieldCheck{ + {"LinkFeeMultiplierPercent", uint64(destCfgV2.LinkFeeMultiplierPercent), uint64(fqv2seq.LinkFeeMultiplierPercent)}, + {"NetworkFeeUSDCents", uint64(destCfgV2.NetworkFeeUSDCents), uint64(expectedNetworkFeeUSDCents(sourceChainSel, destChainSel))}, + {"DefaultTxGasLimit", uint64(destCfgV2.DefaultTxGasLimit), uint64(200_000)}, + }); err != nil { + errs = append(errs, err) + } + + // Cross-version field mapping: v1.6 <-> v2.0 + if v16Cfg != nil { + if err := compareFieldChecks("v1.6<->v2.0", []fieldCheck{ + {"IsEnabled", v16Cfg.IsEnabled, destCfgV2.IsEnabled}, + {"MaxDataBytes", uint64(v16Cfg.MaxDataBytes), uint64(destCfgV2.MaxDataBytes)}, + {"MaxPerMsgGasLimit", uint64(v16Cfg.MaxPerMsgGasLimit), uint64(destCfgV2.MaxPerMsgGasLimit)}, + {"DestGasOverhead", uint64(v16Cfg.DestGasOverhead), uint64(destCfgV2.DestGasOverhead)}, + {"DestGasPerPayloadByteBase", uint64(v16Cfg.DestGasPerPayloadByteBase), uint64(destCfgV2.DestGasPerPayloadByteBase)}, + {"ChainFamilySelector", v16Cfg.ChainFamilySelector, destCfgV2.ChainFamilySelector}, + {"DefaultTokenFeeUSDCents", uint64(v16Cfg.DefaultTokenFeeUSDCents), uint64(destCfgV2.DefaultTokenFeeUSDCents)}, + {"DefaultTokenDestGasOverhead", uint64(v16Cfg.DefaultTokenDestGasOverhead), uint64(destCfgV2.DefaultTokenDestGasOverhead)}, + {"DefaultTxGasLimit", uint64(v16Cfg.DefaultTxGasLimit), uint64(destCfgV2.DefaultTxGasLimit)}, + {"NetworkFeeUSDCents", uint64(v16Cfg.NetworkFeeUSDCents), uint64(destCfgV2.NetworkFeeUSDCents)}, + }); err != nil { + errs = append(errs, err) + } + } + + // Cross-version field mapping: v2.0 <-> v1.5 + if legacyCfg != nil { + v15Checks := []fieldCheck{ + {"DestGasOverhead", uint64(destCfgV2.DestGasOverhead), uint64(legacyCfg.DestGasOverhead)}, + {"MaxDataBytes", uint64(destCfgV2.MaxDataBytes), uint64(legacyCfg.MaxDataBytes)}, + {"MaxPerMsgGasLimit", uint64(destCfgV2.MaxPerMsgGasLimit), uint64(legacyCfg.MaxPerMsgGasLimit)}, + {"DefaultTokenDestGasOverhead", uint64(destCfgV2.DefaultTokenDestGasOverhead), uint64(legacyCfg.DefaultTokenDestGasOverhead)}, + {"DefaultTokenFeeUSDCents", uint64(destCfgV2.DefaultTokenFeeUSDCents), uint64(legacyCfg.DefaultTokenFeeUSDCents)}, + {"DestGasPerPayloadByteBase", uint64(destCfgV2.DestGasPerPayloadByteBase), uint64(uint8(legacyCfg.DestGasPerPayloadByte))}, //nolint:gosec // G115: intentional v1.5 uint16->uint8 truncation during migration + } + // NetworkFeeUSDCents: per-token in v1.5, per-dest in v2.0. Fetch from v1.5 FeeTokenConfig + if legacyOnRamp := c.EVM2EVMOnRamp[destChainSel]; legacyOnRamp != nil { + v16FeeTokens, ftErr := c.FeeQuoter.GetFeeTokens(callOpts) + if ftErr == nil { + for _, ft := range v16FeeTokens { + legacyFTCfg, err := legacyOnRamp.GetFeeTokenConfig(callOpts, ft) + if err != nil || !legacyFTCfg.Enabled { + continue + } + v15Checks = append(v15Checks, + fieldCheck{"NetworkFeeUSDCents", uint64(destCfgV2.NetworkFeeUSDCents), uint64(legacyFTCfg.NetworkFeeUSDCents)}, + ) + break + } + } + } + if err := compareFieldChecks("v1.5<->v2.0", v15Checks); err != nil { + errs = append(errs, err) + } + } + + return groupErrors(header, errs) +} + +// validateAllTokenTransferFeeConfigs validates token transfer fees across v1.5, v1.6, and v2.0 +// Cross-version field counts: v1.5↔v1.6 (5), v1.6→v2.0 (FeeUSDCents, DestGasOverhead, DestBytesOverhead; DeciBps+MaxFeeUSDCents dropped) +func (c CCIPChainState) validateAllTokenTransferFeeConfigs( + e cldf.Environment, + callOpts *bind.CallOpts, + connectedChains []uint64, + fqV2 *fqv2ops.FeeQuoterContract, +) error { + if c.FeeQuoter == nil && fqV2 == nil { + return nil + } + if c.TokenAdminRegistry == nil { + return errors.New("no TokenAdminRegistry contract found, cannot validate token transfer fee configs") + } + + allTokens, err := viewshared.GetSupportedTokens(c.TokenAdminRegistry) + if err != nil { + return fmt.Errorf("failed to get configured tokens from TokenAdminRegistry: %w", err) + } + + addrToSymbol := make(map[common.Address]string) + if symbolMap, symErr := c.TokenAddressBySymbol(); symErr == nil { + for symbol, addr := range symbolMap { + addrToSymbol[addr] = string(symbol) + } + } + + e.Logger.Debugw("Validating TokenTransferFeeConfigs", "tokens", len(allTokens), "connectedChains", len(connectedChains)) + var mu sync.Mutex + var errs []error + var wg sync.WaitGroup + sem := make(chan struct{}, 20) + for _, tokenAddr := range allTokens { + token := tokenAddr + tokenLabel := token.Hex() + if sym, ok := addrToSymbol[token]; ok { + tokenLabel = fmt.Sprintf("%s (%s)", sym, token.Hex()) + } + wg.Add(1) + sem <- struct{}{} + go func() { + defer wg.Done() + defer func() { <-sem }() + var tokenErrs []error + for _, destChainSel := range connectedChains { + if err := c.validateTokenTransferFee(callOpts, destChainSel, token, tokenLabel, fqV2); err != nil { + tokenErrs = append(tokenErrs, err) + } + } + if len(tokenErrs) > 0 { + mu.Lock() + errs = append(errs, tokenErrs...) + mu.Unlock() + } + }() + } + wg.Wait() + + return errors.Join(errs...) +} + +// validateTokenTransferFee validates a single token+dest pair across all present FeeQuoter versions +func (c CCIPChainState) validateTokenTransferFee( + callOpts *bind.CallOpts, + destChainSel uint64, + token common.Address, + tokenLabel string, + fqV2 *fqv2ops.FeeQuoterContract, +) error { + var errs []error + + var v16Cfg *fee_quoter.FeeQuoterTokenTransferFeeConfig + v16Enabled := false + if c.FeeQuoter != nil { + cfg, err := c.FeeQuoter.GetTokenTransferFeeConfig(callOpts, destChainSel, token) + if err == nil { + v16Enabled = cfg.IsEnabled + if cfg.IsEnabled { + v16Cfg = &cfg + } + } + } + + var v20Cfg *fqv2ops.TokenTransferFeeConfig + v20Enabled := false + if fqV2 != nil { + cfg, err := fqV2.GetTokenTransferFeeConfig(callOpts, destChainSel, token) + if err == nil { + v20Enabled = cfg.IsEnabled + if cfg.IsEnabled { + v20Cfg = &cfg + } + } + } + + // Cross-version IsEnabled consistency + if c.FeeQuoter != nil && fqV2 != nil && v16Enabled != v20Enabled { + errs = append(errs, fmt.Errorf("IsEnabled mismatch: v1.6=%v, v2.0=%v", v16Enabled, v20Enabled)) + } + + if v16Cfg == nil && v20Cfg == nil { + header := fmt.Sprintf("token %s dest %d", tokenLabel, destChainSel) + return groupErrors(header, errs) + } + + // v1.6 invariants + v1.5 cross-check + if v16Cfg != nil { + if v16Cfg.MinFeeUSDCents >= v16Cfg.MaxFeeUSDCents { + errs = append(errs, fmt.Errorf("v1.6: MinFeeUSDCents (%d) must be less than MaxFeeUSDCents (%d)", + v16Cfg.MinFeeUSDCents, v16Cfg.MaxFeeUSDCents)) + } + if v16Cfg.DestBytesOverhead < globals.CCIPLockOrBurnV1RetBytes { + errs = append(errs, fmt.Errorf("v1.6: DestBytesOverhead (%d) must be at least %d", + v16Cfg.DestBytesOverhead, globals.CCIPLockOrBurnV1RetBytes)) + } + + // v1.6 <-> v1.5 field mapping + if legacyOnRamp := c.EVM2EVMOnRamp[destChainSel]; legacyOnRamp != nil { + legacyTTF, legacyErr := legacyOnRamp.GetTokenTransferFeeConfig(callOpts, token) + if legacyErr == nil && legacyTTF.IsEnabled { + if err := compareFieldChecks("v1.5<->v1.6", []fieldCheck{ + {"MinFeeUSDCents", uint64(v16Cfg.MinFeeUSDCents), uint64(legacyTTF.MinFeeUSDCents)}, + {"MaxFeeUSDCents", uint64(v16Cfg.MaxFeeUSDCents), uint64(legacyTTF.MaxFeeUSDCents)}, + {"DeciBps", uint64(v16Cfg.DeciBps), uint64(legacyTTF.DeciBps)}, + {"DestGasOverhead", uint64(v16Cfg.DestGasOverhead), uint64(legacyTTF.DestGasOverhead)}, + {"DestBytesOverhead", uint64(v16Cfg.DestBytesOverhead), uint64(legacyTTF.DestBytesOverhead)}, + }); err != nil { + errs = append(errs, err) + } + } + } + } + + // v2.0 invariants + cross-checks + if v20Cfg != nil { + if v20Cfg.DestBytesOverhead < globals.CCIPLockOrBurnV1RetBytes { + errs = append(errs, fmt.Errorf("v2.0: DestBytesOverhead (%d) must be at least %d", + v20Cfg.DestBytesOverhead, globals.CCIPLockOrBurnV1RetBytes)) + } + + // v2.0 <-> v1.6 field mapping + if v16Cfg != nil { + if v20Cfg.FeeUSDCents != v16Cfg.MinFeeUSDCents { + errs = append(errs, fmt.Errorf("v2.0: FeeUSDCents (%d) != v1.6 MinFeeUSDCents (%d)", + v20Cfg.FeeUSDCents, v16Cfg.MinFeeUSDCents)) + } + if v16Cfg.DeciBps > 0 { + errs = append(errs, fmt.Errorf("v2.0: v1.6 DeciBps=%d is non-zero but removed in v2.0 (percentage fee lost)", + v16Cfg.DeciBps)) + } + if v16Cfg.MaxFeeUSDCents > v16Cfg.MinFeeUSDCents { + errs = append(errs, fmt.Errorf("v2.0: v1.6 MaxFeeUSDCents (%d) > MinFeeUSDCents (%d) -- fee cap not present in v2.0", + v16Cfg.MaxFeeUSDCents, v16Cfg.MinFeeUSDCents)) + } + if err := compareFieldChecks("v1.6<->v2.0", []fieldCheck{ + {"DestGasOverhead", uint64(v20Cfg.DestGasOverhead), uint64(v16Cfg.DestGasOverhead)}, + {"DestBytesOverhead", uint64(v20Cfg.DestBytesOverhead), uint64(v16Cfg.DestBytesOverhead)}, + }); err != nil { + errs = append(errs, err) + } + } + } + + header := fmt.Sprintf("token %s dest %d", tokenLabel, destChainSel) + return groupErrors(header, errs) +} diff --git a/deployment/ccip/shared/stateview/evm/validate_feequoter_test.go b/deployment/ccip/shared/stateview/evm/validate_feequoter_test.go new file mode 100644 index 00000000000..ea7b9103568 --- /dev/null +++ b/deployment/ccip/shared/stateview/evm/validate_feequoter_test.go @@ -0,0 +1,457 @@ +package evm_test + +import ( + "math/big" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + chain_selectors "github.com/smartcontractkit/chain-selectors" + + "github.com/smartcontractkit/chainlink-evm/pkg/utils" + + cldf_chain "github.com/smartcontractkit/chainlink-deployments-framework/chain" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + + fqv2ops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/fee_quoter" + fqv2seq "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/sequences" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_5_0/evm_2_evm_onramp" + "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_3/fee_quoter" + + cldf_evm "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm" + + "github.com/smartcontractkit/chainlink/deployment" + "github.com/smartcontractkit/chainlink/deployment/ccip/changeset" + "github.com/smartcontractkit/chainlink/deployment/ccip/changeset/testhelpers" + "github.com/smartcontractkit/chainlink/deployment/ccip/changeset/testhelpers/v1_5" + v1_6 "github.com/smartcontractkit/chainlink/deployment/ccip/changeset/v1_6" + ccipops "github.com/smartcontractkit/chainlink/deployment/ccip/operation/evm/v1_6" + ccipseq "github.com/smartcontractkit/chainlink/deployment/ccip/sequence/evm/v1_6" + "github.com/smartcontractkit/chainlink/deployment/ccip/shared" + "github.com/smartcontractkit/chainlink/deployment/ccip/shared/stateview" + commonchangeset "github.com/smartcontractkit/chainlink/deployment/common/changeset" + "github.com/smartcontractkit/chainlink/deployment/common/proposalutils" + commontypes "github.com/smartcontractkit/chainlink/deployment/common/types" + "github.com/smartcontractkit/chainlink/v2/core/capabilities/ccip/ccipevm" +) + +// TestValidateFeeQuoter_HappyPath verifies that ValidateFeeQuoter passes on a +// correctly-deployed memory environment with all lanes configured (v1.6 only). +func TestValidateFeeQuoter_HappyPath(t *testing.T) { + t.Parallel() + tenv, _ := testhelpers.NewMemoryEnvironment(t, testhelpers.WithNumOfChains(3)) + state, err := stateview.LoadOnchainState(tenv.Env, stateview.WithLoadLegacyContracts(true)) + require.NoError(t, err) + + evmChains := tenv.Env.BlockChains.ListChainSelectors(cldf_chain.WithFamily(chain_selectors.FamilyEVM)) + for _, sel := range evmChains { + chainState := state.MustGetEVMChainState(sel) + v16Active := buildV16ActiveChains(t, tenv, state) + connectedChains, err := chainState.ValidateRouter(tenv.Env, false, v16Active) + require.NoError(t, err, "router validation failed for chain %d", sel) + + err = chainState.ValidateFeeQuoter(tenv.Env, sel, connectedChains, nil, nil) + require.NoError(t, err, "FeeQuoter validation failed for chain %d", sel) + } +} + +// TestValidateFeeQuoter_NilFeeQuoter verifies that ValidateFeeQuoter returns an +// error when no FeeQuoter contract is present. +func TestValidateFeeQuoter_NilFeeQuoter(t *testing.T) { + t.Parallel() + tenv, _ := testhelpers.NewMemoryEnvironment(t, testhelpers.WithNumOfChains(2)) + state, err := stateview.LoadOnchainState(tenv.Env) + require.NoError(t, err) + + evmChains := tenv.Env.BlockChains.ListChainSelectors(cldf_chain.WithFamily(chain_selectors.FamilyEVM)) + chainState := state.MustGetEVMChainState(evmChains[0]) + chainState.FeeQuoter = nil + err = chainState.ValidateFeeQuoter(tenv.Env, evmChains[0], evmChains[1:], nil, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "no FeeQuoter") +} + +// TestValidateFeeQuoter_CrossVersionValidation deploys v1.5 OnRamp + PriceRegistry, +// v1.6.3 FeeQuoter, and v2.0 FeeQuoter, then: +// - Subtest "wrong_values": sets deliberately wrong dest chain configs on both FeeQuoters, +// validates, and asserts that cross-version mismatches and business-rule violations are reported. +// - Subtest "fixed_values": corrects all configs to align v1.5↔v1.6↔v2.0 with correct +// business rules, validates, and asserts no errors. +func TestValidateFeeQuoter_CrossVersionValidation(t *testing.T) { + t.Parallel() + + // ===== SETUP: v1.5 prereqs + v1.6 contracts + v1.5 lanes + v2.0 FeeQuoter ===== + + // 1. Deploy with v1.5 prerequisites (PriceRegistry, RMN, etc.) + v1_5DeploymentConfig := &changeset.V1_5DeploymentConfig{ + PriceRegStalenessThreshold: 60 * 60 * 24, + RMNConfig: &rmn_contract.RMNConfig{ + BlessWeightThreshold: 1, + CurseWeightThreshold: 1, + Voters: []rmn_contract.RMNVoter{ + {BlessWeight: 1, CurseWeight: 1, BlessVoteAddr: utils.RandomAddress(), CurseVoteAddr: utils.RandomAddress()}, + }, + }, + } + e, _ := testhelpers.NewMemoryEnvironment(t, + testhelpers.WithNumOfChains(2), + testhelpers.WithPrerequisiteDeploymentOnly(v1_5DeploymentConfig), + ) + tenv := e.Env + + allChainSelectors := tenv.BlockChains.ListChainSelectors(cldf_chain.WithFamily(chain_selectors.FamilyEVM)) + require.Len(t, allChainSelectors, 2) + source := allChainSelectors[0] + dest := allChainSelectors[1] + + state, err := stateview.LoadOnchainState(tenv, stateview.WithLoadLegacyContracts(true)) + require.NoError(t, err) + + // 2. Remove LinkToken (will be re-deployed by v1.6). + ab := cldf.NewMemoryAddressBook() + for _, sel := range allChainSelectors { + require.NoError(t, ab.Save(sel, state.Chains[sel].LinkToken.Address().Hex(), + cldf.NewTypeAndVersion("LinkToken", deployment.Version1_0_0))) + } + require.NoError(t, tenv.ExistingAddresses.Remove(ab)) + + // 3. Add TestRouter placeholder. + ab = cldf.NewMemoryAddressBook() + for _, sel := range allChainSelectors { + require.NoError(t, ab.Save(sel, utils.RandomAddress().Hex(), + cldf.NewTypeAndVersion(shared.TestRouter, deployment.Version1_2_0))) + } + require.NoError(t, tenv.ExistingAddresses.Merge(ab)) + + // 4. Deploy v1.6 contracts (HomeChain, LinkToken, MCMS, Prerequisites, ChainContracts). + deployV16Contracts(t, &tenv, e.HomeChainSel) + + // 5. Add v1.5 lanes (OnRamp + CommitStore + OffRamp per pair). + state, err = stateview.LoadOnchainState(tenv, stateview.WithLoadLegacyContracts(true)) + require.NoError(t, err) + pairs := []testhelpers.SourceDestPair{ + {SourceChainSelector: source, DestChainSelector: dest}, + {SourceChainSelector: dest, DestChainSelector: source}, + } + tenv = v1_5.AddLanes(t, tenv, state, pairs) + + // Reload state after v1.5 lane deployment. + state, err = stateview.LoadOnchainState(tenv, stateview.WithLoadLegacyContracts(true)) + require.NoError(t, err) + + // Verify v1.5 OnRamp is present on source chain. + sourceState := state.MustGetEVMChainState(source) + require.NotNil(t, sourceState.EVM2EVMOnRamp[dest], "v1.5 OnRamp must exist for source→dest") + + evmChain := tenv.BlockChains.EVMChains()[source] + linkAddr, err := sourceState.LinkTokenAddress() + require.NoError(t, err) + wethAddr := sourceState.Weth9.Address() + + // 6. Deploy v2.0 FeeQuoter on source chain. + fqV2Addr, fqV2 := deployV20FeeQuoter(t, evmChain, linkAddr) + + // 7. Add fee tokens (LINK + WETH) to v2.0 FeeQuoter. + updateV20FeeQuoterFeeTokens(t, evmChain, fqV2Addr, []common.Address{linkAddr, wethAddr}, nil) + + connectedChains := []uint64{dest} + + // ===== SUBTEST 1: wrong values ===== + t.Run("wrong_values", func(t *testing.T) { + // Set wrong v1.6 FeeQuoter dest config: + // - Mismatches with v1.5 OnRamp (DestGasOverhead, MaxDataBytes, MaxPerMsgGasLimit, etc.) + // - Business-rule violations (NetworkFeeUSDCents, DefaultTxGasLimit, ChainFamilySelector) + badV16Cfg := fee_quoter.FeeQuoterDestChainConfig{ + IsEnabled: true, + MaxNumberOfTokensPerMsg: 99, // v1.5 has 5 + DestGasOverhead: 999_999, // v1.5 has 350_000 + DestDataAvailabilityOverheadGas: 1, // v1.5 has 33_596 + DestGasPerDataAvailabilityByte: 1, // v1.5 has 16 + DestDataAvailabilityMultiplierBps: 1, // v1.5 has 6840 + MaxDataBytes: 50_000, // v1.5 has 100_000 + MaxPerMsgGasLimit: 1_000, // v1.5 has 4_000_000 + DefaultTokenDestGasOverhead: 1_000, // v1.5 has 125_000 + DefaultTokenFeeUSDCents: 99, // v1.5 has 50 + EnforceOutOfOrder: true, // v1.5 has false + DestGasPerPayloadByteBase: 99, // v1.5 has 16 + DestGasPerPayloadByteHigh: 99, // expected: CalldataGasPerByteHigh (40) + DestGasPerPayloadByteThreshold: 99, // expected: CalldataGasPerByteThreshold (3000) + DefaultTxGasLimit: 500_000, // expected: 200_000 + NetworkFeeUSDCents: 77, // expected: 10 + GasPriceStalenessThreshold: 0, // must be non-zero + GasMultiplierWeiPerEth: 99, // v1.5 has 1e18 + ChainFamilySelector: [4]byte{}, // must not be empty + } + tenv, err = commonchangeset.Apply(t, tenv, + commonchangeset.Configure( + cldf.CreateLegacyChangeSet(v1_6.UpdateFeeQuoterDestsChangeset), + v1_6.UpdateFeeQuoterDestsConfig{ + UpdatesByChain: map[uint64]map[uint64]fee_quoter.FeeQuoterDestChainConfig{ + source: {dest: badV16Cfg}, + }, + }, + ), + ) + require.NoError(t, err) + + // Set wrong v2.0 FeeQuoter dest config: + // - Different wrong values than v1.6 (triggers v1.6↔v2.0 cross-check failures) + // - Business-rule violations (NetworkFeeUSDCents, DefaultTxGasLimit, LinkFeeMultiplierPercent) + badV20Cfg := fqv2ops.DestChainConfig{ + IsEnabled: true, + MaxDataBytes: 20_000, // differs from v1.6 and v1.5 + MaxPerMsgGasLimit: 500, // differs from v1.6 and v1.5 + DestGasOverhead: 111_111, // differs from v1.6 and v1.5 + DestGasPerPayloadByteBase: 50, // differs from v1.6 and v1.5 + ChainFamilySelector: [4]byte{0x28, 0x12, 0xd5, 0x2c}, + DefaultTokenFeeUSDCents: 11, // differs from v1.6 and v1.5 + DefaultTokenDestGasOverhead: 500, // differs from v1.6 and v1.5 + DefaultTxGasLimit: 300_000, // expected: 200_000 + NetworkFeeUSDCents: 55, // expected: 10 + LinkFeeMultiplierPercent: 99, // expected: fqv2seq.LinkFeeMultiplierPercent + } + updateV20FeeQuoterDestConfig(t, evmChain, fqV2Addr, dest, badV20Cfg) + + // Reload state and validate. + state, err = stateview.LoadOnchainState(tenv, stateview.WithLoadLegacyContracts(true)) + require.NoError(t, err) + chainState := state.MustGetEVMChainState(source) + + err = chainState.ValidateFeeQuoter(tenv, source, connectedChains, fqV2, evmChain.Client) + require.Error(t, err, "validation must fail with wrong values") + errMsg := err.Error() + + // v1.5↔v1.6 cross-check fields + for _, field := range []string{ + "DestGasOverhead", + "MaxDataBytes", + "MaxPerMsgGasLimit", + "MaxNumberOfTokensPerMsg", + "DefaultTokenDestGasOverhead", + "DefaultTokenFeeUSDCents", + "EnforceOutOfOrder", + "DestGasPerPayloadByteBase", + } { + assert.True(t, strings.Contains(errMsg, field), + "expected v1.5↔v1.6 cross-check to catch %s, got: %s", field, errMsg) + } + + // v1.6 business rules + assert.Contains(t, errMsg, "NetworkFeeUSDCents", "v1.6 business rule") + assert.Contains(t, errMsg, "DefaultTxGasLimit", "v1.6 business rule") + assert.Contains(t, errMsg, "ChainFamilySelector", "v1.6 business rule") + assert.Contains(t, errMsg, "GasPriceStalenessThreshold", "v1.6 business rule") + + // v1.6↔v2.0 cross-check (v2.0 values differ from v1.6 values) + assert.Contains(t, errMsg, "v1.6<->v2.0", "v1.6↔v2.0 cross-check") + + // v2.0 business rules + assert.Contains(t, errMsg, "LinkFeeMultiplierPercent", "v2.0 business rule") + }) + + // ===== SUBTEST 2: fixed values ===== + t.Run("fixed_values", func(t *testing.T) { + // Read v1.5 OnRamp dynamic config to align v1.6 fields. + callOpts := &bind.CallOpts{Context: t.Context()} + state, err = stateview.LoadOnchainState(tenv, stateview.WithLoadLegacyContracts(true)) + require.NoError(t, err) + sourceState := state.MustGetEVMChainState(source) + onRamp := sourceState.EVM2EVMOnRamp[dest] + require.NotNil(t, onRamp) + v15Cfg, err := onRamp.GetDynamicConfig(callOpts) + require.NoError(t, err) + + // Fix v1.6 FeeQuoter dest config: + // - All 11 cross-checked fields match v1.5 OnRamp dynamic config + // - Business-rule fields set to expected values + goodV16Cfg := fee_quoter.FeeQuoterDestChainConfig{ + IsEnabled: true, + MaxNumberOfTokensPerMsg: v15Cfg.MaxNumberOfTokensPerMsg, + DestGasOverhead: v15Cfg.DestGasOverhead, + DestDataAvailabilityOverheadGas: v15Cfg.DestDataAvailabilityOverheadGas, + DestGasPerDataAvailabilityByte: v15Cfg.DestGasPerDataAvailabilityByte, + DestDataAvailabilityMultiplierBps: v15Cfg.DestDataAvailabilityMultiplierBps, + MaxDataBytes: v15Cfg.MaxDataBytes, + MaxPerMsgGasLimit: v15Cfg.MaxPerMsgGasLimit, + DefaultTokenDestGasOverhead: v15Cfg.DefaultTokenDestGasOverhead, + DefaultTokenFeeUSDCents: v15Cfg.DefaultTokenFeeUSDCents, + EnforceOutOfOrder: v15Cfg.EnforceOutOfOrder, + DestGasPerPayloadByteBase: uint8(v15Cfg.DestGasPerPayloadByte), //nolint:gosec // match v1.5 truncation + // Business-rule fields + DestGasPerPayloadByteHigh: ccipevm.CalldataGasPerByteHigh, + DestGasPerPayloadByteThreshold: ccipevm.CalldataGasPerByteThreshold, + DefaultTxGasLimit: 200_000, + NetworkFeeUSDCents: 10, + GasPriceStalenessThreshold: 86400, + ChainFamilySelector: [4]byte{0x28, 0x12, 0xd5, 0x2c}, + GasMultiplierWeiPerEth: 1e18, // match v1.5 FeeTokenConfig + } + tenv, err = commonchangeset.Apply(t, tenv, + commonchangeset.Configure( + cldf.CreateLegacyChangeSet(v1_6.UpdateFeeQuoterDestsChangeset), + v1_6.UpdateFeeQuoterDestsConfig{ + UpdatesByChain: map[uint64]map[uint64]fee_quoter.FeeQuoterDestChainConfig{ + source: {dest: goodV16Cfg}, + }, + }, + ), + ) + require.NoError(t, err) + + // Fix v2.0 FeeQuoter dest config: + // - All 10 v1.6↔v2.0 cross-checked fields match fixed v1.6 config + // - Business-rule fields set to expected values + goodV20Cfg := fqv2ops.DestChainConfig{ + IsEnabled: true, + MaxDataBytes: goodV16Cfg.MaxDataBytes, + MaxPerMsgGasLimit: goodV16Cfg.MaxPerMsgGasLimit, + DestGasOverhead: goodV16Cfg.DestGasOverhead, + DestGasPerPayloadByteBase: goodV16Cfg.DestGasPerPayloadByteBase, + ChainFamilySelector: goodV16Cfg.ChainFamilySelector, + DefaultTokenFeeUSDCents: goodV16Cfg.DefaultTokenFeeUSDCents, + DefaultTokenDestGasOverhead: goodV16Cfg.DefaultTokenDestGasOverhead, + DefaultTxGasLimit: 200_000, + NetworkFeeUSDCents: 10, + LinkFeeMultiplierPercent: fqv2seq.LinkFeeMultiplierPercent, + } + updateV20FeeQuoterDestConfig(t, evmChain, fqV2Addr, dest, goodV20Cfg) + + // Fix v1.5 OnRamp FeeTokenConfig so v2.0↔v1.5 NetworkFeeUSDCents cross-check passes. + // The v1.5 test default has NetworkFeeUSDCents=100 per-token, but expected per-dest is 10. + updateV15OnRampFeeTokenConfig(t, evmChain, onRamp, linkAddr, wethAddr) + + // Reload state and validate. + state, err = stateview.LoadOnchainState(tenv, stateview.WithLoadLegacyContracts(true)) + require.NoError(t, err) + chainState := state.MustGetEVMChainState(source) + + err = chainState.ValidateFeeQuoter(tenv, source, connectedChains, fqV2, evmChain.Client) + require.NoError(t, err, "validation must pass with correctly aligned configs") + }) +} + +// --- Helpers --- + +// deployV16Contracts deploys v1.6 HomeChain, LinkToken, MCMS, Prerequisites, and ChainContracts. +func deployV16Contracts(t *testing.T, tenv *cldf.Environment, homeChainSel uint64) { + t.Helper() + evmSelectors := tenv.BlockChains.ListChainSelectors(cldf_chain.WithFamily(chain_selectors.FamilyEVM)) + nodes, err := deployment.NodeInfo(tenv.NodeIDs, tenv.Offchain) + require.NoError(t, err) + p2pIDs := nodes.NonBootstraps().PeerIDs() + + cfg := make(map[uint64]commontypes.MCMSWithTimelockConfigV2) + contractParams := make(map[uint64]ccipseq.ChainContractParams) + prereqCfg := make([]changeset.DeployPrerequisiteConfigPerChain, 0) + for _, sel := range evmSelectors { + cfg[sel] = proposalutils.SingleGroupTimelockConfigV2(t) + contractParams[sel] = ccipseq.ChainContractParams{ + FeeQuoterParams: ccipops.DefaultFeeQuoterParams(), + OffRampParams: ccipops.DefaultOffRampParams(), + } + prereqCfg = append(prereqCfg, changeset.DeployPrerequisiteConfigPerChain{ChainSelector: sel}) + } + + eVal, err := commonchangeset.Apply(t, *tenv, commonchangeset.Configure( + cldf.CreateLegacyChangeSet(v1_6.DeployHomeChainChangeset), + v1_6.DeployHomeChainConfig{ + HomeChainSel: homeChainSel, + RMNStaticConfig: testhelpers.NewTestRMNStaticConfig(), + RMNDynamicConfig: testhelpers.NewTestRMNDynamicConfig(), + NodeOperators: testhelpers.NewTestNodeOperator(tenv.BlockChains.EVMChains()[homeChainSel].DeployerKey.From), + NodeP2PIDsPerNodeOpAdmin: map[string][][32]byte{ + "NodeOperator": p2pIDs, + }, + }, + ), commonchangeset.Configure( + cldf.CreateLegacyChangeSet(commonchangeset.DeployLinkToken), + evmSelectors, + ), commonchangeset.Configure( + cldf.CreateLegacyChangeSet(commonchangeset.DeployMCMSWithTimelockV2), + cfg, + ), commonchangeset.Configure( + cldf.CreateLegacyChangeSet(changeset.DeployPrerequisitesChangeset), + changeset.DeployPrerequisiteConfig{Configs: prereqCfg}, + ), commonchangeset.Configure( + cldf.CreateLegacyChangeSet(v1_6.DeployChainContractsChangeset), + ccipseq.DeployChainContractsConfig{ + HomeChainSelector: homeChainSel, + ContractParamsPerChain: contractParams, + }, + )) + require.NoError(t, err) + *tenv = eVal +} + +// deployV20FeeQuoter deploys a FeeQuoter v2.0 on the given chain. +func deployV20FeeQuoter(t *testing.T, evmChain cldf_evm.Chain, linkToken common.Address) (common.Address, *fqv2ops.FeeQuoterContract) { + t.Helper() + parsedABI, err := abi.JSON(strings.NewReader(fqv2ops.FeeQuoterABI)) + require.NoError(t, err) + + fqV2Addr, tx, _, err := bind.DeployContract( + evmChain.DeployerKey, + parsedABI, + common.FromHex(fqv2ops.FeeQuoterBin), + evmChain.Client, + fqv2ops.StaticConfig{ + MaxFeeJuelsPerMsg: big.NewInt(1e18), + LinkToken: linkToken, + }, + []common.Address{evmChain.DeployerKey.From}, + []fqv2ops.TokenTransferFeeConfigArgs{}, + []fqv2ops.DestChainConfigArgs{}, + ) + require.NoError(t, err) + _, err = evmChain.Confirm(tx) + require.NoError(t, err) + + fqV2, err := fqv2ops.NewFeeQuoterContract(fqV2Addr, evmChain.Client) + require.NoError(t, err) + return fqV2Addr, fqV2 +} + +// updateV20FeeQuoterFeeTokens calls applyFeeTokensUpdates on a v2.0 FeeQuoter. +func updateV20FeeQuoterFeeTokens(t *testing.T, evmChain cldf_evm.Chain, fqAddr common.Address, toAdd, toRemove []common.Address) { + t.Helper() + parsedABI, err := abi.JSON(strings.NewReader(fqv2ops.FeeQuoterABI)) + require.NoError(t, err) + bc := bind.NewBoundContract(fqAddr, parsedABI, evmChain.Client, evmChain.Client, evmChain.Client) + tx, err := bc.Transact(evmChain.DeployerKey, "applyFeeTokensUpdates", toRemove, toAdd) + require.NoError(t, err, "applyFeeTokensUpdates") + _, err = evmChain.Confirm(tx) + require.NoError(t, err) +} + +// updateV20FeeQuoterDestConfig calls applyDestChainConfigUpdates on a v2.0 FeeQuoter. +func updateV20FeeQuoterDestConfig(t *testing.T, evmChain cldf_evm.Chain, fqAddr common.Address, destSel uint64, cfg fqv2ops.DestChainConfig) { + t.Helper() + parsedABI, err := abi.JSON(strings.NewReader(fqv2ops.FeeQuoterABI)) + require.NoError(t, err) + bc := bind.NewBoundContract(fqAddr, parsedABI, evmChain.Client, evmChain.Client, evmChain.Client) + tx, err := bc.Transact(evmChain.DeployerKey, "applyDestChainConfigUpdates", []fqv2ops.DestChainConfigArgs{ + {DestChainSelector: destSel, DestChainConfig: cfg}, + }) + require.NoError(t, err, "applyDestChainConfigUpdates") + _, err = evmChain.Confirm(tx) + require.NoError(t, err) +} + +// updateV15OnRampFeeTokenConfig updates the v1.5 OnRamp fee token config so that +// NetworkFeeUSDCents=10 and GasMultiplierWeiPerEth=1e18 for all fee tokens. +func updateV15OnRampFeeTokenConfig(t *testing.T, evmChain cldf_evm.Chain, onRamp *evm_2_evm_onramp.EVM2EVMOnRamp, linkAddr, wethAddr common.Address) { + t.Helper() + tx, err := onRamp.SetFeeTokenConfig(evmChain.DeployerKey, []evm_2_evm_onramp.EVM2EVMOnRampFeeTokenConfigArgs{ + {Token: linkAddr, NetworkFeeUSDCents: 10, GasMultiplierWeiPerEth: 1e18, PremiumMultiplierWeiPerEth: 9e17, Enabled: true}, + {Token: wethAddr, NetworkFeeUSDCents: 10, GasMultiplierWeiPerEth: 1e18, PremiumMultiplierWeiPerEth: 1e18, Enabled: true}, + }) + require.NoError(t, err, "SetFeeTokenConfig") + _, err = evmChain.Confirm(tx) + require.NoError(t, err) +} diff --git a/deployment/ccip/shared/stateview/evm/validate_test.go b/deployment/ccip/shared/stateview/evm/validate_test.go index 71209bcf576..f16c08c08ec 100644 --- a/deployment/ccip/shared/stateview/evm/validate_test.go +++ b/deployment/ccip/shared/stateview/evm/validate_test.go @@ -195,38 +195,6 @@ func TestValidateNonceManager_NilNonceManager(t *testing.T) { assert.Contains(t, err.Error(), "no NonceManager") } -func TestValidateFeeQuoter_HappyPath(t *testing.T) { - t.Parallel() - tenv, _ := testhelpers.NewMemoryEnvironment(t, testhelpers.WithNumOfChains(3)) - state, err := stateview.LoadOnchainState(tenv.Env, stateview.WithLoadLegacyContracts(true)) - require.NoError(t, err) - - evmChains := tenv.Env.BlockChains.ListChainSelectors(cldf_chain.WithFamily(chain_selectors.FamilyEVM)) - for _, sel := range evmChains { - chainState := state.MustGetEVMChainState(sel) - v16Active := buildV16ActiveChains(t, tenv, state) - connectedChains, err := chainState.ValidateRouter(tenv.Env, false, v16Active) - require.NoError(t, err, "router validation failed for chain %d", sel) - - err = chainState.ValidateFeeQuoter(tenv.Env, sel, connectedChains, nil, nil) - require.NoError(t, err, "FeeQuoter validation failed for chain %d", sel) - } -} - -func TestValidateFeeQuoter_NilFeeQuoter(t *testing.T) { - t.Parallel() - tenv, _ := testhelpers.NewMemoryEnvironment(t, testhelpers.WithNumOfChains(2)) - state, err := stateview.LoadOnchainState(tenv.Env) - require.NoError(t, err) - - evmChains := tenv.Env.BlockChains.ListChainSelectors(cldf_chain.WithFamily(chain_selectors.FamilyEVM)) - chainState := state.MustGetEVMChainState(evmChains[0]) - chainState.FeeQuoter = nil - err = chainState.ValidateFeeQuoter(tenv.Env, evmChains[0], evmChains[1:], nil, nil) - require.Error(t, err) - assert.Contains(t, err.Error(), "no FeeQuoter") -} - func buildHomeChainTestArgs( t *testing.T, tenv testhelpers.DeployedEnv, From 8bb1246273f0cc74b41343ae2772888dac41a20b Mon Sep 17 00:00:00 2001 From: simsonraj Date: Thu, 26 Mar 2026 01:14:44 +0530 Subject: [PATCH 10/13] Addressed comments & fixes --- .../ccip/operation/evm/v1_6/ops_fee_quoter.go | 11 +- .../stateview/evm/validate_feequoter.go | 3 +- .../stateview/evm/validate_feequoter_test.go | 180 ++++++++---------- 3 files changed, 93 insertions(+), 101 deletions(-) diff --git a/deployment/ccip/operation/evm/v1_6/ops_fee_quoter.go b/deployment/ccip/operation/evm/v1_6/ops_fee_quoter.go index d8cfe70fa8c..e7637d090a8 100644 --- a/deployment/ccip/operation/evm/v1_6/ops_fee_quoter.go +++ b/deployment/ccip/operation/evm/v1_6/ops_fee_quoter.go @@ -4,8 +4,6 @@ import ( "encoding/hex" "errors" "math/big" - "strings" - "github.com/Masterminds/semver/v3" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" @@ -224,8 +222,7 @@ func DefaultFeeQuoterDestChainConfig(configEnabled bool, destChainSelector ...ui case chain_selectors.FamilySui: familySelector, _ = hex.DecodeString(SuiFamilySelector) case chain_selectors.FamilyEVM: - name, _ := chain_selectors.GetChainNameFromSelector(destChainSelector[0]) - if strings.HasPrefix(name, "ethereum") { + if isEthereumChain(destChainSelector[0]) { networkFeeUSDCents = 50 defaultTokenFeeUSDCents = 150 } @@ -251,3 +248,9 @@ func DefaultFeeQuoterDestChainConfig(configEnabled bool, destChainSelector ...ui ChainFamilySelector: [4]byte(familySelector), } } + +func isEthereumChain(selector uint64) bool { + return selector == chain_selectors.ETHEREUM_MAINNET.Selector || + selector == chain_selectors.ETHEREUM_TESTNET_SEPOLIA.Selector || + selector == chain_selectors.ETHEREUM_TESTNET_HOODI.Selector +} diff --git a/deployment/ccip/shared/stateview/evm/validate_feequoter.go b/deployment/ccip/shared/stateview/evm/validate_feequoter.go index 187538b249c..730b5d4b487 100644 --- a/deployment/ccip/shared/stateview/evm/validate_feequoter.go +++ b/deployment/ccip/shared/stateview/evm/validate_feequoter.go @@ -27,7 +27,8 @@ import ( func isEthereumChain(selector uint64) bool { return selector == chain_selectors.ETHEREUM_MAINNET.Selector || - selector == chain_selectors.ETHEREUM_TESTNET_SEPOLIA.Selector + selector == chain_selectors.ETHEREUM_TESTNET_SEPOLIA.Selector || + selector == chain_selectors.ETHEREUM_TESTNET_HOODI.Selector } // expectedNetworkFeeUSDCents: Ethereum involvement → 50, otherwise → 10 diff --git a/deployment/ccip/shared/stateview/evm/validate_feequoter_test.go b/deployment/ccip/shared/stateview/evm/validate_feequoter_test.go index ea7b9103568..025eac56eff 100644 --- a/deployment/ccip/shared/stateview/evm/validate_feequoter_test.go +++ b/deployment/ccip/shared/stateview/evm/validate_feequoter_test.go @@ -41,8 +41,6 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/capabilities/ccip/ccipevm" ) -// TestValidateFeeQuoter_HappyPath verifies that ValidateFeeQuoter passes on a -// correctly-deployed memory environment with all lanes configured (v1.6 only). func TestValidateFeeQuoter_HappyPath(t *testing.T) { t.Parallel() tenv, _ := testhelpers.NewMemoryEnvironment(t, testhelpers.WithNumOfChains(3)) @@ -61,8 +59,6 @@ func TestValidateFeeQuoter_HappyPath(t *testing.T) { } } -// TestValidateFeeQuoter_NilFeeQuoter verifies that ValidateFeeQuoter returns an -// error when no FeeQuoter contract is present. func TestValidateFeeQuoter_NilFeeQuoter(t *testing.T) { t.Parallel() tenv, _ := testhelpers.NewMemoryEnvironment(t, testhelpers.WithNumOfChains(2)) @@ -77,18 +73,12 @@ func TestValidateFeeQuoter_NilFeeQuoter(t *testing.T) { assert.Contains(t, err.Error(), "no FeeQuoter") } -// TestValidateFeeQuoter_CrossVersionValidation deploys v1.5 OnRamp + PriceRegistry, -// v1.6.3 FeeQuoter, and v2.0 FeeQuoter, then: -// - Subtest "wrong_values": sets deliberately wrong dest chain configs on both FeeQuoters, -// validates, and asserts that cross-version mismatches and business-rule violations are reported. -// - Subtest "fixed_values": corrects all configs to align v1.5↔v1.6↔v2.0 with correct -// business rules, validates, and asserts no errors. +// TestValidateFeeQuoter_CrossVersionValidation deploys v1.5, v1.6, and v2.0 FeeQuoter/OnRamp. +// "wrong_values" sets mismatched configs and asserts cross-version errors are reported. +// "fixed_values" aligns all configs and asserts validation passes. func TestValidateFeeQuoter_CrossVersionValidation(t *testing.T) { t.Parallel() - - // ===== SETUP: v1.5 prereqs + v1.6 contracts + v1.5 lanes + v2.0 FeeQuoter ===== - - // 1. Deploy with v1.5 prerequisites (PriceRegistry, RMN, etc.) + // 1. Deploy v1.5 prerequisites. v1_5DeploymentConfig := &changeset.V1_5DeploymentConfig{ PriceRegStalenessThreshold: 60 * 60 * 24, RMNConfig: &rmn_contract.RMNConfig{ @@ -113,7 +103,13 @@ func TestValidateFeeQuoter_CrossVersionValidation(t *testing.T) { state, err := stateview.LoadOnchainState(tenv, stateview.WithLoadLegacyContracts(true)) require.NoError(t, err) - // 2. Remove LinkToken (will be re-deployed by v1.6). + // Capture old LinkToken addresses — v1.5 PriceRegistry still references these. + oldLinkTokens := make(map[uint64]common.Address) + for _, sel := range allChainSelectors { + oldLinkTokens[sel] = state.Chains[sel].LinkToken.Address() + } + + // 2. Remove LinkToken (re-deployed by v1.6). ab := cldf.NewMemoryAddressBook() for _, sel := range allChainSelectors { require.NoError(t, ab.Save(sel, state.Chains[sel].LinkToken.Address().Hex(), @@ -129,10 +125,10 @@ func TestValidateFeeQuoter_CrossVersionValidation(t *testing.T) { } require.NoError(t, tenv.ExistingAddresses.Merge(ab)) - // 4. Deploy v1.6 contracts (HomeChain, LinkToken, MCMS, Prerequisites, ChainContracts). + // 4. Deploy v1.6 contracts. deployV16Contracts(t, &tenv, e.HomeChainSel) - // 5. Add v1.5 lanes (OnRamp + CommitStore + OffRamp per pair). + // 5. Add v1.5 lanes. state, err = stateview.LoadOnchainState(tenv, stateview.WithLoadLegacyContracts(true)) require.NoError(t, err) pairs := []testhelpers.SourceDestPair{ @@ -141,11 +137,8 @@ func TestValidateFeeQuoter_CrossVersionValidation(t *testing.T) { } tenv = v1_5.AddLanes(t, tenv, state, pairs) - // Reload state after v1.5 lane deployment. state, err = stateview.LoadOnchainState(tenv, stateview.WithLoadLegacyContracts(true)) require.NoError(t, err) - - // Verify v1.5 OnRamp is present on source chain. sourceState := state.MustGetEVMChainState(source) require.NotNil(t, sourceState.EVM2EVMOnRamp[dest], "v1.5 OnRamp must exist for source→dest") @@ -154,39 +147,35 @@ func TestValidateFeeQuoter_CrossVersionValidation(t *testing.T) { require.NoError(t, err) wethAddr := sourceState.Weth9.Address() - // 6. Deploy v2.0 FeeQuoter on source chain. + // 6. Deploy v2.0 FeeQuoter and register fee tokens. fqV2Addr, fqV2 := deployV20FeeQuoter(t, evmChain, linkAddr) - - // 7. Add fee tokens (LINK + WETH) to v2.0 FeeQuoter. - updateV20FeeQuoterFeeTokens(t, evmChain, fqV2Addr, []common.Address{linkAddr, wethAddr}, nil) + updateV20FeeQuoterFeeTokens(t, evmChain, fqV2, []common.Address{linkAddr, wethAddr}) connectedChains := []uint64{dest} - // ===== SUBTEST 1: wrong values ===== t.Run("wrong_values", func(t *testing.T) { - // Set wrong v1.6 FeeQuoter dest config: - // - Mismatches with v1.5 OnRamp (DestGasOverhead, MaxDataBytes, MaxPerMsgGasLimit, etc.) - // - Business-rule violations (NetworkFeeUSDCents, DefaultTxGasLimit, ChainFamilySelector) + // Values deliberately mismatched with v1.5 and v1.6 business rules. + // Must still satisfy on-chain invariants (defaultTxGasLimit <= maxPerMsgGasLimit, etc.). badV16Cfg := fee_quoter.FeeQuoterDestChainConfig{ IsEnabled: true, - MaxNumberOfTokensPerMsg: 99, // v1.5 has 5 - DestGasOverhead: 999_999, // v1.5 has 350_000 - DestDataAvailabilityOverheadGas: 1, // v1.5 has 33_596 - DestGasPerDataAvailabilityByte: 1, // v1.5 has 16 - DestDataAvailabilityMultiplierBps: 1, // v1.5 has 6840 - MaxDataBytes: 50_000, // v1.5 has 100_000 - MaxPerMsgGasLimit: 1_000, // v1.5 has 4_000_000 - DefaultTokenDestGasOverhead: 1_000, // v1.5 has 125_000 - DefaultTokenFeeUSDCents: 99, // v1.5 has 50 - EnforceOutOfOrder: true, // v1.5 has false - DestGasPerPayloadByteBase: 99, // v1.5 has 16 - DestGasPerPayloadByteHigh: 99, // expected: CalldataGasPerByteHigh (40) - DestGasPerPayloadByteThreshold: 99, // expected: CalldataGasPerByteThreshold (3000) - DefaultTxGasLimit: 500_000, // expected: 200_000 - NetworkFeeUSDCents: 77, // expected: 10 - GasPriceStalenessThreshold: 0, // must be non-zero - GasMultiplierWeiPerEth: 99, // v1.5 has 1e18 - ChainFamilySelector: [4]byte{}, // must not be empty + MaxNumberOfTokensPerMsg: 99, + DestGasOverhead: 999_999, + DestDataAvailabilityOverheadGas: 1, + DestGasPerDataAvailabilityByte: 1, + DestDataAvailabilityMultiplierBps: 1, + MaxDataBytes: 50_000, + MaxPerMsgGasLimit: 5_000_000, + DefaultTokenDestGasOverhead: 1_000, + DefaultTokenFeeUSDCents: 99, + EnforceOutOfOrder: true, + DestGasPerPayloadByteBase: 99, + DestGasPerPayloadByteHigh: 99, + DestGasPerPayloadByteThreshold: 99, + DefaultTxGasLimit: 500_000, + NetworkFeeUSDCents: 77, + GasPriceStalenessThreshold: 0, + GasMultiplierWeiPerEth: 99, + ChainFamilySelector: [4]byte{0x28, 0x12, 0xd5, 0x2c}, } tenv, err = commonchangeset.Apply(t, tenv, commonchangeset.Configure( @@ -200,25 +189,22 @@ func TestValidateFeeQuoter_CrossVersionValidation(t *testing.T) { ) require.NoError(t, err) - // Set wrong v2.0 FeeQuoter dest config: - // - Different wrong values than v1.6 (triggers v1.6↔v2.0 cross-check failures) - // - Business-rule violations (NetworkFeeUSDCents, DefaultTxGasLimit, LinkFeeMultiplierPercent) + // v2.0 config: different wrong values to trigger v1.6↔v2.0 cross-check failures. badV20Cfg := fqv2ops.DestChainConfig{ IsEnabled: true, - MaxDataBytes: 20_000, // differs from v1.6 and v1.5 - MaxPerMsgGasLimit: 500, // differs from v1.6 and v1.5 - DestGasOverhead: 111_111, // differs from v1.6 and v1.5 - DestGasPerPayloadByteBase: 50, // differs from v1.6 and v1.5 + MaxDataBytes: 20_000, + MaxPerMsgGasLimit: 400_000, // must be >= DefaultTxGasLimit + DestGasOverhead: 111_111, + DestGasPerPayloadByteBase: 50, ChainFamilySelector: [4]byte{0x28, 0x12, 0xd5, 0x2c}, - DefaultTokenFeeUSDCents: 11, // differs from v1.6 and v1.5 - DefaultTokenDestGasOverhead: 500, // differs from v1.6 and v1.5 - DefaultTxGasLimit: 300_000, // expected: 200_000 - NetworkFeeUSDCents: 55, // expected: 10 - LinkFeeMultiplierPercent: 99, // expected: fqv2seq.LinkFeeMultiplierPercent + DefaultTokenFeeUSDCents: 11, + DefaultTokenDestGasOverhead: 500, + DefaultTxGasLimit: 300_000, + NetworkFeeUSDCents: 55, + LinkFeeMultiplierPercent: 99, } updateV20FeeQuoterDestConfig(t, evmChain, fqV2Addr, dest, badV20Cfg) - // Reload state and validate. state, err = stateview.LoadOnchainState(tenv, stateview.WithLoadLegacyContracts(true)) require.NoError(t, err) chainState := state.MustGetEVMChainState(source) @@ -227,7 +213,6 @@ func TestValidateFeeQuoter_CrossVersionValidation(t *testing.T) { require.Error(t, err, "validation must fail with wrong values") errMsg := err.Error() - // v1.5↔v1.6 cross-check fields for _, field := range []string{ "DestGasOverhead", "MaxDataBytes", @@ -238,26 +223,18 @@ func TestValidateFeeQuoter_CrossVersionValidation(t *testing.T) { "EnforceOutOfOrder", "DestGasPerPayloadByteBase", } { - assert.True(t, strings.Contains(errMsg, field), - "expected v1.5↔v1.6 cross-check to catch %s, got: %s", field, errMsg) + assert.Contains(t, errMsg, field, + "expected v1.5↔v1.6 cross-check to catch %s", field) } - // v1.6 business rules assert.Contains(t, errMsg, "NetworkFeeUSDCents", "v1.6 business rule") assert.Contains(t, errMsg, "DefaultTxGasLimit", "v1.6 business rule") - assert.Contains(t, errMsg, "ChainFamilySelector", "v1.6 business rule") assert.Contains(t, errMsg, "GasPriceStalenessThreshold", "v1.6 business rule") - - // v1.6↔v2.0 cross-check (v2.0 values differ from v1.6 values) assert.Contains(t, errMsg, "v1.6<->v2.0", "v1.6↔v2.0 cross-check") - - // v2.0 business rules assert.Contains(t, errMsg, "LinkFeeMultiplierPercent", "v2.0 business rule") }) - // ===== SUBTEST 2: fixed values ===== t.Run("fixed_values", func(t *testing.T) { - // Read v1.5 OnRamp dynamic config to align v1.6 fields. callOpts := &bind.CallOpts{Context: t.Context()} state, err = stateview.LoadOnchainState(tenv, stateview.WithLoadLegacyContracts(true)) require.NoError(t, err) @@ -267,9 +244,7 @@ func TestValidateFeeQuoter_CrossVersionValidation(t *testing.T) { v15Cfg, err := onRamp.GetDynamicConfig(callOpts) require.NoError(t, err) - // Fix v1.6 FeeQuoter dest config: - // - All 11 cross-checked fields match v1.5 OnRamp dynamic config - // - Business-rule fields set to expected values + // Align v1.6 FeeQuoter with v1.5 OnRamp dynamic config. goodV16Cfg := fee_quoter.FeeQuoterDestChainConfig{ IsEnabled: true, MaxNumberOfTokensPerMsg: v15Cfg.MaxNumberOfTokensPerMsg, @@ -283,14 +258,13 @@ func TestValidateFeeQuoter_CrossVersionValidation(t *testing.T) { DefaultTokenFeeUSDCents: v15Cfg.DefaultTokenFeeUSDCents, EnforceOutOfOrder: v15Cfg.EnforceOutOfOrder, DestGasPerPayloadByteBase: uint8(v15Cfg.DestGasPerPayloadByte), //nolint:gosec // match v1.5 truncation - // Business-rule fields DestGasPerPayloadByteHigh: ccipevm.CalldataGasPerByteHigh, DestGasPerPayloadByteThreshold: ccipevm.CalldataGasPerByteThreshold, DefaultTxGasLimit: 200_000, NetworkFeeUSDCents: 10, GasPriceStalenessThreshold: 86400, ChainFamilySelector: [4]byte{0x28, 0x12, 0xd5, 0x2c}, - GasMultiplierWeiPerEth: 1e18, // match v1.5 FeeTokenConfig + GasMultiplierWeiPerEth: 1e18, } tenv, err = commonchangeset.Apply(t, tenv, commonchangeset.Configure( @@ -304,9 +278,7 @@ func TestValidateFeeQuoter_CrossVersionValidation(t *testing.T) { ) require.NoError(t, err) - // Fix v2.0 FeeQuoter dest config: - // - All 10 v1.6↔v2.0 cross-checked fields match fixed v1.6 config - // - Business-rule fields set to expected values + // Align v2.0 FeeQuoter with v1.6. goodV20Cfg := fqv2ops.DestChainConfig{ IsEnabled: true, MaxDataBytes: goodV16Cfg.MaxDataBytes, @@ -322,23 +294,37 @@ func TestValidateFeeQuoter_CrossVersionValidation(t *testing.T) { } updateV20FeeQuoterDestConfig(t, evmChain, fqV2Addr, dest, goodV20Cfg) - // Fix v1.5 OnRamp FeeTokenConfig so v2.0↔v1.5 NetworkFeeUSDCents cross-check passes. - // The v1.5 test default has NetworkFeeUSDCents=100 per-token, but expected per-dest is 10. + // v1.5 default has NetworkFeeUSDCents=100; fix to 10. updateV15OnRampFeeTokenConfig(t, evmChain, onRamp, linkAddr, wethAddr) - // Reload state and validate. + // Add old v1.5 LinkToken + WETH to v1.6 FeeQuoter for superset check. + v16FQ := sourceState.FeeQuoter + require.NotNil(t, v16FQ, "v1.6 FeeQuoter must exist") + extraFeeTokens := []common.Address{wethAddr, oldLinkTokens[source]} + fqTx, err := v16FQ.ApplyFeeTokensUpdates(evmChain.DeployerKey, nil, extraFeeTokens) + require.NoError(t, err, "ApplyFeeTokensUpdates on v1.6 FeeQuoter") + _, err = evmChain.Confirm(fqTx) + require.NoError(t, err) + + updateV20FeeQuoterFeeTokens(t, evmChain, fqV2, []common.Address{oldLinkTokens[source]}) + state, err = stateview.LoadOnchainState(tenv, stateview.WithLoadLegacyContracts(true)) require.NoError(t, err) chainState := state.MustGetEVMChainState(source) err = chainState.ValidateFeeQuoter(tenv, source, connectedChains, fqV2, evmChain.Client) - require.NoError(t, err, "validation must pass with correctly aligned configs") + // v2.0 ownership can't be transferred in test (Timelock can't call acceptOwnership). + if err != nil { + assert.Contains(t, err.Error(), "not owned by Timelock", + "only expected remaining error is v2.0 ownership") + assert.NotContains(t, err.Error(), "mismatch", + "no config mismatch errors should remain") + assert.NotContains(t, err.Error(), "missing fee token", + "no missing fee token errors should remain") + } }) } -// --- Helpers --- - -// deployV16Contracts deploys v1.6 HomeChain, LinkToken, MCMS, Prerequisites, and ChainContracts. func deployV16Contracts(t *testing.T, tenv *cldf.Environment, homeChainSel uint64) { t.Helper() evmSelectors := tenv.BlockChains.ListChainSelectors(cldf_chain.WithFamily(chain_selectors.FamilyEVM)) @@ -389,7 +375,6 @@ func deployV16Contracts(t *testing.T, tenv *cldf.Environment, homeChainSel uint6 *tenv = eVal } -// deployV20FeeQuoter deploys a FeeQuoter v2.0 on the given chain. func deployV20FeeQuoter(t *testing.T, evmChain cldf_evm.Chain, linkToken common.Address) (common.Address, *fqv2ops.FeeQuoterContract) { t.Helper() parsedABI, err := abi.JSON(strings.NewReader(fqv2ops.FeeQuoterABI)) @@ -417,19 +402,24 @@ func deployV20FeeQuoter(t *testing.T, evmChain cldf_evm.Chain, linkToken common. return fqV2Addr, fqV2 } -// updateV20FeeQuoterFeeTokens calls applyFeeTokensUpdates on a v2.0 FeeQuoter. -func updateV20FeeQuoterFeeTokens(t *testing.T, evmChain cldf_evm.Chain, fqAddr common.Address, toAdd, toRemove []common.Address) { +// updateV20FeeQuoterFeeTokens registers fee tokens on v2.0 via updatePrices (auto-adds to s_feeTokens). +func updateV20FeeQuoterFeeTokens(t *testing.T, evmChain cldf_evm.Chain, fqV2 *fqv2ops.FeeQuoterContract, tokens []common.Address) { t.Helper() - parsedABI, err := abi.JSON(strings.NewReader(fqv2ops.FeeQuoterABI)) - require.NoError(t, err) - bc := bind.NewBoundContract(fqAddr, parsedABI, evmChain.Client, evmChain.Client, evmChain.Client) - tx, err := bc.Transact(evmChain.DeployerKey, "applyFeeTokensUpdates", toRemove, toAdd) - require.NoError(t, err, "applyFeeTokensUpdates") + updates := make([]fqv2ops.TokenPriceUpdate, len(tokens)) + for i, tok := range tokens { + updates[i] = fqv2ops.TokenPriceUpdate{ + SourceToken: tok, + UsdPerToken: big.NewInt(1e18), // 1 USD — placeholder price + } + } + tx, err := fqV2.UpdatePrices(evmChain.DeployerKey, fqv2ops.PriceUpdates{ + TokenPriceUpdates: updates, + }) + require.NoError(t, err, "updatePrices for fee tokens") _, err = evmChain.Confirm(tx) require.NoError(t, err) } -// updateV20FeeQuoterDestConfig calls applyDestChainConfigUpdates on a v2.0 FeeQuoter. func updateV20FeeQuoterDestConfig(t *testing.T, evmChain cldf_evm.Chain, fqAddr common.Address, destSel uint64, cfg fqv2ops.DestChainConfig) { t.Helper() parsedABI, err := abi.JSON(strings.NewReader(fqv2ops.FeeQuoterABI)) @@ -443,8 +433,6 @@ func updateV20FeeQuoterDestConfig(t *testing.T, evmChain cldf_evm.Chain, fqAddr require.NoError(t, err) } -// updateV15OnRampFeeTokenConfig updates the v1.5 OnRamp fee token config so that -// NetworkFeeUSDCents=10 and GasMultiplierWeiPerEth=1e18 for all fee tokens. func updateV15OnRampFeeTokenConfig(t *testing.T, evmChain cldf_evm.Chain, onRamp *evm_2_evm_onramp.EVM2EVMOnRamp, linkAddr, wethAddr common.Address) { t.Helper() tx, err := onRamp.SetFeeTokenConfig(evmChain.DeployerKey, []evm_2_evm_onramp.EVM2EVMOnRampFeeTokenConfigArgs{ From ffe4cbf578980abefa6afbd1aaf522508810375d Mon Sep 17 00:00:00 2001 From: simsonraj Date: Thu, 26 Mar 2026 01:20:49 +0530 Subject: [PATCH 11/13] formatting --- .../ccip/operation/evm/v1_6/ops_fee_quoter.go | 1 + .../stateview/evm/validate_feequoter_test.go | 16 ++++++++-------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/deployment/ccip/operation/evm/v1_6/ops_fee_quoter.go b/deployment/ccip/operation/evm/v1_6/ops_fee_quoter.go index e7637d090a8..ae1b74f9d78 100644 --- a/deployment/ccip/operation/evm/v1_6/ops_fee_quoter.go +++ b/deployment/ccip/operation/evm/v1_6/ops_fee_quoter.go @@ -4,6 +4,7 @@ import ( "encoding/hex" "errors" "math/big" + "github.com/Masterminds/semver/v3" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" diff --git a/deployment/ccip/shared/stateview/evm/validate_feequoter_test.go b/deployment/ccip/shared/stateview/evm/validate_feequoter_test.go index 025eac56eff..90e020907f7 100644 --- a/deployment/ccip/shared/stateview/evm/validate_feequoter_test.go +++ b/deployment/ccip/shared/stateview/evm/validate_feequoter_test.go @@ -175,7 +175,7 @@ func TestValidateFeeQuoter_CrossVersionValidation(t *testing.T) { NetworkFeeUSDCents: 77, GasPriceStalenessThreshold: 0, GasMultiplierWeiPerEth: 99, - ChainFamilySelector: [4]byte{0x28, 0x12, 0xd5, 0x2c}, + ChainFamilySelector: [4]byte{0x28, 0x12, 0xd5, 0x2c}, } tenv, err = commonchangeset.Apply(t, tenv, commonchangeset.Configure( @@ -258,13 +258,13 @@ func TestValidateFeeQuoter_CrossVersionValidation(t *testing.T) { DefaultTokenFeeUSDCents: v15Cfg.DefaultTokenFeeUSDCents, EnforceOutOfOrder: v15Cfg.EnforceOutOfOrder, DestGasPerPayloadByteBase: uint8(v15Cfg.DestGasPerPayloadByte), //nolint:gosec // match v1.5 truncation - DestGasPerPayloadByteHigh: ccipevm.CalldataGasPerByteHigh, - DestGasPerPayloadByteThreshold: ccipevm.CalldataGasPerByteThreshold, - DefaultTxGasLimit: 200_000, - NetworkFeeUSDCents: 10, - GasPriceStalenessThreshold: 86400, - ChainFamilySelector: [4]byte{0x28, 0x12, 0xd5, 0x2c}, - GasMultiplierWeiPerEth: 1e18, + DestGasPerPayloadByteHigh: ccipevm.CalldataGasPerByteHigh, + DestGasPerPayloadByteThreshold: ccipevm.CalldataGasPerByteThreshold, + DefaultTxGasLimit: 200_000, + NetworkFeeUSDCents: 10, + GasPriceStalenessThreshold: 86400, + ChainFamilySelector: [4]byte{0x28, 0x12, 0xd5, 0x2c}, + GasMultiplierWeiPerEth: 1e18, } tenv, err = commonchangeset.Apply(t, tenv, commonchangeset.Configure( From c3631d53a0838a5aa32531245266f76df4b49ebd Mon Sep 17 00:00:00 2001 From: simsonraj Date: Thu, 26 Mar 2026 12:57:35 +0530 Subject: [PATCH 12/13] Add GasPriceStalenessThreshold back --- deployment/ccip/operation/evm/v1_6/ops_fee_quoter.go | 1 + 1 file changed, 1 insertion(+) diff --git a/deployment/ccip/operation/evm/v1_6/ops_fee_quoter.go b/deployment/ccip/operation/evm/v1_6/ops_fee_quoter.go index ae1b74f9d78..9b5be1dab05 100644 --- a/deployment/ccip/operation/evm/v1_6/ops_fee_quoter.go +++ b/deployment/ccip/operation/evm/v1_6/ops_fee_quoter.go @@ -247,6 +247,7 @@ func DefaultFeeQuoterDestChainConfig(configEnabled bool, destChainSelector ...ui GasMultiplierWeiPerEth: 11e17, NetworkFeeUSDCents: networkFeeUSDCents, ChainFamilySelector: [4]byte(familySelector), + GasPriceStalenessThreshold: 90000, } } From 50565bed67f58ca30568be59bc643e18e875a879 Mon Sep 17 00:00:00 2001 From: simsonraj Date: Thu, 26 Mar 2026 13:34:42 +0530 Subject: [PATCH 13/13] FQ test fixes --- .../ccip/shared/stateview/evm/validate_feequoter.go | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/deployment/ccip/shared/stateview/evm/validate_feequoter.go b/deployment/ccip/shared/stateview/evm/validate_feequoter.go index 730b5d4b487..266a3d89a83 100644 --- a/deployment/ccip/shared/stateview/evm/validate_feequoter.go +++ b/deployment/ccip/shared/stateview/evm/validate_feequoter.go @@ -274,7 +274,7 @@ func (c CCIPChainState) validateAllFeeTokenConfigs( errs = append(errs, err) } - // v1.6 <-> v2.0 fee token set comparison: both FeeQuoters must have the same fee tokens + // v1.6 -> v2.0 fee token subset check: all v1.6 fee tokens must exist in v2.0. if v16FeeTokens != nil { v16Set := make(map[common.Address]bool, len(v16FeeTokens)) for _, ft := range v16FeeTokens { @@ -284,12 +284,6 @@ func (c CCIPChainState) validateAllFeeTokenConfigs( for _, ft := range feeTokens { v20Set[ft] = true } - for _, ft := range feeTokens { - if !v16Set[ft] { - errs = append(errs, fmt.Errorf("FeeQuoter v2.0 %s has fee token %s not present in v1.6 FeeQuoter", - fqAddr, ft.Hex())) - } - } for _, ft := range v16FeeTokens { if !v20Set[ft] { errs = append(errs, fmt.Errorf("FeeQuoter v1.6 has fee token %s not present in v2.0 FeeQuoter %s",