diff --git a/chains/evm/deployment/v1_0_0/operations/weth/weth.go b/chains/evm/deployment/v1_0_0/operations/weth/weth.go index 827e41a912..4e11393bb7 100644 --- a/chains/evm/deployment/v1_0_0/operations/weth/weth.go +++ b/chains/evm/deployment/v1_0_0/operations/weth/weth.go @@ -1,10 +1,18 @@ package weth import ( + "encoding/json" + "fmt" + "math/big" + "github.com/Masterminds/semver/v3" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/operations/contract" cldf_deployment "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + mcms_types "github.com/smartcontractkit/mcms/types" "github.com/smartcontractkit/chainlink-evm/gethwrappers/shared/generated/initial/weth9" ) @@ -24,3 +32,109 @@ var Deploy = contract.NewDeploy(contract.DeployParams[ConstructorArgs]{ }, Validate: func(ConstructorArgs) error { return nil }, }) + +// ============================================================================= +// WETH Withdraw Operation (Unwrap WETH to Native ETH) +// ============================================================================= + +// WithdrawInput specifies the amount of WETH to unwrap to native ETH. +// The native ETH will be sent to msg.sender (the MCMS timelock). +type WithdrawInput struct { + Amount *big.Int // Amount in wei to unwrap +} + +// Withdraw unwraps WETH to native ETH. The ETH is sent to msg.sender. +// This operation should be called by the MCMS timelock after receiving WETH +// from fee withdrawals. +var Withdraw = contract.NewWrite(contract.WriteParams[WithdrawInput, *weth9.WETH9]{ + Name: "weth:withdraw", + Version: semver.MustParse("1.0.0"), + Description: "Unwraps WETH to native ETH, sending ETH to msg.sender", + ContractType: ContractType, + ContractABI: weth9.WETH9ABI, + NewContract: weth9.NewWETH9, + // Always return false - withdraw is meant to be called by MCMS timelock as part of + // atomic batch operations (e.g., sweep-and-unwrap). The timelock receives WETH from + // earlier transactions in the batch, so we can't execute directly with deployer key. + IsAllowedCaller: func(_ *weth9.WETH9, _ *bind.CallOpts, _ common.Address, _ WithdrawInput) (bool, error) { return false, nil }, + Validate: func(args WithdrawInput) error { return nil }, + CallContract: func(weth *weth9.WETH9, opts *bind.TransactOpts, args WithdrawInput) (*types.Transaction, error) { + return weth.Withdraw(opts, args.Amount) + }, +}) + +// ============================================================================= +// WETH Balance Read Operation +// ============================================================================= + +// BalanceOf reads the WETH balance of an account +var BalanceOf = contract.NewRead(contract.ReadParams[common.Address, *big.Int, *weth9.WETH9]{ + Name: "weth:balance-of", + Version: semver.MustParse("1.0.0"), + Description: "Reads the WETH balance of an account", + ContractType: ContractType, + NewContract: weth9.NewWETH9, + CallContract: func(weth *weth9.WETH9, opts *bind.CallOpts, account common.Address) (*big.Int, error) { + return weth.BalanceOf(opts, account) + }, +}) + +// ============================================================================= +// Native ETH Transfer Helper +// ============================================================================= + +// CreateNativeETHTransfer creates an MCMS transaction that transfers native ETH. +// This is used after unwrapping WETH to send the native ETH to the treasury. +// The MCMS timelock will execute this as a simple value transfer. +func CreateNativeETHTransfer(chainSelector uint64, to common.Address, amount *big.Int) (mcms_types.BatchOperation, error) { + if amount == nil || amount.Sign() <= 0 { + return mcms_types.BatchOperation{}, fmt.Errorf("amount must be positive, got %v", amount) + } + if to == (common.Address{}) { + return mcms_types.BatchOperation{}, fmt.Errorf("recipient address cannot be zero") + } + + // Create AdditionalFields with the ETH value as a number (not quoted string) + // MCMS expects {"value": 123} not {"value": "123"} + additionalFields := json.RawMessage(fmt.Sprintf(`{"value": %s}`, amount.String())) + + return mcms_types.BatchOperation{ + ChainSelector: mcms_types.ChainSelector(chainSelector), + Transactions: []mcms_types.Transaction{ + { + OperationMetadata: mcms_types.OperationMetadata{ + ContractType: "NativeETHTransfer", + }, + To: to.Hex(), + Data: []byte{}, // Empty but non-nil for MCMS validation + AdditionalFields: additionalFields, + }, + }, + }, nil +} + +// CreateNativeETHTransferTx creates a single Transaction for native ETH transfer. +// Unlike CreateNativeETHTransfer (which returns BatchOperation), this returns a +// Transaction that can be combined with other transactions in a single atomic batch. +// Use this when building multi-step atomic operations. +func CreateNativeETHTransferTx(to common.Address, amount *big.Int) (mcms_types.Transaction, error) { + if amount == nil || amount.Sign() <= 0 { + return mcms_types.Transaction{}, fmt.Errorf("amount must be positive, got %v", amount) + } + if to == (common.Address{}) { + return mcms_types.Transaction{}, fmt.Errorf("recipient address cannot be zero") + } + + // Create AdditionalFields with the ETH value as a number (not quoted string) + // MCMS expects {"value": 123} not {"value": "123"} + additionalFields := json.RawMessage(fmt.Sprintf(`{"value": %s}`, amount.String())) + + return mcms_types.Transaction{ + OperationMetadata: mcms_types.OperationMetadata{ + ContractType: "NativeETHTransfer", + }, + To: to.Hex(), + Data: []byte{}, // Empty but non-nil for MCMS validation + AdditionalFields: additionalFields, + }, nil +} diff --git a/chains/evm/deployment/v1_0_0/operations/weth/weth_test.go b/chains/evm/deployment/v1_0_0/operations/weth/weth_test.go new file mode 100644 index 0000000000..0f0ab1acce --- /dev/null +++ b/chains/evm/deployment/v1_0_0/operations/weth/weth_test.go @@ -0,0 +1,314 @@ +package weth + +import ( + "encoding/json" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + mcms_types "github.com/smartcontractkit/mcms/types" + "github.com/stretchr/testify/require" +) + +func TestCreateNativeETHTransfer(t *testing.T) { + validChainSelector := uint64(5009297550715157269) + validAddress := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678") + validAmount := big.NewInt(1000000000000000000) // 1 ETH in wei + + tests := []struct { + name string + chainSelector uint64 + to common.Address + amount *big.Int + expectedErr string + validate func(t *testing.T, result mcms_types.BatchOperation) + }{ + { + name: "valid transfer", + chainSelector: validChainSelector, + to: validAddress, + amount: validAmount, + validate: func(t *testing.T, result mcms_types.BatchOperation) { + require.Equal(t, mcms_types.ChainSelector(validChainSelector), result.ChainSelector) + require.Len(t, result.Transactions, 1) + tx := result.Transactions[0] + require.Equal(t, validAddress.Hex(), tx.To) + require.Equal(t, "NativeETHTransfer", tx.OperationMetadata.ContractType) + require.Nil(t, tx.Data) + + // Verify AdditionalFields contains the correct value + var additionalFields map[string]string + err := json.Unmarshal(tx.AdditionalFields, &additionalFields) + require.NoError(t, err) + require.Equal(t, validAmount.String(), additionalFields["value"]) + }, + }, + { + name: "zero amount", + chainSelector: validChainSelector, + to: validAddress, + amount: big.NewInt(0), + expectedErr: "amount must be positive", + }, + { + name: "negative amount", + chainSelector: validChainSelector, + to: validAddress, + amount: big.NewInt(-1), + expectedErr: "amount must be positive", + }, + { + name: "nil amount", + chainSelector: validChainSelector, + to: validAddress, + amount: nil, + expectedErr: "amount must be positive", + }, + { + name: "zero address", + chainSelector: validChainSelector, + to: common.Address{}, + amount: validAmount, + expectedErr: "recipient address cannot be zero", + }, + { + name: "large amount", + chainSelector: validChainSelector, + to: validAddress, + amount: new(big.Int).Mul(big.NewInt(1000000), big.NewInt(1e18)), // 1M ETH + validate: func(t *testing.T, result mcms_types.BatchOperation) { + require.Len(t, result.Transactions, 1) + var additionalFields map[string]string + err := json.Unmarshal(result.Transactions[0].AdditionalFields, &additionalFields) + require.NoError(t, err) + expectedAmount := new(big.Int).Mul(big.NewInt(1000000), big.NewInt(1e18)) + require.Equal(t, expectedAmount.String(), additionalFields["value"]) + }, + }, + { + name: "different chain selector", + chainSelector: uint64(4340886533089894000), // Different chain + to: validAddress, + amount: validAmount, + validate: func(t *testing.T, result mcms_types.BatchOperation) { + require.Equal(t, mcms_types.ChainSelector(4340886533089894000), result.ChainSelector) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := CreateNativeETHTransfer(tc.chainSelector, tc.to, tc.amount) + + if tc.expectedErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectedErr) + return + } + + require.NoError(t, err) + if tc.validate != nil { + tc.validate(t, result) + } + }) + } +} + +func TestCreateNativeETHTransferTx(t *testing.T) { + validAddress := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678") + validAmount := big.NewInt(1000000000000000000) // 1 ETH in wei + + tests := []struct { + name string + to common.Address + amount *big.Int + expectedErr string + validate func(t *testing.T, result mcms_types.Transaction) + }{ + { + name: "valid transfer returns Transaction", + to: validAddress, + amount: validAmount, + validate: func(t *testing.T, result mcms_types.Transaction) { + require.Equal(t, validAddress.Hex(), result.To) + require.Nil(t, result.Data) + }, + }, + { + name: "ContractType is NativeETHTransfer", + to: validAddress, + amount: validAmount, + validate: func(t *testing.T, result mcms_types.Transaction) { + require.Equal(t, "NativeETHTransfer", result.OperationMetadata.ContractType) + }, + }, + { + name: "To field is hex-encoded address", + to: validAddress, + amount: validAmount, + validate: func(t *testing.T, result mcms_types.Transaction) { + // Verify it's a valid hex address format + require.Equal(t, validAddress.Hex(), result.To) + // Should start with 0x + require.True(t, len(result.To) > 2 && result.To[:2] == "0x") + }, + }, + { + name: "AdditionalFields has correct value encoding", + to: validAddress, + amount: validAmount, + validate: func(t *testing.T, result mcms_types.Transaction) { + var additionalFields map[string]string + err := json.Unmarshal(result.AdditionalFields, &additionalFields) + require.NoError(t, err) + require.Contains(t, additionalFields, "value") + require.Equal(t, validAmount.String(), additionalFields["value"]) + }, + }, + { + name: "zero amount", + to: validAddress, + amount: big.NewInt(0), + expectedErr: "amount must be positive", + }, + { + name: "nil amount", + to: validAddress, + amount: nil, + expectedErr: "amount must be positive", + }, + { + name: "zero address", + to: common.Address{}, + amount: validAmount, + expectedErr: "recipient address cannot be zero", + }, + { + name: "negative amount", + to: validAddress, + amount: big.NewInt(-100), + expectedErr: "amount must be positive", + }, + { + name: "small amount (1 wei)", + to: validAddress, + amount: big.NewInt(1), + validate: func(t *testing.T, result mcms_types.Transaction) { + var additionalFields map[string]string + err := json.Unmarshal(result.AdditionalFields, &additionalFields) + require.NoError(t, err) + require.Equal(t, "1", additionalFields["value"]) + }, + }, + { + name: "very large amount", + to: validAddress, + amount: new(big.Int).Exp(big.NewInt(10), big.NewInt(30), nil), // 10^30 wei + validate: func(t *testing.T, result mcms_types.Transaction) { + var additionalFields map[string]string + err := json.Unmarshal(result.AdditionalFields, &additionalFields) + require.NoError(t, err) + expectedAmount := new(big.Int).Exp(big.NewInt(10), big.NewInt(30), nil) + require.Equal(t, expectedAmount.String(), additionalFields["value"]) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := CreateNativeETHTransferTx(tc.to, tc.amount) + + if tc.expectedErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectedErr) + return + } + + require.NoError(t, err) + if tc.validate != nil { + tc.validate(t, result) + } + }) + } +} + +// TestAdditionalFieldsJSONStructure verifies the exact JSON structure of AdditionalFields +func TestAdditionalFieldsJSONStructure(t *testing.T) { + validAddress := common.HexToAddress("0xabcdef1234567890abcdef1234567890abcdef12") + amount := big.NewInt(123456789) + + t.Run("CreateNativeETHTransfer JSON structure", func(t *testing.T) { + result, err := CreateNativeETHTransfer(12345, validAddress, amount) + require.NoError(t, err) + require.Len(t, result.Transactions, 1) + + // Unmarshal and verify structure + var fields map[string]interface{} + err = json.Unmarshal(result.Transactions[0].AdditionalFields, &fields) + require.NoError(t, err) + + // Should only have "value" key + require.Len(t, fields, 1) + require.Contains(t, fields, "value") + + // Value should be the string representation of the amount + require.Equal(t, "123456789", fields["value"]) + }) + + t.Run("CreateNativeETHTransferTx JSON structure", func(t *testing.T) { + result, err := CreateNativeETHTransferTx(validAddress, amount) + require.NoError(t, err) + + // Unmarshal and verify structure + var fields map[string]interface{} + err = json.Unmarshal(result.AdditionalFields, &fields) + require.NoError(t, err) + + // Should only have "value" key + require.Len(t, fields, 1) + require.Contains(t, fields, "value") + + // Value should be the string representation of the amount + require.Equal(t, "123456789", fields["value"]) + }) +} + +// TestBatchOperationVsTransaction verifies the difference between the two functions +func TestBatchOperationVsTransaction(t *testing.T) { + validAddress := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678") + validAmount := big.NewInt(1e18) + chainSelector := uint64(5009297550715157269) + + t.Run("CreateNativeETHTransfer returns BatchOperation with one transaction", func(t *testing.T) { + batchOp, err := CreateNativeETHTransfer(chainSelector, validAddress, validAmount) + require.NoError(t, err) + + // Verify it's a BatchOperation with exactly one transaction + require.Equal(t, mcms_types.ChainSelector(chainSelector), batchOp.ChainSelector) + require.Len(t, batchOp.Transactions, 1) + }) + + t.Run("CreateNativeETHTransferTx returns single Transaction", func(t *testing.T) { + tx, err := CreateNativeETHTransferTx(validAddress, validAmount) + require.NoError(t, err) + + // Verify it returns a Transaction (not wrapped in BatchOperation) + require.Equal(t, validAddress.Hex(), tx.To) + require.Equal(t, "NativeETHTransfer", tx.OperationMetadata.ContractType) + }) + + t.Run("transactions from both functions have same structure", func(t *testing.T) { + batchOp, err := CreateNativeETHTransfer(chainSelector, validAddress, validAmount) + require.NoError(t, err) + + tx, err := CreateNativeETHTransferTx(validAddress, validAmount) + require.NoError(t, err) + + // The transaction inside BatchOperation should match the standalone Transaction + batchTx := batchOp.Transactions[0] + require.Equal(t, batchTx.To, tx.To) + require.Equal(t, batchTx.Data, tx.Data) + require.Equal(t, batchTx.OperationMetadata, tx.OperationMetadata) + require.Equal(t, batchTx.AdditionalFields, tx.AdditionalFields) + }) +} \ No newline at end of file diff --git a/chains/evm/deployment/v1_5_0/changesets/onramp_fees.go b/chains/evm/deployment/v1_5_0/changesets/onramp_fees.go new file mode 100644 index 0000000000..4bc29d89c5 --- /dev/null +++ b/chains/evm/deployment/v1_5_0/changesets/onramp_fees.go @@ -0,0 +1,425 @@ +package changesets + +import ( + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + + "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_5_0/evm_2_evm_onramp" + fee_quoter_binding "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_6_0/fee_quoter" + + evm_datastore_utils "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/datastore" + evm_sequences "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/sequences" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_5_0/operations/onramp" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_5_0/sequences" + fee_quoter_ops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_0/operations/fee_quoter" + v1_6_0_sequences "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_6_0/sequences" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils/changesets" + datastore_utils "github.com/smartcontractkit/chainlink-ccip/deployment/utils/datastore" + "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf_deployment "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-evm/gethwrappers/shared/generated/initial/weth9" +) + +// ============================================================================= +// Configuration Changeset +// ============================================================================= + +// ConfigureFeeSweepV150Cfg configures an OnRamp to send all LINK fees to treasury +type ConfigureFeeSweepV150Cfg struct { + ChainSel uint64 `json:"chainSelector" yaml:"chainSelector"` + OnRampAddress common.Address `json:"onRampAddress" yaml:"onRampAddress"` + Treasury common.Address `json:"treasury" yaml:"treasury"` +} + +func (c ConfigureFeeSweepV150Cfg) ChainSelector() uint64 { + return c.ChainSel +} + +// ConfigureFeeSweepV150 returns a changeset that configures the OnRamp to send all LINK fees to treasury. +func ConfigureFeeSweepV150(allowedRecipients map[uint64]common.Address) func(*changesets.MCMSReaderRegistry) cldf_deployment.ChangeSetV2[changesets.WithMCMS[ConfigureFeeSweepV150Cfg]] { + return changesets.NewFromOnChainSequence(changesets.NewFromOnChainSequenceParams[ + sequences.ConfigureFeeSweepV150Input, + evm.Chain, + ConfigureFeeSweepV150Cfg, + ]{ + Sequence: sequences.ConfigureFeeSweepV150Sequence, + ResolveInput: func(e cldf_deployment.Environment, cfg ConfigureFeeSweepV150Cfg) (sequences.ConfigureFeeSweepV150Input, error) { + return sequences.ConfigureFeeSweepV150Input{ + ChainSelector: cfg.ChainSel, + OnRampAddress: cfg.OnRampAddress, + Treasury: cfg.Treasury, + AllowedRecipients: allowedRecipients, + }, nil + }, + ResolveDep: evm_sequences.ResolveEVMChainDep[ConfigureFeeSweepV150Cfg], + }) +} + +// ============================================================================= +// LINK Sweep Changeset +// ============================================================================= + +// SweepLinkFeesV150Cfg triggers LINK fee payout with safety validation +type SweepLinkFeesV150Cfg struct { + ChainSel uint64 `json:"chainSelector" yaml:"chainSelector"` + OnRampAddress common.Address `json:"onRampAddress" yaml:"onRampAddress"` + ExpectedTreasury common.Address `json:"expectedTreasury" yaml:"expectedTreasury"` +} + +func (c SweepLinkFeesV150Cfg) ChainSelector() uint64 { + return c.ChainSel +} + +// SweepLinkFeesV150 returns a changeset that sweeps accumulated LINK fees to the configured NOPs. +func SweepLinkFeesV150(allowedRecipients map[uint64]common.Address) func(*changesets.MCMSReaderRegistry) cldf_deployment.ChangeSetV2[changesets.WithMCMS[SweepLinkFeesV150Cfg]] { + return changesets.NewFromOnChainSequence(changesets.NewFromOnChainSequenceParams[ + sequences.SweepLinkFeesV150Input, + evm.Chain, + SweepLinkFeesV150Cfg, + ]{ + Sequence: sequences.SweepLinkFeesV150Sequence, + ResolveInput: func(e cldf_deployment.Environment, cfg SweepLinkFeesV150Cfg) (sequences.SweepLinkFeesV150Input, error) { + return sequences.SweepLinkFeesV150Input{ + ChainSelector: cfg.ChainSel, + OnRampAddress: cfg.OnRampAddress, + ExpectedTreasury: cfg.ExpectedTreasury, + AllowedRecipients: allowedRecipients, + }, nil + }, + ResolveDep: evm_sequences.ResolveEVMChainDep[SweepLinkFeesV150Cfg], + }) +} + +// ============================================================================= +// Shared helpers +// ============================================================================= + +// resolveWETH9AndTimelock looks up WETH9 and RBACTimelock from the datastore. +// Returns (zero, zero, nil) if WETH9 is not registered. Returns an error only +// if WETH9 is found but the RBACTimelock cannot be resolved. +func resolveWETH9AndTimelock( + ds datastore.DataStore, + chainSel uint64, + timelockQualifier string, +) (weth9Addr, timelockAddr common.Address, err error) { + resolved, err := datastore_utils.FindAndFormatRef( + ds, + datastore.AddressRef{Type: datastore.ContractType("WETH9")}, + chainSel, + evm_datastore_utils.ToEVMAddress, + ) + if err != nil { + // WETH9 not registered — caller should treat all tokens as plain ERC20 + return common.Address{}, common.Address{}, nil + } + weth9Addr = resolved + + timelockAddr, err = datastore_utils.FindAndFormatRef( + ds, + datastore.AddressRef{ + Type: datastore.ContractType("RBACTimelock"), + Qualifier: timelockQualifier, + }, + chainSel, + evm_datastore_utils.ToEVMAddress, + ) + if err != nil { + return common.Address{}, common.Address{}, fmt.Errorf( + "RBACTimelock not found for chain %d (qualifier: %q); needed for WETH unwrap: %w", + chainSel, timelockQualifier, err) + } + return weth9Addr, timelockAddr, nil +} + +// resolveFeeQuoterNonLinkTokens resolves non-LINK fee token addresses from the +// on-chain FeeQuoter contract. Returns an error if no FeeQuoter is found in the +// datastore. Returns an empty slice if all fee tokens are LINK. +func resolveFeeQuoterNonLinkTokens( + ds datastore.DataStore, + chainSel uint64, + chain evm.Chain, + callOpts *bind.CallOpts, +) ([]common.Address, error) { + // Find FeeQuoter in datastore (picks latest version < 1.7.0) + refs := ds.Addresses().Filter( + datastore.AddressRefByType(datastore.ContractType(fee_quoter_ops.ContractType)), + datastore.AddressRefByChainSelector(chainSel), + ) + ref, err := v1_6_0_sequences.GetFeeQuoterAddress(refs, chainSel) + if err != nil { + return nil, fmt.Errorf("no FeeQuoter found for chain %d; ensure chain has v1.6+ contracts: %w", chainSel, err) + } + fqAddr := common.HexToAddress(ref.Address) + + // Create FeeQuoter binding and query on-chain state + fq, err := fee_quoter_binding.NewFeeQuoter(fqAddr, chain.Client) + if err != nil { + return nil, fmt.Errorf("failed to create FeeQuoter binding for %s: %w", fqAddr.Hex(), err) + } + + feeTokens, err := fq.GetFeeTokens(callOpts) + if err != nil { + return nil, fmt.Errorf("failed to get fee tokens from FeeQuoter %s: %w", fqAddr.Hex(), err) + } + + staticCfg, err := fq.GetStaticConfig(callOpts) + if err != nil { + return nil, fmt.Errorf("failed to get static config from FeeQuoter %s: %w", fqAddr.Hex(), err) + } + + // Filter out LINK, return remaining non-LINK tokens. + // Fail on unexpected data — don't silently fix bad on-chain state. + seen := make(map[common.Address]bool) + var nonLink []common.Address + for _, token := range feeTokens { + if token == (common.Address{}) { + return nil, fmt.Errorf("FeeQuoter %s returned zero address in fee tokens list", fqAddr.Hex()) + } + if seen[token] { + return nil, fmt.Errorf("FeeQuoter %s returned duplicate fee token %s", fqAddr.Hex(), token.Hex()) + } + seen[token] = true + if token != staticCfg.LinkToken { + nonLink = append(nonLink, token) + } + } + return nonLink, nil +} + +// ============================================================================= +// Non-LINK Sweep Changeset (with WETH auto-detection) +// ============================================================================= + +// SweepNonLinkFeesV150Cfg withdraws non-LINK fee tokens to treasury. +// If any fee token is WETH9 (detected from datastore), automatically does atomic unwrap. +type SweepNonLinkFeesV150Cfg struct { + ChainSel uint64 `json:"chainSelector" yaml:"chainSelector"` + OnRampAddress common.Address `json:"onRampAddress" yaml:"onRampAddress"` + FeeTokens []common.Address `json:"feeTokens" yaml:"feeTokens"` + Treasury common.Address `json:"treasury" yaml:"treasury"` + TimelockQualifier string `json:"timelockQualifier" yaml:"timelockQualifier"` // Required for WETH unwrap +} + +func (c SweepNonLinkFeesV150Cfg) ChainSelector() uint64 { + return c.ChainSel +} + +// SweepNonLinkFeesV150 returns a changeset that withdraws non-LINK fee tokens to treasury. +// Automatically detects WETH9 from datastore and does atomic unwrap if a fee token matches. +func SweepNonLinkFeesV150(allowedRecipients map[uint64]common.Address) func(*changesets.MCMSReaderRegistry) cldf_deployment.ChangeSetV2[changesets.WithMCMS[SweepNonLinkFeesV150Cfg]] { + return changesets.NewFromOnChainSequence(changesets.NewFromOnChainSequenceParams[ + sequences.SweepNonLinkFeesV150Input, + evm.Chain, + SweepNonLinkFeesV150Cfg, + ]{ + Sequence: sequences.SweepNonLinkFeesV150Sequence, + ResolveInput: func(e cldf_deployment.Environment, cfg SweepNonLinkFeesV150Cfg) (sequences.SweepNonLinkFeesV150Input, error) { + result := sequences.SweepNonLinkFeesV150Input{ + ChainSelector: cfg.ChainSel, + OnRampAddress: cfg.OnRampAddress, + FeeTokens: cfg.FeeTokens, + Treasury: cfg.Treasury, + AllowedRecipients: allowedRecipients, + } + + // Resolve WETH9 + RBACTimelock (graceful: zero addresses if WETH9 not registered) + weth9Addr, timelockAddr, err := resolveWETH9AndTimelock(e.DataStore, cfg.ChainSel, cfg.TimelockQualifier) + if err != nil { + return sequences.SweepNonLinkFeesV150Input{}, err + } + result.WETH9Address = weth9Addr + result.MCMSAddress = timelockAddr + + // If WETH9 is not among fee tokens, no unwrap needed — skip balance query + hasWETH := false + for _, token := range cfg.FeeTokens { + if token == weth9Addr { + hasWETH = true + break + } + } + if !hasWETH || weth9Addr == (common.Address{}) { + return result, nil + } + + // Query WETH balance of OnRamp + chain, ok := e.BlockChains.EVMChains()[cfg.ChainSel] + if !ok { + return sequences.SweepNonLinkFeesV150Input{}, fmt.Errorf( + "chain %d not found in environment", cfg.ChainSel) + } + wethContract, err := weth9.NewWETH9(weth9Addr, chain.Client) + if err != nil { + return sequences.SweepNonLinkFeesV150Input{}, fmt.Errorf( + "failed to create WETH9 binding: %w", err) + } + // NOTE: WETH balance is queried at resolution time. If the balance changes + // between resolution and MCMS proposal execution (e.g., more fees accumulate + // or a concurrent proposal withdraws), the unwrap step may fail because it + // uses this exact amount. The batch reverts atomically — no fund loss. + // To fix: re-resolve and re-submit the proposal. + balance, err := wethContract.BalanceOf(&bind.CallOpts{Context: e.GetContext()}, cfg.OnRampAddress) + if err != nil { + return sequences.SweepNonLinkFeesV150Input{}, fmt.Errorf( + "failed to query WETH balance for OnRamp %s: %w", cfg.OnRampAddress.Hex(), err) + } + result.WETHBalance = balance + + return result, nil + }, + ResolveDep: evm_sequences.ResolveEVMChainDep[SweepNonLinkFeesV150Cfg], + }) +} + +// ============================================================================= +// Chain-Wide Mega Flow Changeset +// ============================================================================= + +// SweepAllOnRampsV150Cfg is the user config for the mega flow: configure NOPs, +// sweep LINK, sweep non-LINK (with WETH auto-unwrap) for ALL OnRamps on a chain. +type SweepAllOnRampsV150Cfg struct { + ChainSel uint64 `json:"chainSelector" yaml:"chainSelector"` + Treasury common.Address `json:"treasury" yaml:"treasury"` + TimelockQualifier string `json:"timelockQualifier" yaml:"timelockQualifier"` + MinSweepAmount string `json:"minSweepAmount" yaml:"minSweepAmount"` // Wei as string, "0" = sweep all + // SkipNopsCheck skips the on-chain NOP configuration pre-check. + // Default false. Set true for dry-run/testing when NOPs haven't been + // configured on-chain yet. The SetNops tx is still included in the + // batch regardless — this only skips the fail-fast verification. + SkipNopsCheck bool `json:"skipNopsCheck,omitempty" yaml:"skipNopsCheck,omitempty"` +} + +func (c SweepAllOnRampsV150Cfg) ChainSelector() uint64 { + return c.ChainSel +} + +// SweepAllOnRampsV150 creates the mega flow changeset. +// Non-LINK fee tokens are resolved dynamically from the on-chain FeeQuoter contract. +// WETH9 is resolved from datastore. +func SweepAllOnRampsV150( + allowedRecipients map[uint64]common.Address, +) func(*changesets.MCMSReaderRegistry) cldf_deployment.ChangeSetV2[changesets.WithMCMS[SweepAllOnRampsV150Cfg]] { + return changesets.NewFromOnChainSequence(changesets.NewFromOnChainSequenceParams[ + sequences.SweepAllOnRampsV150Input, + evm.Chain, + SweepAllOnRampsV150Cfg, + ]{ + Sequence: sequences.SweepAllOnRampsV150Sequence, + ResolveInput: func(e cldf_deployment.Environment, cfg SweepAllOnRampsV150Cfg) (sequences.SweepAllOnRampsV150Input, error) { + // Parse MinSweepAmount + minSweepAmount := big.NewInt(0) + if cfg.MinSweepAmount != "" { + var ok bool + minSweepAmount, ok = new(big.Int).SetString(cfg.MinSweepAmount, 10) + if !ok { + return sequences.SweepAllOnRampsV150Input{}, fmt.Errorf("invalid minSweepAmount: %s", cfg.MinSweepAmount) + } + } + + // Get EVM chain for RPC calls + chain, ok := e.BlockChains.EVMChains()[cfg.ChainSel] + if !ok { + return sequences.SweepAllOnRampsV150Input{}, fmt.Errorf( + "chain %d not found in environment", cfg.ChainSel) + } + + // Resolve non-LINK fee tokens from on-chain FeeQuoter + tokens, err := resolveFeeQuoterNonLinkTokens( + e.DataStore, cfg.ChainSel, chain, + &bind.CallOpts{Context: e.GetContext()}, + ) + if err != nil { + return sequences.SweepAllOnRampsV150Input{}, err + } + + // Resolve WETH9 + RBACTimelock (graceful: zero addresses if WETH9 not registered) + weth9Addr, timelockAddr, err := resolveWETH9AndTimelock(e.DataStore, cfg.ChainSel, cfg.TimelockQualifier) + if err != nil { + return sequences.SweepAllOnRampsV150Input{}, err + } + + // Discover OnRamps from datastore + onRampRefs := e.DataStore.Addresses().Filter( + datastore.AddressRefByType(datastore.ContractType(onramp.ContractType)), + datastore.AddressRefByVersion(onramp.Version), + datastore.AddressRefByChainSelector(cfg.ChainSel), + ) + if len(onRampRefs) == 0 { + return sequences.SweepAllOnRampsV150Input{}, fmt.Errorf( + "no v1.5.0 OnRamps found for chain %d", cfg.ChainSel) + } + + // Convert refs to addresses + onRamps := make([]common.Address, 0, len(onRampRefs)) + for _, ref := range onRampRefs { + if !common.IsHexAddress(ref.Address) { + return sequences.SweepAllOnRampsV150Input{}, fmt.Errorf( + "invalid OnRamp address in datastore: %s", ref.Address) + } + onRamps = append(onRamps, common.HexToAddress(ref.Address)) + } + + // Query balances for all OnRamps. + // NOTE: All balances (LINK fees and token balances including WETH) are + // queried at resolution time. If balances change between resolution and + // MCMS proposal execution, the WETH unwrap step may fail because it uses + // exact amounts. The batch reverts atomically — no fund loss. + // To fix: re-resolve and re-submit the proposal. + onRampTokenBalances := make(map[common.Address]map[common.Address]*big.Int) + onRampLINKFees := make(map[common.Address]*big.Int) + + for _, onRampAddr := range onRamps { + // Query LINK fees + onRampContract, err := evm_2_evm_onramp.NewEVM2EVMOnRamp(onRampAddr, chain.Client) + if err != nil { + return sequences.SweepAllOnRampsV150Input{}, fmt.Errorf( + "failed to create OnRamp binding for %s: %w", onRampAddr.Hex(), err) + } + linkFees, err := onRampContract.GetNopFeesJuels(&bind.CallOpts{Context: e.GetContext()}) + if err != nil { + return sequences.SweepAllOnRampsV150Input{}, fmt.Errorf( + "failed to query LINK fees for OnRamp %s: %w", onRampAddr.Hex(), err) + } + onRampLINKFees[onRampAddr] = linkFees + + // Query non-LINK token balances. We reuse the WETH9 binding as a generic + // ERC20 wrapper since it has balanceOf — any ERC20-compatible binding works. + tokenBalances := make(map[common.Address]*big.Int) + for _, token := range tokens { + tokenContract, err := weth9.NewWETH9(token, chain.Client) + if err != nil { + return sequences.SweepAllOnRampsV150Input{}, fmt.Errorf( + "failed to create ERC20 binding for token %s: %w", token.Hex(), err) + } + balance, err := tokenContract.BalanceOf(&bind.CallOpts{Context: e.GetContext()}, onRampAddr) + if err != nil { + return sequences.SweepAllOnRampsV150Input{}, fmt.Errorf( + "failed to query balance of token %s for OnRamp %s: %w", + token.Hex(), onRampAddr.Hex(), err) + } + tokenBalances[token] = balance + } + onRampTokenBalances[onRampAddr] = tokenBalances + } + + return sequences.SweepAllOnRampsV150Input{ + ChainSelector: cfg.ChainSel, + Treasury: cfg.Treasury, + OnRamps: onRamps, + NonLinkFeeTokens: tokens, + WETH9Address: weth9Addr, + MCMSAddress: timelockAddr, + OnRampTokenBalances: onRampTokenBalances, + OnRampLINKFees: onRampLINKFees, + MinSweepAmount: minSweepAmount, + AllowedRecipients: allowedRecipients, + SkipNopsCheck: cfg.SkipNopsCheck, + }, nil + }, + ResolveDep: evm_sequences.ResolveEVMChainDep[SweepAllOnRampsV150Cfg], + }) +} diff --git a/chains/evm/deployment/v1_5_0/changesets/onramp_fees_test.go b/chains/evm/deployment/v1_5_0/changesets/onramp_fees_test.go new file mode 100644 index 0000000000..add7becaa8 --- /dev/null +++ b/chains/evm/deployment/v1_5_0/changesets/onramp_fees_test.go @@ -0,0 +1,388 @@ +package changesets_test + +import ( + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/ethereum/go-ethereum/common" + chain_selectors "github.com/smartcontractkit/chain-selectors" + "github.com/stretchr/testify/require" + + onramp_changesets "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_5_0/changesets" + utils_changesets "github.com/smartcontractkit/chainlink-ccip/deployment/utils/changesets" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils/mcms" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" + mcms_types "github.com/smartcontractkit/mcms/types" +) + +// MockReader implements mcms.Reader for test purposes +type MockReader struct{} + +func (m *MockReader) GetChainMetadata(_ deployment.Environment, _ uint64, input mcms.Input) (mcms_types.ChainMetadata, error) { + return mcms_types.ChainMetadata{StartingOpCount: 1}, nil +} + +func (m *MockReader) GetTimelockRef(_ deployment.Environment, selector uint64, _ mcms.Input) (datastore.AddressRef, error) { + return datastore.AddressRef{ + ChainSelector: selector, + Address: "0x0000000000000000000000000000000000000001", + Type: datastore.ContractType("Timelock"), + Version: semver.MustParse("1.0.0"), + }, nil +} + +func (m *MockReader) GetMCMSRef(_ deployment.Environment, selector uint64, _ mcms.Input) (datastore.AddressRef, error) { + return datastore.AddressRef{ + ChainSelector: selector, + Address: "0x0000000000000000000000000000000000000002", + Type: datastore.ContractType("MCM"), + Version: semver.MustParse("1.0.0"), + }, nil +} + +func init() { + registry := utils_changesets.GetRegistry() + registry.RegisterMCMSReader("evm", &MockReader{}) +} + +// ============================================================================= +// ConfigureFeeSweep Tests +// ============================================================================= + +func TestConfigureFeeSweep_Validation(t *testing.T) { + t.Parallel() + + chainSel := chain_selectors.ETHEREUM_MAINNET.Selector + e, err := environment.New(t.Context(), + environment.WithEVMSimulated(t, []uint64{chainSel}), + ) + require.NoError(t, err) + + ds := datastore.NewMemoryDataStore() + err = ds.Addresses().Add(datastore.AddressRef{ + ChainSelector: chainSel, Type: datastore.ContractType("Timelock"), + Version: semver.MustParse("1.0.0"), Address: "0x0000000000000000000000000000000000000001", + }) + require.NoError(t, err) + err = ds.Addresses().Add(datastore.AddressRef{ + ChainSelector: chainSel, Type: datastore.ContractType("MCM"), + Version: semver.MustParse("1.0.0"), Address: "0x0000000000000000000000000000000000000002", + }) + require.NoError(t, err) + e.DataStore = ds.Seal() + + t.Run("missing chain in allowed recipients", func(t *testing.T) { + allowedRecipients := map[uint64]common.Address{ + 12345: common.HexToAddress("0x1111111111111111111111111111111111111111"), + } + + registry := utils_changesets.GetRegistry() + cs := onramp_changesets.ConfigureFeeSweepV150(allowedRecipients)(registry) + + input := utils_changesets.WithMCMS[onramp_changesets.ConfigureFeeSweepV150Cfg]{ + Cfg: onramp_changesets.ConfigureFeeSweepV150Cfg{ + ChainSel: chainSel, + OnRampAddress: common.HexToAddress("0x3333333333333333333333333333333333333333"), + Treasury: common.HexToAddress("0x1111111111111111111111111111111111111111"), + }, + MCMS: mcms.Input{ + OverridePreviousRoot: true, ValidUntil: 4126214326, + TimelockDelay: mcms_types.MustParseDuration("1h"), + TimelockAction: mcms_types.TimelockActionSchedule, Description: "Test", + }, + } + + err := cs.VerifyPreconditions(*e, input) + require.NoError(t, err) + + _, err = cs.Apply(*e, input) + require.Error(t, err) + require.Contains(t, err.Error(), "not in the allowed recipients list") + }) + + t.Run("zero treasury address", func(t *testing.T) { + allowedRecipients := map[uint64]common.Address{ + chainSel: common.HexToAddress("0x1111111111111111111111111111111111111111"), + } + + registry := utils_changesets.GetRegistry() + cs := onramp_changesets.ConfigureFeeSweepV150(allowedRecipients)(registry) + + input := utils_changesets.WithMCMS[onramp_changesets.ConfigureFeeSweepV150Cfg]{ + Cfg: onramp_changesets.ConfigureFeeSweepV150Cfg{ + ChainSel: chainSel, + OnRampAddress: common.HexToAddress("0x3333333333333333333333333333333333333333"), + Treasury: common.Address{}, + }, + MCMS: mcms.Input{ + OverridePreviousRoot: true, ValidUntil: 4126214326, + TimelockDelay: mcms_types.MustParseDuration("1h"), + TimelockAction: mcms_types.TimelockActionSchedule, Description: "Test", + }, + } + + err := cs.VerifyPreconditions(*e, input) + require.NoError(t, err) + + _, err = cs.Apply(*e, input) + require.Error(t, err) + require.Contains(t, err.Error(), "zero address") + }) +} + +// ============================================================================= +// SweepLinkFees Tests +// ============================================================================= + +func TestSweepLinkFees_Validation(t *testing.T) { + t.Parallel() + + chainSel := chain_selectors.ETHEREUM_MAINNET.Selector + e, err := environment.New(t.Context(), + environment.WithEVMSimulated(t, []uint64{chainSel}), + ) + require.NoError(t, err) + + ds := datastore.NewMemoryDataStore() + err = ds.Addresses().Add(datastore.AddressRef{ + ChainSelector: chainSel, Type: datastore.ContractType("Timelock"), + Version: semver.MustParse("1.0.0"), Address: "0x0000000000000000000000000000000000000001", + }) + require.NoError(t, err) + err = ds.Addresses().Add(datastore.AddressRef{ + ChainSelector: chainSel, Type: datastore.ContractType("MCM"), + Version: semver.MustParse("1.0.0"), Address: "0x0000000000000000000000000000000000000002", + }) + require.NoError(t, err) + e.DataStore = ds.Seal() + + t.Run("treasury mismatch with allowed list", func(t *testing.T) { + allowedRecipients := map[uint64]common.Address{ + chainSel: common.HexToAddress("0x1111111111111111111111111111111111111111"), + } + + registry := utils_changesets.GetRegistry() + cs := onramp_changesets.SweepLinkFeesV150(allowedRecipients)(registry) + + input := utils_changesets.WithMCMS[onramp_changesets.SweepLinkFeesV150Cfg]{ + Cfg: onramp_changesets.SweepLinkFeesV150Cfg{ + ChainSel: chainSel, + OnRampAddress: common.HexToAddress("0x3333333333333333333333333333333333333333"), + ExpectedTreasury: common.HexToAddress("0x5555555555555555555555555555555555555555"), + }, + MCMS: mcms.Input{ + OverridePreviousRoot: true, ValidUntil: 4126214326, + TimelockDelay: mcms_types.MustParseDuration("1h"), + TimelockAction: mcms_types.TimelockActionSchedule, Description: "Test", + }, + } + + err := cs.VerifyPreconditions(*e, input) + require.NoError(t, err) + + _, err = cs.Apply(*e, input) + require.Error(t, err) + require.Contains(t, err.Error(), "not the approved treasury") + }) +} + +// ============================================================================= +// SweepNonLinkFees Tests +// ============================================================================= + +func TestSweepNonLinkFees_Validation(t *testing.T) { + t.Parallel() + + chainSel := chain_selectors.ETHEREUM_MAINNET.Selector + e, err := environment.New(t.Context(), + environment.WithEVMSimulated(t, []uint64{chainSel}), + ) + require.NoError(t, err) + + ds := datastore.NewMemoryDataStore() + err = ds.Addresses().Add(datastore.AddressRef{ + ChainSelector: chainSel, Type: datastore.ContractType("Timelock"), + Version: semver.MustParse("1.0.0"), Address: "0x0000000000000000000000000000000000000001", + }) + require.NoError(t, err) + err = ds.Addresses().Add(datastore.AddressRef{ + ChainSelector: chainSel, Type: datastore.ContractType("MCM"), + Version: semver.MustParse("1.0.0"), Address: "0x0000000000000000000000000000000000000002", + }) + require.NoError(t, err) + e.DataStore = ds.Seal() + + t.Run("empty fee tokens list is valid", func(t *testing.T) { + allowedRecipients := map[uint64]common.Address{ + chainSel: common.HexToAddress("0x1111111111111111111111111111111111111111"), + } + + registry := utils_changesets.GetRegistry() + cs := onramp_changesets.SweepNonLinkFeesV150(allowedRecipients)(registry) + + input := utils_changesets.WithMCMS[onramp_changesets.SweepNonLinkFeesV150Cfg]{ + Cfg: onramp_changesets.SweepNonLinkFeesV150Cfg{ + ChainSel: chainSel, + OnRampAddress: common.HexToAddress("0x3333333333333333333333333333333333333333"), + FeeTokens: []common.Address{}, + Treasury: allowedRecipients[chainSel], + }, + MCMS: mcms.Input{ + OverridePreviousRoot: true, ValidUntil: 4126214326, + TimelockDelay: mcms_types.MustParseDuration("1h"), + TimelockAction: mcms_types.TimelockActionSchedule, Description: "Test", + }, + } + + err := cs.VerifyPreconditions(*e, input) + require.NoError(t, err) + }) + + t.Run("treasury validation failure", func(t *testing.T) { + allowedRecipients := map[uint64]common.Address{ + chainSel: common.HexToAddress("0x1111111111111111111111111111111111111111"), + } + + registry := utils_changesets.GetRegistry() + cs := onramp_changesets.SweepNonLinkFeesV150(allowedRecipients)(registry) + + input := utils_changesets.WithMCMS[onramp_changesets.SweepNonLinkFeesV150Cfg]{ + Cfg: onramp_changesets.SweepNonLinkFeesV150Cfg{ + ChainSel: chainSel, + OnRampAddress: common.HexToAddress("0x3333333333333333333333333333333333333333"), + FeeTokens: []common.Address{common.HexToAddress("0xAAAA")}, + Treasury: common.HexToAddress("0x9999999999999999999999999999999999999999"), + }, + MCMS: mcms.Input{ + OverridePreviousRoot: true, ValidUntil: 4126214326, + TimelockDelay: mcms_types.MustParseDuration("1h"), + TimelockAction: mcms_types.TimelockActionSchedule, Description: "Test", + }, + } + + err := cs.VerifyPreconditions(*e, input) + require.NoError(t, err) + + _, err = cs.Apply(*e, input) + require.Error(t, err) + require.Contains(t, err.Error(), "not the approved treasury") + }) +} + +// ============================================================================= +// SweepAllOnRampsV150 (Mega Flow) Tests +// ============================================================================= + +func TestSweepAllOnRampsV150_NoFeeQuoterInDatastore(t *testing.T) { + t.Parallel() + + chainSel := chain_selectors.ETHEREUM_MAINNET.Selector + e, err := environment.New(t.Context(), + environment.WithEVMSimulated(t, []uint64{chainSel}), + ) + require.NoError(t, err) + + ds := datastore.NewMemoryDataStore() + err = ds.Addresses().Add(datastore.AddressRef{ + ChainSelector: chainSel, Type: datastore.ContractType("WETH9"), + Version: semver.MustParse("1.0.0"), Address: "0x4200000000000000000000000000000000000006", + }) + require.NoError(t, err) + err = ds.Addresses().Add(datastore.AddressRef{ + ChainSelector: chainSel, Type: datastore.ContractType("RBACTimelock"), + Version: semver.MustParse("1.0.0"), Address: "0x0000000000000000000000000000000000000099", + }) + require.NoError(t, err) + err = ds.Addresses().Add(datastore.AddressRef{ + ChainSelector: chainSel, Type: datastore.ContractType("Timelock"), + Version: semver.MustParse("1.0.0"), Address: "0x0000000000000000000000000000000000000001", + }) + require.NoError(t, err) + err = ds.Addresses().Add(datastore.AddressRef{ + ChainSelector: chainSel, Type: datastore.ContractType("MCM"), + Version: semver.MustParse("1.0.0"), Address: "0x0000000000000000000000000000000000000002", + }) + require.NoError(t, err) + e.DataStore = ds.Seal() + + allowedRecipients := map[uint64]common.Address{ + chainSel: common.HexToAddress("0x1111111111111111111111111111111111111111"), + } + + registry := utils_changesets.GetRegistry() + cs := onramp_changesets.SweepAllOnRampsV150(allowedRecipients)(registry) + + input := utils_changesets.WithMCMS[onramp_changesets.SweepAllOnRampsV150Cfg]{ + Cfg: onramp_changesets.SweepAllOnRampsV150Cfg{ + ChainSel: chainSel, + Treasury: allowedRecipients[chainSel], + }, + MCMS: mcms.Input{ + OverridePreviousRoot: true, ValidUntil: 4126214326, + TimelockDelay: mcms_types.MustParseDuration("1h"), + TimelockAction: mcms_types.TimelockActionSchedule, Description: "Test", + }, + } + + // FeeQuoter resolution now happens before OnRamp discovery, so with no + // FeeQuoter in the datastore the changeset fails at that point. + err = cs.VerifyPreconditions(*e, input) + require.Error(t, err) + require.Contains(t, err.Error(), "no FeeQuoter found") +} + +// ============================================================================= +// Chain Selector Validation Tests +// ============================================================================= + +func TestChangesets_NonexistentChain(t *testing.T) { + t.Parallel() + + validChainSel := chain_selectors.ETHEREUM_MAINNET.Selector + e, err := environment.New(t.Context(), + environment.WithEVMSimulated(t, []uint64{validChainSel}), + ) + require.NoError(t, err) + + ds := datastore.NewMemoryDataStore() + err = ds.Addresses().Add(datastore.AddressRef{ + ChainSelector: validChainSel, Type: datastore.ContractType("Timelock"), + Version: semver.MustParse("1.0.0"), Address: "0x0000000000000000000000000000000000000001", + }) + require.NoError(t, err) + err = ds.Addresses().Add(datastore.AddressRef{ + ChainSelector: validChainSel, Type: datastore.ContractType("MCM"), + Version: semver.MustParse("1.0.0"), Address: "0x0000000000000000000000000000000000000002", + }) + require.NoError(t, err) + e.DataStore = ds.Seal() + + nonexistentChainSel := uint64(999999) + + t.Run("ConfigureFeeSweep with nonexistent chain", func(t *testing.T) { + allowedRecipients := map[uint64]common.Address{ + nonexistentChainSel: common.HexToAddress("0x1111111111111111111111111111111111111111"), + } + + registry := utils_changesets.GetRegistry() + cs := onramp_changesets.ConfigureFeeSweepV150(allowedRecipients)(registry) + + input := utils_changesets.WithMCMS[onramp_changesets.ConfigureFeeSweepV150Cfg]{ + Cfg: onramp_changesets.ConfigureFeeSweepV150Cfg{ + ChainSel: nonexistentChainSel, + OnRampAddress: common.HexToAddress("0x3333333333333333333333333333333333333333"), + Treasury: allowedRecipients[nonexistentChainSel], + }, + MCMS: mcms.Input{ + OverridePreviousRoot: true, ValidUntil: 4126214326, + TimelockDelay: mcms_types.MustParseDuration("1h"), + TimelockAction: mcms_types.TimelockActionSchedule, Description: "Test", + }, + } + + err := cs.VerifyPreconditions(*e, input) + require.Error(t, err) + }) +} diff --git a/chains/evm/deployment/v1_5_0/operations/onramp/onramp.go b/chains/evm/deployment/v1_5_0/operations/onramp/onramp.go index 9aaf76a23e..d29a19c974 100644 --- a/chains/evm/deployment/v1_5_0/operations/onramp/onramp.go +++ b/chains/evm/deployment/v1_5_0/operations/onramp/onramp.go @@ -1,6 +1,9 @@ package onramp import ( + "fmt" + "math/big" + "github.com/Masterminds/semver/v3" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" @@ -92,6 +95,116 @@ var OnRampDynamicConfig = contract.NewRead(contract.ReadParams[any, evm_2_evm_on }, }) +// ============================================================================= +// Fee Withdrawal Operations +// ============================================================================= + +// WithdrawNonLinkFeesInput specifies which non-LINK token to withdraw and where to send it +type WithdrawNonLinkFeesInput struct { + FeeToken common.Address + To common.Address +} + +// OnRampWithdrawNonLinkFees withdraws accumulated non-LINK fee tokens to a specified address. +var OnRampWithdrawNonLinkFees = contract.NewWrite(contract.WriteParams[WithdrawNonLinkFeesInput, *evm_2_evm_onramp.EVM2EVMOnRamp]{ + Name: "onramp:withdraw-non-link-fees", + Version: Version, + Description: "Withdraws non-LINK fee tokens from the OnRamp 1.5.0 contract to a specified address", + ContractType: ContractType, + ContractABI: evm_2_evm_onramp.EVM2EVMOnRampABI, + NewContract: evm_2_evm_onramp.NewEVM2EVMOnRamp, + IsAllowedCaller: contract.OnlyOwner[*evm_2_evm_onramp.EVM2EVMOnRamp, WithdrawNonLinkFeesInput], + Validate: func(args WithdrawNonLinkFeesInput) error { return nil }, + CallContract: func(onRamp *evm_2_evm_onramp.EVM2EVMOnRamp, opts *bind.TransactOpts, args WithdrawNonLinkFeesInput) (*types.Transaction, error) { + return onRamp.WithdrawNonLinkFees(opts, args.FeeToken, args.To) + }, +}) + +// NopAndWeight is an alias for the gethwrapper type to simplify consumer imports +type NopAndWeight = evm_2_evm_onramp.EVM2EVMOnRampNopAndWeight + +// SetNopsInput specifies the NOPs and their payment weights +type SetNopsInput struct { + NopsAndWeights []NopAndWeight +} + +// OnRampSetNops sets the NOPs and their payment weights for LINK fee distribution. +var OnRampSetNops = contract.NewWrite(contract.WriteParams[SetNopsInput, *evm_2_evm_onramp.EVM2EVMOnRamp]{ + Name: "onramp:set-nops", + Version: Version, + Description: "Sets the NOPs and their payment weights on the OnRamp 1.5.0 contract", + ContractType: ContractType, + ContractABI: evm_2_evm_onramp.EVM2EVMOnRampABI, + NewContract: evm_2_evm_onramp.NewEVM2EVMOnRamp, + IsAllowedCaller: contract.OnlyOwner[*evm_2_evm_onramp.EVM2EVMOnRamp, SetNopsInput], + Validate: func(args SetNopsInput) error { + if len(args.NopsAndWeights) == 0 { + return fmt.Errorf("NopsAndWeights list cannot be empty") + } + return nil + }, + CallContract: func(onRamp *evm_2_evm_onramp.EVM2EVMOnRamp, opts *bind.TransactOpts, args SetNopsInput) (*types.Transaction, error) { + return onRamp.SetNops(opts, args.NopsAndWeights) + }, +}) + +// OnRampPayNops triggers payout of accumulated LINK fees to NOPs based on their configured weights +var OnRampPayNops = contract.NewWrite(contract.WriteParams[any, *evm_2_evm_onramp.EVM2EVMOnRamp]{ + Name: "onramp:pay-nops", + Version: Version, + Description: "Pays out accumulated LINK fees to NOPs based on their weights on the OnRamp 1.5.0 contract", + ContractType: ContractType, + ContractABI: evm_2_evm_onramp.EVM2EVMOnRampABI, + NewContract: evm_2_evm_onramp.NewEVM2EVMOnRamp, + IsAllowedCaller: contract.OnlyOwner[*evm_2_evm_onramp.EVM2EVMOnRamp, any], + Validate: func(args any) error { return nil }, + CallContract: func(onRamp *evm_2_evm_onramp.EVM2EVMOnRamp, opts *bind.TransactOpts, args any) (*types.Transaction, error) { + return onRamp.PayNops(opts) + }, +}) + +// ============================================================================= +// Fee Withdrawal Read Operations (for validation) +// ============================================================================= + +// GetNopsResult contains the current NOP configuration +type GetNopsResult struct { + NopsAndWeights []NopAndWeight + WeightsTotal *big.Int +} + +// OnRampGetNops reads the current NOP configuration from the OnRamp +// Use this to verify NOPs are correctly configured before calling PayNops +var OnRampGetNops = contract.NewRead(contract.ReadParams[any, GetNopsResult, *evm_2_evm_onramp.EVM2EVMOnRamp]{ + Name: "onramp:get-nops", + Version: Version, + Description: "Reads the current NOP configuration from the OnRamp 1.5.0 contract", + ContractType: ContractType, + NewContract: evm_2_evm_onramp.NewEVM2EVMOnRamp, + CallContract: func(onRamp *evm_2_evm_onramp.EVM2EVMOnRamp, opts *bind.CallOpts, args any) (GetNopsResult, error) { + result, err := onRamp.GetNops(opts) + if err != nil { + return GetNopsResult{}, err + } + return GetNopsResult{ + NopsAndWeights: result.NopsAndWeights, + WeightsTotal: result.WeightsTotal, + }, nil + }, +}) + +// OnRampGetNopFeesJuels reads the current accumulated LINK fees pending for NOP payout +var OnRampGetNopFeesJuels = contract.NewRead(contract.ReadParams[any, *big.Int, *evm_2_evm_onramp.EVM2EVMOnRamp]{ + Name: "onramp:get-nop-fees-juels", + Version: Version, + Description: "Reads the accumulated LINK fees (in juels) pending for NOP payout", + ContractType: ContractType, + NewContract: evm_2_evm_onramp.NewEVM2EVMOnRamp, + CallContract: func(onRamp *evm_2_evm_onramp.EVM2EVMOnRamp, opts *bind.CallOpts, args any) (*big.Int, error) { + return onRamp.GetNopFeesJuels(opts) + }, +}) + var OnRampFeeTokenConfig = contract.NewRead(contract.ReadParams[common.Address, evm_2_evm_onramp.EVM2EVMOnRampFeeTokenConfig, *evm_2_evm_onramp.EVM2EVMOnRamp]{ Name: "onramp:fee-token-config", Version: Version, diff --git a/chains/evm/deployment/v1_5_0/operations/onramp/onramp_test.go b/chains/evm/deployment/v1_5_0/operations/onramp/onramp_test.go new file mode 100644 index 0000000000..3a95451014 --- /dev/null +++ b/chains/evm/deployment/v1_5_0/operations/onramp/onramp_test.go @@ -0,0 +1,703 @@ +package onramp + +import ( + "context" + "fmt" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/operations/contract" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v1_5_0/evm_2_evm_onramp" +) + +// ============================================================================= +// Test Constants +// ============================================================================= + +var ( + validChainSel = uint64(5009297550715157269) + invalidChainSel = uint64(12345) + testAddress = common.HexToAddress("0x01") + ownerAddress = common.HexToAddress("0x02") + nopAddress1 = common.HexToAddress("0x03") + nopAddress2 = common.HexToAddress("0x04") + feeTokenAddress = common.HexToAddress("0x05") + withdrawToAddr = common.HexToAddress("0x06") +) + +// ============================================================================= +// Operation Metadata Tests +// ============================================================================= + +func TestOnRampOperationsMetadata(t *testing.T) { + tests := []struct { + name string + operationID string + operationVer string + expectedID string + expectedVersion string + }{ + { + name: "SetTokenTransferFeeConfig metadata", + operationID: OnRampSetTokenTransferFeeConfig.ID(), + operationVer: OnRampSetTokenTransferFeeConfig.Version(), + expectedID: "onramp:set-token-transfer-fee-config", + expectedVersion: "1.5.0", + }, + { + name: "GetTokenTransferFeeConfig metadata", + operationID: OnRampGetTokenTransferFeeConfig.ID(), + operationVer: OnRampGetTokenTransferFeeConfig.Version(), + expectedID: "onramp:get-token-transfer-fee-config", + expectedVersion: "1.5.0", + }, + { + name: "StaticConfig metadata", + operationID: OnRampStaticConfig.ID(), + operationVer: OnRampStaticConfig.Version(), + expectedID: "onramp:static-config", + expectedVersion: "1.5.0", + }, + { + name: "DynamicConfig metadata", + operationID: OnRampDynamicConfig.ID(), + operationVer: OnRampDynamicConfig.Version(), + expectedID: "onramp:dynamic-config", + expectedVersion: "1.5.0", + }, + { + name: "WithdrawNonLinkFees metadata", + operationID: OnRampWithdrawNonLinkFees.ID(), + operationVer: OnRampWithdrawNonLinkFees.Version(), + expectedID: "onramp:withdraw-non-link-fees", + expectedVersion: "1.5.0", + }, + { + name: "SetNops metadata", + operationID: OnRampSetNops.ID(), + operationVer: OnRampSetNops.Version(), + expectedID: "onramp:set-nops", + expectedVersion: "1.5.0", + }, + { + name: "PayNops metadata", + operationID: OnRampPayNops.ID(), + operationVer: OnRampPayNops.Version(), + expectedID: "onramp:pay-nops", + expectedVersion: "1.5.0", + }, + { + name: "GetNops metadata", + operationID: OnRampGetNops.ID(), + operationVer: OnRampGetNops.Version(), + expectedID: "onramp:get-nops", + expectedVersion: "1.5.0", + }, + { + name: "GetNopFeesJuels metadata", + operationID: OnRampGetNopFeesJuels.ID(), + operationVer: OnRampGetNopFeesJuels.Version(), + expectedID: "onramp:get-nop-fees-juels", + expectedVersion: "1.5.0", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.expectedID, test.operationID) + assert.Equal(t, test.expectedVersion, test.operationVer) + }) + } +} + +func TestContractTypeAndVersion(t *testing.T) { + assert.Equal(t, "EVM2EVMOnRamp", string(ContractType)) + assert.Equal(t, "1.5.0", Version.String()) +} + +// ============================================================================= +// SetNops Tests +// ============================================================================= + +func TestOnRampSetNops(t *testing.T) { + tests := []struct { + desc string + input SetNopsInput + expectedErr string + }{ + { + desc: "single NOP with 100% weight", + input: SetNopsInput{ + NopsAndWeights: []NopAndWeight{ + {Nop: nopAddress1, Weight: 10000}, + }, + }, + }, + { + desc: "multiple NOPs with split weights", + input: SetNopsInput{ + NopsAndWeights: []NopAndWeight{ + {Nop: nopAddress1, Weight: 5000}, + {Nop: nopAddress2, Weight: 5000}, + }, + }, + }, + { + desc: "treasury as sole NOP", + input: SetNopsInput{ + NopsAndWeights: []NopAndWeight{ + {Nop: nopAddress1, Weight: 65535}, + }, + }, + }, + { + desc: "unequal weight distribution", + input: SetNopsInput{ + NopsAndWeights: []NopAndWeight{ + {Nop: nopAddress1, Weight: 7000}, + {Nop: nopAddress2, Weight: 3000}, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + // Verify the input types are properly structured + assert.NotNil(t, test.input.NopsAndWeights) + + // Validate each NOP has an address + for i, nop := range test.input.NopsAndWeights { + if len(test.input.NopsAndWeights) > 0 { + assert.NotEqual(t, common.Address{}, nop.Nop, "NOP %d should have a valid address", i) + } + } + }) + } +} + +func TestOnRampSetNopsInputValidation(t *testing.T) { + lggr, err := logger.New() + require.NoError(t, err) + + bundle := operations.NewBundle( + func() context.Context { return context.Background() }, + lggr, + operations.NewMemoryReporter(), + ) + + chain := evm.Chain{ + Selector: validChainSel, + } + + t.Run("empty NopsAndWeights rejected", func(t *testing.T) { + input := contract.FunctionInput[SetNopsInput]{ + ChainSelector: validChainSel, + Address: testAddress, + Args: SetNopsInput{ + NopsAndWeights: []NopAndWeight{}, + }, + } + + _, err := operations.ExecuteOperation(bundle, OnRampSetNops, chain, input) + require.Error(t, err) + assert.Contains(t, err.Error(), "NopsAndWeights list cannot be empty") + }) +} + +// ============================================================================= +// GetNops Tests +// ============================================================================= + +func TestGetNopsResultStructure(t *testing.T) { + // Test the result structure + result := GetNopsResult{ + NopsAndWeights: []NopAndWeight{ + {Nop: nopAddress1, Weight: 5000}, + {Nop: nopAddress2, Weight: 5000}, + }, + WeightsTotal: big.NewInt(10000), + } + + assert.Len(t, result.NopsAndWeights, 2) + assert.Equal(t, big.NewInt(10000), result.WeightsTotal) + assert.Equal(t, nopAddress1, result.NopsAndWeights[0].Nop) + assert.Equal(t, nopAddress2, result.NopsAndWeights[1].Nop) +} + +func TestGetNopsEmptyResult(t *testing.T) { + result := GetNopsResult{ + NopsAndWeights: []NopAndWeight{}, + WeightsTotal: big.NewInt(0), + } + + assert.Empty(t, result.NopsAndWeights) + assert.Equal(t, big.NewInt(0), result.WeightsTotal) +} + +// ============================================================================= +// WithdrawNonLinkFees Tests +// ============================================================================= + +func TestOnRampWithdrawNonLinkFeesInput(t *testing.T) { + tests := []struct { + desc string + input WithdrawNonLinkFeesInput + }{ + { + desc: "valid withdrawal to address", + input: WithdrawNonLinkFeesInput{ + FeeToken: feeTokenAddress, + To: withdrawToAddr, + }, + }, + { + desc: "zero address token handling", + input: WithdrawNonLinkFeesInput{ + FeeToken: common.Address{}, + To: withdrawToAddr, + }, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + // Verify input structure + assert.NotNil(t, test.input) + // The To address should normally be non-zero for valid withdrawals + if test.desc != "zero address token handling" { + assert.NotEqual(t, common.Address{}, test.input.FeeToken) + } + assert.NotEqual(t, common.Address{}, test.input.To) + }) + } +} + +// ============================================================================= +// PayNops Tests +// ============================================================================= + +func TestOnRampPayNopsNoArgs(t *testing.T) { + // PayNops takes no arguments (uses `any` type) + // This test verifies the operation is structured correctly + assert.Equal(t, "onramp:pay-nops", OnRampPayNops.ID()) + assert.Equal(t, "1.5.0", OnRampPayNops.Version()) +} + +// ============================================================================= +// SetTokenTransferFeeConfig Tests +// ============================================================================= + +func TestSetTokenTransferFeeConfigInput(t *testing.T) { + tests := []struct { + desc string + input SetTokenTransferFeeConfigInput + }{ + { + desc: "single token config", + input: SetTokenTransferFeeConfigInput{ + TokenTransferFeeConfigArgs: []evm_2_evm_onramp.EVM2EVMOnRampTokenTransferFeeConfigArgs{ + { + Token: feeTokenAddress, + MinFeeUSDCents: 50, + MaxFeeUSDCents: 1000, + DeciBps: 10, + DestGasOverhead: 10000, + DestBytesOverhead: 500, + AggregateRateLimitEnabled: false, + }, + }, + TokensToUseDefaultFeeConfigs: []common.Address{}, + }, + }, + { + desc: "multiple token configs", + input: SetTokenTransferFeeConfigInput{ + TokenTransferFeeConfigArgs: []evm_2_evm_onramp.EVM2EVMOnRampTokenTransferFeeConfigArgs{ + { + Token: feeTokenAddress, + MinFeeUSDCents: 50, + MaxFeeUSDCents: 1000, + DeciBps: 10, + DestGasOverhead: 10000, + DestBytesOverhead: 500, + AggregateRateLimitEnabled: false, + }, + { + Token: nopAddress1, // using as another token for test + MinFeeUSDCents: 100, + MaxFeeUSDCents: 2000, + DeciBps: 20, + DestGasOverhead: 20000, + DestBytesOverhead: 1000, + AggregateRateLimitEnabled: true, + }, + }, + TokensToUseDefaultFeeConfigs: []common.Address{}, + }, + }, + { + desc: "with tokens to use default config", + input: SetTokenTransferFeeConfigInput{ + TokenTransferFeeConfigArgs: []evm_2_evm_onramp.EVM2EVMOnRampTokenTransferFeeConfigArgs{}, + TokensToUseDefaultFeeConfigs: []common.Address{feeTokenAddress, nopAddress1}, + }, + }, + { + desc: "empty config", + input: SetTokenTransferFeeConfigInput{ + TokenTransferFeeConfigArgs: []evm_2_evm_onramp.EVM2EVMOnRampTokenTransferFeeConfigArgs{}, + TokensToUseDefaultFeeConfigs: []common.Address{}, + }, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + assert.NotNil(t, test.input.TokenTransferFeeConfigArgs) + assert.NotNil(t, test.input.TokensToUseDefaultFeeConfigs) + }) + } +} + +// ============================================================================= +// NopAndWeight Type Tests +// ============================================================================= + +func TestNopAndWeightType(t *testing.T) { + // Verify NopAndWeight is an alias for the gethwrapper type + nop := NopAndWeight{ + Nop: nopAddress1, + Weight: 10000, + } + + // This should compile and work correctly + assert.Equal(t, nopAddress1, nop.Nop) + assert.Equal(t, uint16(10000), nop.Weight) + + // Verify it's the same as the evm_2_evm_onramp type + var wrapped evm_2_evm_onramp.EVM2EVMOnRampNopAndWeight = nop + assert.Equal(t, nop.Nop, wrapped.Nop) + assert.Equal(t, nop.Weight, wrapped.Weight) +} + +// ============================================================================= +// Chain Selector Validation Tests +// ============================================================================= + +func TestChainSelectorMismatchErrors(t *testing.T) { + lggr, err := logger.New() + require.NoError(t, err) + + bundle := operations.NewBundle( + func() context.Context { return context.Background() }, + lggr, + operations.NewMemoryReporter(), + ) + + chain := evm.Chain{ + Selector: validChainSel, + } + + // Test that mismatched chain selectors produce appropriate errors + // We use a read operation as it's simpler (no contract deployment needed) + t.Run("GetNops with mismatched chain selector", func(t *testing.T) { + input := contract.FunctionInput[any]{ + ChainSelector: invalidChainSel, + Address: testAddress, + Args: nil, + } + + _, err := operations.ExecuteOperation(bundle, OnRampGetNops, chain, input) + require.Error(t, err) + assert.Contains(t, err.Error(), fmt.Sprintf("mismatch between inputted chain selector and selector defined within dependencies: %d != %d", invalidChainSel, validChainSel)) + }) + + t.Run("GetNopFeesJuels with mismatched chain selector", func(t *testing.T) { + input := contract.FunctionInput[any]{ + ChainSelector: invalidChainSel, + Address: testAddress, + Args: nil, + } + + _, err := operations.ExecuteOperation(bundle, OnRampGetNopFeesJuels, chain, input) + require.Error(t, err) + assert.Contains(t, err.Error(), "mismatch between inputted chain selector and selector defined within dependencies") + }) +} + +// ============================================================================= +// Empty Address Validation Tests +// ============================================================================= + +func TestEmptyAddressErrors(t *testing.T) { + lggr, err := logger.New() + require.NoError(t, err) + + bundle := operations.NewBundle( + func() context.Context { return context.Background() }, + lggr, + operations.NewMemoryReporter(), + ) + + chain := evm.Chain{ + Selector: validChainSel, + } + + t.Run("GetNops with empty address", func(t *testing.T) { + input := contract.FunctionInput[any]{ + ChainSelector: validChainSel, + Address: common.Address{}, + Args: nil, + } + + _, err := operations.ExecuteOperation(bundle, OnRampGetNops, chain, input) + require.Error(t, err) + assert.Contains(t, err.Error(), "address must be specified") + }) + + t.Run("GetTokenTransferFeeConfig with empty address", func(t *testing.T) { + input := contract.FunctionInput[common.Address]{ + ChainSelector: validChainSel, + Address: common.Address{}, + Args: feeTokenAddress, + } + + _, err := operations.ExecuteOperation(bundle, OnRampGetTokenTransferFeeConfig, chain, input) + require.Error(t, err) + assert.Contains(t, err.Error(), "address must be specified") + }) +} + +// ============================================================================= +// Write Operation Output Structure Tests +// ============================================================================= + +func TestWriteOutputStructure(t *testing.T) { + // Test that WriteOutput can be properly constructed + output := contract.WriteOutput{ + ChainSelector: validChainSel, + ExecInfo: nil, + } + + assert.Equal(t, validChainSel, output.ChainSelector) + assert.False(t, output.Executed(), "Output without ExecInfo should not be marked as executed") + + output.ExecInfo = &contract.ExecInfo{Hash: "0xabc123"} + assert.True(t, output.Executed(), "Output with ExecInfo should be marked as executed") +} + +// ============================================================================= +// FunctionInput Structure Tests +// ============================================================================= + +func TestFunctionInputStructures(t *testing.T) { + t.Run("SetNops FunctionInput", func(t *testing.T) { + input := contract.FunctionInput[SetNopsInput]{ + ChainSelector: validChainSel, + Address: testAddress, + Args: SetNopsInput{ + NopsAndWeights: []NopAndWeight{ + {Nop: nopAddress1, Weight: 10000}, + }, + }, + } + + assert.Equal(t, validChainSel, input.ChainSelector) + assert.Equal(t, testAddress, input.Address) + assert.Len(t, input.Args.NopsAndWeights, 1) + }) + + t.Run("WithdrawNonLinkFees FunctionInput", func(t *testing.T) { + input := contract.FunctionInput[WithdrawNonLinkFeesInput]{ + ChainSelector: validChainSel, + Address: testAddress, + Args: WithdrawNonLinkFeesInput{ + FeeToken: feeTokenAddress, + To: withdrawToAddr, + }, + } + + assert.Equal(t, validChainSel, input.ChainSelector) + assert.Equal(t, testAddress, input.Address) + assert.Equal(t, feeTokenAddress, input.Args.FeeToken) + assert.Equal(t, withdrawToAddr, input.Args.To) + }) + + t.Run("SetTokenTransferFeeConfig FunctionInput", func(t *testing.T) { + input := contract.FunctionInput[SetTokenTransferFeeConfigInput]{ + ChainSelector: validChainSel, + Address: testAddress, + Args: SetTokenTransferFeeConfigInput{ + TokenTransferFeeConfigArgs: []evm_2_evm_onramp.EVM2EVMOnRampTokenTransferFeeConfigArgs{ + { + Token: feeTokenAddress, + MinFeeUSDCents: 50, + MaxFeeUSDCents: 1000, + }, + }, + TokensToUseDefaultFeeConfigs: []common.Address{}, + }, + } + + assert.Equal(t, validChainSel, input.ChainSelector) + assert.Len(t, input.Args.TokenTransferFeeConfigArgs, 1) + assert.Equal(t, feeTokenAddress, input.Args.TokenTransferFeeConfigArgs[0].Token) + }) + + t.Run("PayNops FunctionInput with nil args", func(t *testing.T) { + input := contract.FunctionInput[any]{ + ChainSelector: validChainSel, + Address: testAddress, + Args: nil, + } + + assert.Equal(t, validChainSel, input.ChainSelector) + assert.Equal(t, testAddress, input.Address) + assert.Nil(t, input.Args) + }) +} + +// ============================================================================= +// BatchOperation Construction Tests +// ============================================================================= + +func TestBatchOperationFromWriteOutputs(t *testing.T) { + t.Run("single write output", func(t *testing.T) { + outputs := []contract.WriteOutput{ + { + ChainSelector: validChainSel, + }, + } + + batchOp, err := contract.NewBatchOperationFromWrites(outputs) + require.NoError(t, err) + assert.Equal(t, validChainSel, uint64(batchOp.ChainSelector)) + }) + + t.Run("multiple write outputs same chain", func(t *testing.T) { + outputs := []contract.WriteOutput{ + {ChainSelector: validChainSel}, + {ChainSelector: validChainSel}, + } + + batchOp, err := contract.NewBatchOperationFromWrites(outputs) + require.NoError(t, err) + assert.Equal(t, validChainSel, uint64(batchOp.ChainSelector)) + }) + + t.Run("empty outputs", func(t *testing.T) { + outputs := []contract.WriteOutput{} + + batchOp, err := contract.NewBatchOperationFromWrites(outputs) + require.NoError(t, err) + assert.Equal(t, uint64(0), uint64(batchOp.ChainSelector)) + }) + + t.Run("all executed outputs filtered", func(t *testing.T) { + outputs := []contract.WriteOutput{ + { + ChainSelector: validChainSel, + ExecInfo: &contract.ExecInfo{Hash: "0xabc"}, + }, + } + + batchOp, err := contract.NewBatchOperationFromWrites(outputs) + require.NoError(t, err) + // Executed outputs are filtered out + assert.Equal(t, uint64(0), uint64(batchOp.ChainSelector)) + }) +} + +// ============================================================================= +// Mock Contract Interface Tests +// ============================================================================= + +// mockOnRamp implements a minimal interface for testing +type mockOnRamp struct { + address common.Address + owner common.Address + nops []evm_2_evm_onramp.EVM2EVMOnRampNopAndWeight + fees *big.Int +} + +func (m *mockOnRamp) Address() common.Address { + return m.address +} + +func (m *mockOnRamp) Owner(opts *bind.CallOpts) (common.Address, error) { + return m.owner, nil +} + +func (m *mockOnRamp) SetNops(opts *bind.TransactOpts, nops []evm_2_evm_onramp.EVM2EVMOnRampNopAndWeight) (*types.Transaction, error) { + m.nops = nops + return types.NewTx(&types.LegacyTx{ + To: &m.address, + Data: []byte{0xDE, 0xAD, 0xBE, 0xEF}, + }), nil +} + +func (m *mockOnRamp) GetNops(opts *bind.CallOpts) (struct { + NopsAndWeights []evm_2_evm_onramp.EVM2EVMOnRampNopAndWeight + WeightsTotal *big.Int +}, error) { + return struct { + NopsAndWeights []evm_2_evm_onramp.EVM2EVMOnRampNopAndWeight + WeightsTotal *big.Int + }{ + NopsAndWeights: m.nops, + WeightsTotal: big.NewInt(10000), + }, nil +} + +func (m *mockOnRamp) GetNopFeesJuels(opts *bind.CallOpts) (*big.Int, error) { + if m.fees == nil { + return big.NewInt(0), nil + } + return m.fees, nil +} + +func TestMockOnRampBehavior(t *testing.T) { + mock := &mockOnRamp{ + address: testAddress, + owner: ownerAddress, + nops: []evm_2_evm_onramp.EVM2EVMOnRampNopAndWeight{}, + fees: big.NewInt(1000000), + } + + t.Run("owner check", func(t *testing.T) { + owner, err := mock.Owner(nil) + require.NoError(t, err) + assert.Equal(t, ownerAddress, owner) + }) + + t.Run("set and get nops", func(t *testing.T) { + nops := []evm_2_evm_onramp.EVM2EVMOnRampNopAndWeight{ + {Nop: nopAddress1, Weight: 5000}, + {Nop: nopAddress2, Weight: 5000}, + } + + _, err := mock.SetNops(nil, nops) + require.NoError(t, err) + + result, err := mock.GetNops(nil) + require.NoError(t, err) + assert.Len(t, result.NopsAndWeights, 2) + assert.Equal(t, big.NewInt(10000), result.WeightsTotal) + }) + + t.Run("get fees", func(t *testing.T) { + fees, err := mock.GetNopFeesJuels(nil) + require.NoError(t, err) + assert.Equal(t, big.NewInt(1000000), fees) + }) +} diff --git a/chains/evm/deployment/v1_5_0/sequences/onramp_fees.go b/chains/evm/deployment/v1_5_0/sequences/onramp_fees.go new file mode 100644 index 0000000000..1f38238d09 --- /dev/null +++ b/chains/evm/deployment/v1_5_0/sequences/onramp_fees.go @@ -0,0 +1,499 @@ +package sequences + +import ( + "fmt" + "math/big" + + "github.com/Masterminds/semver/v3" + "github.com/ethereum/go-ethereum/common" + + evm_contract "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/operations/contract" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/weth" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_5_0/operations/onramp" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils/sequences" + "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm" + cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + mcms_types "github.com/smartcontractkit/mcms/types" +) + +// ============================================================================= +// Input Types +// ============================================================================= + +// ConfigureFeeSweepV150Input configures an OnRamp to send all LINK fees to a treasury address. +type ConfigureFeeSweepV150Input struct { + ChainSelector uint64 + OnRampAddress common.Address + Treasury common.Address + AllowedRecipients map[uint64]common.Address +} + +func (c ConfigureFeeSweepV150Input) Validate(chain evm.Chain) error { + if c.ChainSelector != chain.Selector { + return fmt.Errorf("chain selector %d does not match chain %s", c.ChainSelector, chain) + } + if c.OnRampAddress == (common.Address{}) { + return fmt.Errorf("OnRampAddress cannot be zero address") + } + if err := validateTreasuryAddress(c.AllowedRecipients, c.ChainSelector, c.Treasury); err != nil { + return fmt.Errorf("treasury validation failed: %w", err) + } + return nil +} + +// SweepLinkFeesV150Input triggers LINK fee payout after verifying NOP configuration. +type SweepLinkFeesV150Input struct { + ChainSelector uint64 + OnRampAddress common.Address + ExpectedTreasury common.Address + AllowedRecipients map[uint64]common.Address +} + +func (c SweepLinkFeesV150Input) Validate(chain evm.Chain) error { + if c.ChainSelector != chain.Selector { + return fmt.Errorf("chain selector %d does not match chain %s", c.ChainSelector, chain) + } + if c.OnRampAddress == (common.Address{}) { + return fmt.Errorf("OnRampAddress cannot be zero address") + } + if err := validateTreasuryAddress(c.AllowedRecipients, c.ChainSelector, c.ExpectedTreasury); err != nil { + return fmt.Errorf("expected treasury validation failed: %w", err) + } + return nil +} + +// SweepNonLinkFeesV150Input withdraws multiple non-LINK fee tokens to treasury. +// If WETH9Address is non-zero, tokens matching WETH9 trigger atomic 3-step unwrap +// (withdraw to MCMS → unwrap WETH → transfer native ETH to treasury). +type SweepNonLinkFeesV150Input struct { + ChainSelector uint64 + OnRampAddress common.Address + FeeTokens []common.Address + Treasury common.Address + WETH9Address common.Address // Zero = no WETH auto-detection + MCMSAddress common.Address // RBACTimelock for WETH unwrap destination + WETHBalance *big.Int // WETH balance of OnRamp (for unwrap amount) + AllowedRecipients map[uint64]common.Address +} + +func (c SweepNonLinkFeesV150Input) Validate(chain evm.Chain) error { + if c.ChainSelector != chain.Selector { + return fmt.Errorf("chain selector %d does not match chain %s", c.ChainSelector, chain) + } + if c.OnRampAddress == (common.Address{}) { + return fmt.Errorf("OnRampAddress cannot be zero address") + } + if err := validateTreasuryAddress(c.AllowedRecipients, c.ChainSelector, c.Treasury); err != nil { + return fmt.Errorf("treasury validation failed: %w", err) + } + if c.WETH9Address != (common.Address{}) && c.MCMSAddress == (common.Address{}) { + return fmt.Errorf("MCMSAddress must be set when WETH9Address is provided") + } + return nil +} + +// SweepAllOnRampsV150Input is the mega flow input: configure NOPs, sweep LINK, +// sweep non-LINK (with WETH auto-unwrap) for ALL OnRamps on a chain. +type SweepAllOnRampsV150Input struct { + ChainSelector uint64 + Treasury common.Address + OnRamps []common.Address + NonLinkFeeTokens []common.Address // Non-LINK tokens for this chain + WETH9Address common.Address // Zero = no WETH unwrap + MCMSAddress common.Address // RBACTimelock, zero if no WETH unwrap + OnRampTokenBalances map[common.Address]map[common.Address]*big.Int // onRamp → token → balance + OnRampLINKFees map[common.Address]*big.Int // onRamp → LINK fee balance (juels) + MinSweepAmount *big.Int // Skip tokens below this (wei) + AllowedRecipients map[uint64]common.Address + // SkipNopsCheck skips the on-chain NOP pre-check when true. + // Use for dry-run testing only. The SetNops tx is always included + // in the batch regardless of this setting. + SkipNopsCheck bool +} + +func (c SweepAllOnRampsV150Input) Validate(chain evm.Chain) error { + if c.ChainSelector != chain.Selector { + return fmt.Errorf("chain selector %d does not match chain %s", c.ChainSelector, chain) + } + if err := validateTreasuryAddress(c.AllowedRecipients, c.ChainSelector, c.Treasury); err != nil { + return fmt.Errorf("treasury validation failed: %w", err) + } + if len(c.OnRamps) == 0 { + return fmt.Errorf("no OnRamps provided") + } + if c.WETH9Address != (common.Address{}) && c.MCMSAddress == (common.Address{}) { + return fmt.Errorf("MCMSAddress must be set when WETH9Address is provided") + } + if c.MinSweepAmount == nil { + return fmt.Errorf("MinSweepAmount must not be nil (use 0 to sweep everything)") + } + if c.MinSweepAmount.Sign() < 0 { + return fmt.Errorf("MinSweepAmount must be non-negative, got %s", c.MinSweepAmount.String()) + } + for i, addr := range c.OnRamps { + if addr == (common.Address{}) { + return fmt.Errorf("OnRamps[%d] cannot be zero address", i) + } + } + return nil +} + +// ============================================================================= +// Treasury Address Validation +// ============================================================================= + +func validateTreasuryAddress(allowedRecipients map[uint64]common.Address, chainSelector uint64, address common.Address) error { + if address == (common.Address{}) { + return fmt.Errorf("treasury address cannot be zero address") + } + if allowedRecipients == nil { + return fmt.Errorf("allowedRecipients map is nil; must be provided from CLD inputs") + } + allowedAddr, ok := allowedRecipients[chainSelector] + if !ok { + return fmt.Errorf("chain selector %d is not in the allowed recipients list", chainSelector) + } + if allowedAddr != address { + return fmt.Errorf("address %s is not the approved treasury for chain %d; expected %s", + address.Hex(), chainSelector, allowedAddr.Hex()) + } + return nil +} + +// ============================================================================= +// Shared Helper Functions +// ============================================================================= + +// checkNopConfig reads the current NOP config and returns true if it matches the +// expected treasury setup: exactly 1 NOP, address == treasury, weight == 65535, +// weightsTotal == 65535. +func checkNopConfig(b cldf_ops.Bundle, chain evm.Chain, onRampAddr, treasury common.Address) (bool, error) { + nopsResult, err := cldf_ops.ExecuteOperation(b, onramp.OnRampGetNops, chain, evm_contract.FunctionInput[any]{ + ChainSelector: chain.Selector, + Address: onRampAddr, + }) + if err != nil { + return false, fmt.Errorf("failed to get NOP config for OnRamp %s: %w", onRampAddr.Hex(), err) + } + + nops := nopsResult.Output.NopsAndWeights + if len(nops) != 1 { + return false, nil + } + if nops[0].Nop != treasury { + return false, nil + } + if nops[0].Weight != 65535 { + return false, nil + } + if nopsResult.Output.WeightsTotal == nil || nopsResult.Output.WeightsTotal.Cmp(big.NewInt(65535)) != 0 { + return false, nil + } + return true, nil +} + +// buildSetNopsTx builds a SetNops transaction to configure treasury as sole NOP with 100% weight. +func buildSetNopsTx(b cldf_ops.Bundle, chain evm.Chain, onRampAddr, treasury common.Address) (mcms_types.Transaction, error) { + report, err := cldf_ops.ExecuteOperation(b, onramp.OnRampSetNops, chain, evm_contract.FunctionInput[onramp.SetNopsInput]{ + ChainSelector: chain.Selector, + Address: onRampAddr, + Args: onramp.SetNopsInput{ + NopsAndWeights: []onramp.NopAndWeight{ + {Nop: treasury, Weight: 65535}, + }, + }, + }) + if err != nil { + return mcms_types.Transaction{}, fmt.Errorf("failed to build SetNops tx for OnRamp %s: %w", onRampAddr.Hex(), err) + } + return report.Output.Tx, nil +} + +// buildPayNopsTx builds a PayNops transaction. Caller must verify NOP config first. +func buildPayNopsTx(b cldf_ops.Bundle, chain evm.Chain, onRampAddr common.Address) (mcms_types.Transaction, error) { + report, err := cldf_ops.ExecuteOperation(b, onramp.OnRampPayNops, chain, evm_contract.FunctionInput[any]{ + ChainSelector: chain.Selector, + Address: onRampAddr, + }) + if err != nil { + return mcms_types.Transaction{}, fmt.Errorf("failed to build PayNops tx for OnRamp %s: %w", onRampAddr.Hex(), err) + } + return report.Output.Tx, nil +} + +// buildNonLinkSweepTxs builds transactions for sweeping non-LINK fee tokens. +// If a token matches weth9Addr (and weth9Addr is non-zero), it does atomic 3-step +// unwrap: withdraw WETH to MCMS → unwrap to native ETH → transfer to treasury. +// Otherwise does direct WithdrawNonLinkFees to treasury. +func buildNonLinkSweepTxs(b cldf_ops.Bundle, chain evm.Chain, onRampAddr common.Address, + feeTokens []common.Address, treasury, weth9Addr, mcmsAddr common.Address, + wethBalance *big.Int) ([]mcms_types.Transaction, error) { + + var txs []mcms_types.Transaction + + for _, token := range feeTokens { + if weth9Addr != (common.Address{}) && token == weth9Addr { + // Skip WETH if balance is nil/zero + if wethBalance == nil || wethBalance.Sign() <= 0 { + continue + } + + // Atomic 3-step WETH unwrap + + // Step 1: Withdraw WETH from OnRamp to MCMS timelock + withdrawReport, err := cldf_ops.ExecuteOperation(b, onramp.OnRampWithdrawNonLinkFees, chain, + evm_contract.FunctionInput[onramp.WithdrawNonLinkFeesInput]{ + ChainSelector: chain.Selector, + Address: onRampAddr, + Args: onramp.WithdrawNonLinkFeesInput{ + FeeToken: weth9Addr, + To: mcmsAddr, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to build WETH withdraw tx for OnRamp %s: %w", onRampAddr.Hex(), err) + } + if !withdrawReport.Output.Executed() { + txs = append(txs, withdrawReport.Output.Tx) + } + + // Step 2: MCMS calls WETH9.withdraw() to unwrap WETH to native ETH + unwrapReport, err := cldf_ops.ExecuteOperation(b, weth.Withdraw, chain, + evm_contract.FunctionInput[weth.WithdrawInput]{ + ChainSelector: chain.Selector, + Address: weth9Addr, + Args: weth.WithdrawInput{Amount: wethBalance}, + }) + if err != nil { + return nil, fmt.Errorf("failed to build WETH unwrap tx for OnRamp %s: %w", onRampAddr.Hex(), err) + } + if !unwrapReport.Output.Executed() { + txs = append(txs, unwrapReport.Output.Tx) + } + + // Step 3: Transfer native ETH from MCMS to treasury + ethTx, err := weth.CreateNativeETHTransferTx(treasury, wethBalance) + if err != nil { + return nil, fmt.Errorf("failed to build ETH transfer tx for OnRamp %s: %w", onRampAddr.Hex(), err) + } + txs = append(txs, ethTx) + } else { + // Direct withdraw to treasury + report, err := cldf_ops.ExecuteOperation(b, onramp.OnRampWithdrawNonLinkFees, chain, + evm_contract.FunctionInput[onramp.WithdrawNonLinkFeesInput]{ + ChainSelector: chain.Selector, + Address: onRampAddr, + Args: onramp.WithdrawNonLinkFeesInput{ + FeeToken: token, + To: treasury, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to build WithdrawNonLinkFees tx for token %s on OnRamp %s: %w", + token.Hex(), onRampAddr.Hex(), err) + } + if !report.Output.Executed() { + txs = append(txs, report.Output.Tx) + } + } + } + + return txs, nil +} + +// getTokenBalance safely retrieves a token balance from a nested map. +func getTokenBalance(balances map[common.Address]map[common.Address]*big.Int, onRamp, token common.Address) *big.Int { + if balances == nil { + return nil + } + tokenBalances, ok := balances[onRamp] + if !ok { + return nil + } + return tokenBalances[token] +} + +// ============================================================================= +// Sequences +// ============================================================================= + +// ConfigureFeeSweepV150Sequence sets up an OnRamp to send all LINK fees to the treasury. +var ConfigureFeeSweepV150Sequence = cldf_ops.NewSequence( + "onramp:configure-fee-sweep", + semver.MustParse("1.5.0"), + "Configures the OnRamp 1.5.0 to send all LINK fees to the treasury address", + func(b cldf_ops.Bundle, chain evm.Chain, input ConfigureFeeSweepV150Input) (sequences.OnChainOutput, error) { + if err := input.Validate(chain); err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("invalid input: %w", err) + } + + tx, err := buildSetNopsTx(b, chain, input.OnRampAddress, input.Treasury) + if err != nil { + return sequences.OnChainOutput{}, err + } + + batch := mcms_types.BatchOperation{ + ChainSelector: mcms_types.ChainSelector(chain.Selector), + Transactions: []mcms_types.Transaction{tx}, + } + + return sequences.OnChainOutput{ + BatchOps: []mcms_types.BatchOperation{batch}, + }, nil + }, +) + +// SweepLinkFeesV150Sequence triggers LINK fee payout after verifying NOP configuration. +var SweepLinkFeesV150Sequence = cldf_ops.NewSequence( + "onramp:sweep-link-fees", + semver.MustParse("1.5.0"), + "Sweeps accumulated LINK fees from the OnRamp 1.5.0 to configured NOPs (treasury)", + func(b cldf_ops.Bundle, chain evm.Chain, input SweepLinkFeesV150Input) (sequences.OnChainOutput, error) { + if err := input.Validate(chain); err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("invalid input: %w", err) + } + + // Full NOP config validation before PayNops + isCorrect, err := checkNopConfig(b, chain, input.OnRampAddress, input.ExpectedTreasury) + if err != nil { + return sequences.OnChainOutput{}, err + } + if !isCorrect { + return sequences.OnChainOutput{}, fmt.Errorf( + "NOP configuration is not correct for OnRamp %s; expected sole NOP %s with weight 65535; run configure_fee_sweep_v150_evm first", + input.OnRampAddress.Hex(), input.ExpectedTreasury.Hex()) + } + + tx, err := buildPayNopsTx(b, chain, input.OnRampAddress) + if err != nil { + return sequences.OnChainOutput{}, err + } + + batch := mcms_types.BatchOperation{ + ChainSelector: mcms_types.ChainSelector(chain.Selector), + Transactions: []mcms_types.Transaction{tx}, + } + + return sequences.OnChainOutput{ + BatchOps: []mcms_types.BatchOperation{batch}, + }, nil + }, +) + +// SweepNonLinkFeesV150Sequence withdraws non-LINK fee tokens to treasury. +// Auto-detects WETH among fee tokens and does atomic unwrap if WETH9Address is set. +var SweepNonLinkFeesV150Sequence = cldf_ops.NewSequence( + "onramp:sweep-non-link-fees", + semver.MustParse("1.5.0"), + "Sweeps accumulated non-LINK fee tokens from the OnRamp 1.5.0 to treasury", + func(b cldf_ops.Bundle, chain evm.Chain, input SweepNonLinkFeesV150Input) (sequences.OnChainOutput, error) { + if err := input.Validate(chain); err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("invalid input: %w", err) + } + + txs, err := buildNonLinkSweepTxs(b, chain, input.OnRampAddress, input.FeeTokens, + input.Treasury, input.WETH9Address, input.MCMSAddress, input.WETHBalance) + if err != nil { + return sequences.OnChainOutput{}, err + } + + if len(txs) == 0 { + return sequences.OnChainOutput{}, nil + } + + batch := mcms_types.BatchOperation{ + ChainSelector: mcms_types.ChainSelector(chain.Selector), + Transactions: txs, + } + + return sequences.OnChainOutput{ + BatchOps: []mcms_types.BatchOperation{batch}, + }, nil + }, +) + +// SweepAllOnRampsV150Sequence is the mega flow: configure NOPs, sweep LINK, +// sweep non-LINK (with WETH auto-unwrap) for ALL OnRamps on a chain. +// All transactions are in a single atomic MCMS batch. +var SweepAllOnRampsV150Sequence = cldf_ops.NewSequence( + "onramp:sweep-all-onramps", + semver.MustParse("1.5.0"), + "Configures NOPs and sweeps all fees (LINK + non-LINK + WETH unwrap) from ALL OnRamps v1.5.0", + func(b cldf_ops.Bundle, chain evm.Chain, input SweepAllOnRampsV150Input) (sequences.OnChainOutput, error) { + if err := input.Validate(chain); err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("invalid input: %w", err) + } + + var allTxs []mcms_types.Transaction + + for _, onRampAddr := range input.OnRamps { + // 1. Verify NOP config — fail if not configured (unless skipped for dry-run) + if !input.SkipNopsCheck { + isCorrect, err := checkNopConfig(b, chain, onRampAddr, input.Treasury) + if err != nil { + return sequences.OnChainOutput{}, err + } + if !isCorrect { + return sequences.OnChainOutput{}, fmt.Errorf( + "NOP config for OnRamp %s is not set to treasury %s; "+ + "run configure_fee_sweep_v150_evm first", + onRampAddr.Hex(), input.Treasury.Hex()) + } + } + // Defensive re-assertion: always include SetNops in the batch to guard + // against concurrent proposals modifying NOP config between resolution + // and execution. + { + tx, err := buildSetNopsTx(b, chain, onRampAddr, input.Treasury) + if err != nil { + return sequences.OnChainOutput{}, err + } + allTxs = append(allTxs, tx) + } + + // 2. Sweep LINK if above threshold + linkFees := input.OnRampLINKFees[onRampAddr] + if linkFees != nil && linkFees.Cmp(input.MinSweepAmount) >= 0 { + tx, err := buildPayNopsTx(b, chain, onRampAddr) + if err != nil { + return sequences.OnChainOutput{}, err + } + allTxs = append(allTxs, tx) + } + + // 3. Sweep non-LINK tokens above threshold + for _, token := range input.NonLinkFeeTokens { + tokenBalance := getTokenBalance(input.OnRampTokenBalances, onRampAddr, token) + if tokenBalance == nil || tokenBalance.Cmp(input.MinSweepAmount) < 0 { + continue + } + + var wethBal *big.Int + if token == input.WETH9Address { + wethBal = tokenBalance + } + + tokenTxs, err := buildNonLinkSweepTxs(b, chain, onRampAddr, + []common.Address{token}, input.Treasury, + input.WETH9Address, input.MCMSAddress, wethBal) + if err != nil { + return sequences.OnChainOutput{}, err + } + allTxs = append(allTxs, tokenTxs...) + } + } + + if len(allTxs) == 0 { + return sequences.OnChainOutput{}, fmt.Errorf("no transactions generated; all balances below minSweepAmount") + } + + batch := mcms_types.BatchOperation{ + ChainSelector: mcms_types.ChainSelector(chain.Selector), + Transactions: allTxs, + } + + return sequences.OnChainOutput{ + BatchOps: []mcms_types.BatchOperation{batch}, + }, nil + }, +) diff --git a/chains/evm/deployment/v1_5_0/sequences/onramp_fees_test.go b/chains/evm/deployment/v1_5_0/sequences/onramp_fees_test.go new file mode 100644 index 0000000000..34ae7cc689 --- /dev/null +++ b/chains/evm/deployment/v1_5_0/sequences/onramp_fees_test.go @@ -0,0 +1,666 @@ +package sequences_test + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_5_0/sequences" + "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm" + "github.com/stretchr/testify/require" +) + +var ( + testTreasury = common.HexToAddress("0x1111111111111111111111111111111111111111") + testOtherAddr = common.HexToAddress("0x2222222222222222222222222222222222222222") + testOnRampAddr = common.HexToAddress("0x3333333333333333333333333333333333333333") + testWETH9Addr = common.HexToAddress("0x4444444444444444444444444444444444444444") + testMCMSAddr = common.HexToAddress("0x5555555555555555555555555555555555555555") + testChainSelector = uint64(5009297550715157269) + otherChainSel = uint64(4340886533089894000) +) + +// ============================================================================= +// validateTreasuryAddress Tests (Security Critical) +// ============================================================================= + +func TestValidateTreasuryAddress(t *testing.T) { + chain := evm.Chain{Selector: testChainSelector} + + tests := []struct { + desc string + allowedRecipients map[uint64]common.Address + chainSelector uint64 + address common.Address + expectedErr string + }{ + { + desc: "zero address rejected", + allowedRecipients: map[uint64]common.Address{testChainSelector: testTreasury}, + chainSelector: testChainSelector, + address: common.Address{}, + expectedErr: "cannot be zero", + }, + { + desc: "nil map rejected", + allowedRecipients: nil, + chainSelector: testChainSelector, + address: testTreasury, + expectedErr: "allowedRecipients map is nil", + }, + { + desc: "chain not in allowed list", + allowedRecipients: map[uint64]common.Address{otherChainSel: testTreasury}, + chainSelector: testChainSelector, + address: testTreasury, + expectedErr: "not in the allowed recipients list", + }, + { + desc: "wrong address for chain", + allowedRecipients: map[uint64]common.Address{testChainSelector: testTreasury}, + chainSelector: testChainSelector, + address: testOtherAddr, + expectedErr: "is not the approved treasury", + }, + { + desc: "happy path - address matches allowed", + allowedRecipients: map[uint64]common.Address{testChainSelector: testTreasury}, + chainSelector: testChainSelector, + address: testTreasury, + expectedErr: "", + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + input := sequences.ConfigureFeeSweepV150Input{ + ChainSelector: test.chainSelector, + OnRampAddress: testOnRampAddr, + Treasury: test.address, + AllowedRecipients: test.allowedRecipients, + } + + err := input.Validate(chain) + if test.expectedErr == "" { + require.NoError(t, err) + } else { + require.Error(t, err) + require.Contains(t, err.Error(), test.expectedErr) + } + }) + } +} + +// ============================================================================= +// ConfigureFeeSweepInput Validation Tests +// ============================================================================= + +func TestConfigureFeeSweepInputValidate(t *testing.T) { + chain := evm.Chain{Selector: testChainSelector} + validRecipients := map[uint64]common.Address{testChainSelector: testTreasury} + + tests := []struct { + desc string + makeInput func() sequences.ConfigureFeeSweepV150Input + expectedErr string + }{ + { + desc: "happy path", + makeInput: func() sequences.ConfigureFeeSweepV150Input { + return sequences.ConfigureFeeSweepV150Input{ + ChainSelector: testChainSelector, + OnRampAddress: testOnRampAddr, + Treasury: testTreasury, + AllowedRecipients: validRecipients, + } + }, + }, + { + desc: "chain selector mismatch", + makeInput: func() sequences.ConfigureFeeSweepV150Input { + return sequences.ConfigureFeeSweepV150Input{ + ChainSelector: otherChainSel, + OnRampAddress: testOnRampAddr, + Treasury: testTreasury, + AllowedRecipients: map[uint64]common.Address{otherChainSel: testTreasury}, + } + }, + expectedErr: "chain selector", + }, + { + desc: "treasury not in allowed list", + makeInput: func() sequences.ConfigureFeeSweepV150Input { + return sequences.ConfigureFeeSweepV150Input{ + ChainSelector: testChainSelector, + OnRampAddress: testOnRampAddr, + Treasury: testOtherAddr, + AllowedRecipients: validRecipients, + } + }, + expectedErr: "is not the approved treasury", + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + err := test.makeInput().Validate(chain) + if test.expectedErr == "" { + require.NoError(t, err) + } else { + require.Error(t, err) + require.Contains(t, err.Error(), test.expectedErr) + } + }) + } +} + +// ============================================================================= +// SweepLinkFeesInput Validation Tests +// ============================================================================= + +func TestSweepLinkFeesInputValidate(t *testing.T) { + chain := evm.Chain{Selector: testChainSelector} + validRecipients := map[uint64]common.Address{testChainSelector: testTreasury} + + tests := []struct { + desc string + makeInput func() sequences.SweepLinkFeesV150Input + expectedErr string + }{ + { + desc: "happy path", + makeInput: func() sequences.SweepLinkFeesV150Input { + return sequences.SweepLinkFeesV150Input{ + ChainSelector: testChainSelector, + OnRampAddress: testOnRampAddr, + ExpectedTreasury: testTreasury, + AllowedRecipients: validRecipients, + } + }, + }, + { + desc: "chain selector mismatch", + makeInput: func() sequences.SweepLinkFeesV150Input { + return sequences.SweepLinkFeesV150Input{ + ChainSelector: otherChainSel, + OnRampAddress: testOnRampAddr, + ExpectedTreasury: testTreasury, + AllowedRecipients: map[uint64]common.Address{otherChainSel: testTreasury}, + } + }, + expectedErr: "chain selector", + }, + { + desc: "expected treasury not approved", + makeInput: func() sequences.SweepLinkFeesV150Input { + return sequences.SweepLinkFeesV150Input{ + ChainSelector: testChainSelector, + OnRampAddress: testOnRampAddr, + ExpectedTreasury: testOtherAddr, + AllowedRecipients: validRecipients, + } + }, + expectedErr: "expected treasury validation failed", + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + err := test.makeInput().Validate(chain) + if test.expectedErr == "" { + require.NoError(t, err) + } else { + require.Error(t, err) + require.Contains(t, err.Error(), test.expectedErr) + } + }) + } +} + +// ============================================================================= +// SweepNonLinkFeesInput Validation Tests (with WETH auto-detection fields) +// ============================================================================= + +func TestSweepNonLinkFeesInputValidate(t *testing.T) { + chain := evm.Chain{Selector: testChainSelector} + validRecipients := map[uint64]common.Address{testChainSelector: testTreasury} + feeTokens := []common.Address{testWETH9Addr} + + tests := []struct { + desc string + makeInput func() sequences.SweepNonLinkFeesV150Input + expectedErr string + }{ + { + desc: "happy path - no WETH", + makeInput: func() sequences.SweepNonLinkFeesV150Input { + return sequences.SweepNonLinkFeesV150Input{ + ChainSelector: testChainSelector, + OnRampAddress: testOnRampAddr, + FeeTokens: feeTokens, + Treasury: testTreasury, + AllowedRecipients: validRecipients, + } + }, + }, + { + desc: "happy path - with WETH", + makeInput: func() sequences.SweepNonLinkFeesV150Input { + return sequences.SweepNonLinkFeesV150Input{ + ChainSelector: testChainSelector, + OnRampAddress: testOnRampAddr, + FeeTokens: feeTokens, + Treasury: testTreasury, + WETH9Address: testWETH9Addr, + MCMSAddress: testMCMSAddr, + WETHBalance: big.NewInt(1000), + AllowedRecipients: validRecipients, + } + }, + }, + { + desc: "WETH9 set but MCMSAddress missing", + makeInput: func() sequences.SweepNonLinkFeesV150Input { + return sequences.SweepNonLinkFeesV150Input{ + ChainSelector: testChainSelector, + OnRampAddress: testOnRampAddr, + FeeTokens: feeTokens, + Treasury: testTreasury, + WETH9Address: testWETH9Addr, + AllowedRecipients: validRecipients, + } + }, + expectedErr: "MCMSAddress must be set", + }, + { + desc: "chain selector mismatch", + makeInput: func() sequences.SweepNonLinkFeesV150Input { + return sequences.SweepNonLinkFeesV150Input{ + ChainSelector: otherChainSel, + OnRampAddress: testOnRampAddr, + FeeTokens: feeTokens, + Treasury: testTreasury, + AllowedRecipients: map[uint64]common.Address{otherChainSel: testTreasury}, + } + }, + expectedErr: "chain selector", + }, + { + desc: "treasury not approved", + makeInput: func() sequences.SweepNonLinkFeesV150Input { + return sequences.SweepNonLinkFeesV150Input{ + ChainSelector: testChainSelector, + OnRampAddress: testOnRampAddr, + FeeTokens: feeTokens, + Treasury: testOtherAddr, + AllowedRecipients: validRecipients, + } + }, + expectedErr: "treasury validation failed", + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + err := test.makeInput().Validate(chain) + if test.expectedErr == "" { + require.NoError(t, err) + } else { + require.Error(t, err) + require.Contains(t, err.Error(), test.expectedErr) + } + }) + } +} + +// ============================================================================= +// SweepAllOnRampsV150Input Validation Tests (Mega Flow) +// ============================================================================= + +func TestSweepAllOnRampsV150InputValidate(t *testing.T) { + chain := evm.Chain{Selector: testChainSelector} + validRecipients := map[uint64]common.Address{testChainSelector: testTreasury} + + tests := []struct { + desc string + makeInput func() sequences.SweepAllOnRampsV150Input + expectedErr string + }{ + { + desc: "happy path - with WETH", + makeInput: func() sequences.SweepAllOnRampsV150Input { + return sequences.SweepAllOnRampsV150Input{ + ChainSelector: testChainSelector, + Treasury: testTreasury, + OnRamps: []common.Address{testOnRampAddr}, + NonLinkFeeTokens: []common.Address{testWETH9Addr}, + WETH9Address: testWETH9Addr, + MCMSAddress: testMCMSAddr, + MinSweepAmount: big.NewInt(0), + AllowedRecipients: validRecipients, + } + }, + }, + { + desc: "happy path - no WETH", + makeInput: func() sequences.SweepAllOnRampsV150Input { + return sequences.SweepAllOnRampsV150Input{ + ChainSelector: testChainSelector, + Treasury: testTreasury, + OnRamps: []common.Address{testOnRampAddr}, + NonLinkFeeTokens: []common.Address{testOtherAddr}, + MinSweepAmount: big.NewInt(0), + AllowedRecipients: validRecipients, + } + }, + }, + { + desc: "chain selector mismatch", + makeInput: func() sequences.SweepAllOnRampsV150Input { + return sequences.SweepAllOnRampsV150Input{ + ChainSelector: otherChainSel, + Treasury: testTreasury, + OnRamps: []common.Address{testOnRampAddr}, + MinSweepAmount: big.NewInt(0), + AllowedRecipients: map[uint64]common.Address{otherChainSel: testTreasury}, + } + }, + expectedErr: "chain selector", + }, + { + desc: "treasury not approved", + makeInput: func() sequences.SweepAllOnRampsV150Input { + return sequences.SweepAllOnRampsV150Input{ + ChainSelector: testChainSelector, + Treasury: testOtherAddr, + OnRamps: []common.Address{testOnRampAddr}, + MinSweepAmount: big.NewInt(0), + AllowedRecipients: validRecipients, + } + }, + expectedErr: "treasury validation failed", + }, + { + desc: "empty OnRamps", + makeInput: func() sequences.SweepAllOnRampsV150Input { + return sequences.SweepAllOnRampsV150Input{ + ChainSelector: testChainSelector, + Treasury: testTreasury, + OnRamps: []common.Address{}, + MinSweepAmount: big.NewInt(0), + AllowedRecipients: validRecipients, + } + }, + expectedErr: "no OnRamps provided", + }, + { + desc: "WETH9 set but MCMSAddress missing", + makeInput: func() sequences.SweepAllOnRampsV150Input { + return sequences.SweepAllOnRampsV150Input{ + ChainSelector: testChainSelector, + Treasury: testTreasury, + OnRamps: []common.Address{testOnRampAddr}, + WETH9Address: testWETH9Addr, + MinSweepAmount: big.NewInt(0), + AllowedRecipients: validRecipients, + } + }, + expectedErr: "MCMSAddress must be set", + }, + { + desc: "nil MinSweepAmount", + makeInput: func() sequences.SweepAllOnRampsV150Input { + return sequences.SweepAllOnRampsV150Input{ + ChainSelector: testChainSelector, + Treasury: testTreasury, + OnRamps: []common.Address{testOnRampAddr}, + MinSweepAmount: nil, + AllowedRecipients: validRecipients, + } + }, + expectedErr: "MinSweepAmount must not be nil", + }, + { + desc: "nil allowed recipients", + makeInput: func() sequences.SweepAllOnRampsV150Input { + return sequences.SweepAllOnRampsV150Input{ + ChainSelector: testChainSelector, + Treasury: testTreasury, + OnRamps: []common.Address{testOnRampAddr}, + MinSweepAmount: big.NewInt(0), + AllowedRecipients: nil, + } + }, + expectedErr: "allowedRecipients map is nil", + }, + { + desc: "negative MinSweepAmount", + makeInput: func() sequences.SweepAllOnRampsV150Input { + return sequences.SweepAllOnRampsV150Input{ + ChainSelector: testChainSelector, + Treasury: testTreasury, + OnRamps: []common.Address{testOnRampAddr}, + MinSweepAmount: big.NewInt(-100), + AllowedRecipients: validRecipients, + } + }, + expectedErr: "MinSweepAmount must be non-negative", + }, + { + desc: "zero address in OnRamps", + makeInput: func() sequences.SweepAllOnRampsV150Input { + return sequences.SweepAllOnRampsV150Input{ + ChainSelector: testChainSelector, + Treasury: testTreasury, + OnRamps: []common.Address{common.Address{}}, + MinSweepAmount: big.NewInt(0), + AllowedRecipients: validRecipients, + } + }, + expectedErr: "OnRamps[0] cannot be zero address", + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + err := test.makeInput().Validate(chain) + if test.expectedErr == "" { + require.NoError(t, err) + } else { + require.Error(t, err) + require.Contains(t, err.Error(), test.expectedErr) + } + }) + } +} + +// ============================================================================= +// Validation Order Tests +// ============================================================================= + +func TestSweepAllOnRampsV150InputValidationOrder(t *testing.T) { + chain := evm.Chain{Selector: testChainSelector} + + // Chain selector checked before treasury + input := sequences.SweepAllOnRampsV150Input{ + ChainSelector: otherChainSel, + Treasury: common.Address{}, + OnRamps: nil, + MinSweepAmount: nil, + AllowedRecipients: nil, + } + + err := input.Validate(chain) + require.Error(t, err) + require.Contains(t, err.Error(), "chain selector") +} + +// ============================================================================= +// Edge Case Tests +// ============================================================================= + +func TestInputValidationEdgeCases(t *testing.T) { + chain := evm.Chain{Selector: testChainSelector} + validRecipients := map[uint64]common.Address{testChainSelector: testTreasury} + + t.Run("multiple chains in allowed recipients", func(t *testing.T) { + multiChainRecipients := map[uint64]common.Address{ + testChainSelector: testTreasury, + otherChainSel: testOtherAddr, + } + + input := sequences.ConfigureFeeSweepV150Input{ + ChainSelector: testChainSelector, + OnRampAddress: testOnRampAddr, + Treasury: testTreasury, + AllowedRecipients: multiChainRecipients, + } + + err := input.Validate(chain) + require.NoError(t, err) + }) + + t.Run("empty allowed recipients map", func(t *testing.T) { + input := sequences.ConfigureFeeSweepV150Input{ + ChainSelector: testChainSelector, + OnRampAddress: testOnRampAddr, + Treasury: testTreasury, + AllowedRecipients: map[uint64]common.Address{}, + } + + err := input.Validate(chain) + require.Error(t, err) + require.Contains(t, err.Error(), "not in the allowed recipients list") + }) + + t.Run("mega flow with zero MinSweepAmount sweeps everything", func(t *testing.T) { + input := sequences.SweepAllOnRampsV150Input{ + ChainSelector: testChainSelector, + Treasury: testTreasury, + OnRamps: []common.Address{testOnRampAddr}, + MinSweepAmount: big.NewInt(0), + AllowedRecipients: validRecipients, + } + + err := input.Validate(chain) + require.NoError(t, err) + }) + + t.Run("mega flow with SkipNopsCheck true is valid", func(t *testing.T) { + input := sequences.SweepAllOnRampsV150Input{ + ChainSelector: testChainSelector, + Treasury: testTreasury, + OnRamps: []common.Address{testOnRampAddr}, + MinSweepAmount: big.NewInt(0), + AllowedRecipients: validRecipients, + SkipNopsCheck: true, + } + + err := input.Validate(chain) + require.NoError(t, err) + }) + + t.Run("mega flow with SkipNopsCheck false is valid", func(t *testing.T) { + input := sequences.SweepAllOnRampsV150Input{ + ChainSelector: testChainSelector, + Treasury: testTreasury, + OnRamps: []common.Address{testOnRampAddr}, + MinSweepAmount: big.NewInt(0), + AllowedRecipients: validRecipients, + SkipNopsCheck: false, + } + + err := input.Validate(chain) + require.NoError(t, err) + }) + + t.Run("mega flow with high MinSweepAmount is valid", func(t *testing.T) { + input := sequences.SweepAllOnRampsV150Input{ + ChainSelector: testChainSelector, + Treasury: testTreasury, + OnRamps: []common.Address{testOnRampAddr}, + MinSweepAmount: new(big.Int).SetUint64(1e18), + AllowedRecipients: validRecipients, + } + + err := input.Validate(chain) + require.NoError(t, err) + }) + + t.Run("negative MinSweepAmount rejected", func(t *testing.T) { + input := sequences.SweepAllOnRampsV150Input{ + ChainSelector: testChainSelector, + Treasury: testTreasury, + OnRamps: []common.Address{testOnRampAddr}, + MinSweepAmount: big.NewInt(-1), + AllowedRecipients: validRecipients, + } + + err := input.Validate(chain) + require.Error(t, err) + require.Contains(t, err.Error(), "MinSweepAmount must be non-negative") + }) + + t.Run("zero address in OnRamps slice rejected", func(t *testing.T) { + input := sequences.SweepAllOnRampsV150Input{ + ChainSelector: testChainSelector, + Treasury: testTreasury, + OnRamps: []common.Address{testOnRampAddr, {}}, + MinSweepAmount: big.NewInt(0), + AllowedRecipients: validRecipients, + } + + err := input.Validate(chain) + require.Error(t, err) + require.Contains(t, err.Error(), "OnRamps[1] cannot be zero address") + }) +} + +// ============================================================================= +// OnRampAddress Zero-Check Tests +// ============================================================================= + +func TestOnRampAddressZeroCheck(t *testing.T) { + chain := evm.Chain{Selector: testChainSelector} + validRecipients := map[uint64]common.Address{testChainSelector: testTreasury} + + t.Run("ConfigureFeeSweep rejects zero OnRampAddress", func(t *testing.T) { + input := sequences.ConfigureFeeSweepV150Input{ + ChainSelector: testChainSelector, + OnRampAddress: common.Address{}, + Treasury: testTreasury, + AllowedRecipients: validRecipients, + } + + err := input.Validate(chain) + require.Error(t, err) + require.Contains(t, err.Error(), "OnRampAddress cannot be zero address") + }) + + t.Run("SweepLinkFees rejects zero OnRampAddress", func(t *testing.T) { + input := sequences.SweepLinkFeesV150Input{ + ChainSelector: testChainSelector, + OnRampAddress: common.Address{}, + ExpectedTreasury: testTreasury, + AllowedRecipients: validRecipients, + } + + err := input.Validate(chain) + require.Error(t, err) + require.Contains(t, err.Error(), "OnRampAddress cannot be zero address") + }) + + t.Run("SweepNonLinkFees rejects zero OnRampAddress", func(t *testing.T) { + input := sequences.SweepNonLinkFeesV150Input{ + ChainSelector: testChainSelector, + OnRampAddress: common.Address{}, + FeeTokens: []common.Address{testWETH9Addr}, + Treasury: testTreasury, + AllowedRecipients: validRecipients, + } + + err := input.Validate(chain) + require.Error(t, err) + require.Contains(t, err.Error(), "OnRampAddress cannot be zero address") + }) +}