diff --git a/chains/evm/deployment/v2_0_0/adapters/tokens.go b/chains/evm/deployment/v2_0_0/adapters/tokens.go index 74c1b33ba..83aa07c93 100644 --- a/chains/evm/deployment/v2_0_0/adapters/tokens.go +++ b/chains/evm/deployment/v2_0_0/adapters/tokens.go @@ -30,9 +30,10 @@ import ( ) var ( - _ tokens.TokenPoolMigrator = &TokenAdapter{} - _ tokens.TokenFeeAdapter = &TokenAdapter{} - _ tokens.TokenAdapter = &TokenAdapter{} + _ tokens.TokenPoolMigrator = &TokenAdapter{} + _ tokens.TokenFeeAdapter = &TokenAdapter{} + _ tokens.TokenAdapter = &TokenAdapter{} + _ tokens.TokenPoolAdminAdapter = &TokenAdapter{} ) // TokenAdapter handles EVM token pools at version 2.0.0. @@ -182,6 +183,10 @@ func (t *TokenAdapter) SetTokenTransferFee(e *deployment.Environment) *cldf_ops. return evm_tokens.SetTokenTransferFeeConfigForTokenPools } +func (t *TokenAdapter) SetTokenPoolAdmins() *cldf_ops.Sequence[tokens.SetTokenPoolAdminsSequenceInput, sequences.OnChainOutput, chain.BlockChains] { + return evm_tokens.SetTokenPoolAdmins +} + func (t *TokenAdapter) GetDefaultTokenTransferFeeConfig(src uint64, dst uint64) tokens.TokenTransferFeeConfig { return tokens.GetDefaultChainAgnosticTokenTransferFeeConfig(src, dst) } diff --git a/chains/evm/deployment/v2_0_0/sequences/tokens/set_token_pool_admins.go b/chains/evm/deployment/v2_0_0/sequences/tokens/set_token_pool_admins.go new file mode 100644 index 000000000..95154e8b2 --- /dev/null +++ b/chains/evm/deployment/v2_0_0/sequences/tokens/set_token_pool_admins.go @@ -0,0 +1,83 @@ +package tokens + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/common" + cldf_chain "github.com/smartcontractkit/chainlink-deployments-framework/chain" + "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm" + "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm/operations/contract" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + mcms_types "github.com/smartcontractkit/mcms/types" + + "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/token_pool" + "github.com/smartcontractkit/chainlink-ccip/deployment/tokens" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils/sequences" +) + +var SetTokenPoolAdmins = operations.NewSequence( + "set-token-pool-admins", + utils.Version_2_0_0, + "Updates the rate limit admin and/or fee admin on a v2 token pool; no-op when values already match", + func(b operations.Bundle, chains cldf_chain.BlockChains, input tokens.SetTokenPoolAdminsSequenceInput) (sequences.OnChainOutput, error) { + chain, ok := chains.EVMChains()[input.Selector] + if !ok { + return sequences.OnChainOutput{}, fmt.Errorf("chain with selector %d not defined", input.Selector) + } + if !common.IsHexAddress(input.PoolAddress) { + return sequences.OnChainOutput{}, fmt.Errorf("invalid pool address for chain %d: %s", input.Selector, input.PoolAddress) + } + poolAddr := common.HexToAddress(input.PoolAddress) + + // Force-execute so a repeated apply on the same bundle sees fresh on-chain state + // instead of a cached pre-write read (the no-op guarantee depends on this). + currentReport, err := operations.ExecuteOperation(b, token_pool.GetDynamicConfig, chain, contract.FunctionInput[struct{}]{ + ChainSelector: input.Selector, + Address: poolAddr, + }, operations.WithForceExecute[contract.FunctionInput[struct{}], evm.Chain]()) + if err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to get dynamic config from token pool %s on chain %d: %w", poolAddr.Hex(), input.Selector, err) + } + current := currentReport.Output + + desiredRateLimitAdmin := current.RateLimitAdmin + if input.RateLimitAdmin != nil { + if !common.IsHexAddress(*input.RateLimitAdmin) { + return sequences.OnChainOutput{}, fmt.Errorf("invalid rate limit admin address for chain %d: %s", input.Selector, *input.RateLimitAdmin) + } + desiredRateLimitAdmin = common.HexToAddress(*input.RateLimitAdmin) + } + desiredFeeAdmin := current.FeeAdmin + if input.FeeAdmin != nil { + if !common.IsHexAddress(*input.FeeAdmin) { + return sequences.OnChainOutput{}, fmt.Errorf("invalid fee admin address for chain %d: %s", input.Selector, *input.FeeAdmin) + } + desiredFeeAdmin = common.HexToAddress(*input.FeeAdmin) + } + + if desiredRateLimitAdmin == current.RateLimitAdmin && desiredFeeAdmin == current.FeeAdmin { + b.Logger.Infof("Token pool admins already match desired values for pool %s on chain %d; skipping", poolAddr.Hex(), input.Selector) + return sequences.OnChainOutput{}, nil + } + + write, err := operations.ExecuteOperation(b, token_pool.SetDynamicConfig, chain, contract.FunctionInput[token_pool.SetDynamicConfigArgs]{ + ChainSelector: input.Selector, + Address: poolAddr, + Args: token_pool.SetDynamicConfigArgs{ + Router: current.Router, + RateLimitAdmin: desiredRateLimitAdmin, + FeeAdmin: desiredFeeAdmin, + }, + }) + if err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to set dynamic config on token pool %s on chain %d: %w", poolAddr.Hex(), input.Selector, err) + } + + batch, err := contract.NewBatchOperationFromWrites([]contract.WriteOutput{write.Output}) + if err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to create batch operation from writes: %w", err) + } + return sequences.OnChainOutput{BatchOps: []mcms_types.BatchOperation{batch}}, nil + }, +) diff --git a/deployment/tokens/configure_token_pool.go b/deployment/tokens/configure_token_pool.go new file mode 100644 index 000000000..001b615b1 --- /dev/null +++ b/deployment/tokens/configure_token_pool.go @@ -0,0 +1,391 @@ +package tokens + +import ( + "errors" + "fmt" + + chain_selectors "github.com/smartcontractkit/chain-selectors" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + mcms_types "github.com/smartcontractkit/mcms/types" + + "github.com/smartcontractkit/chainlink-ccip/deployment/finality" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils/changesets" + datastore_utils "github.com/smartcontractkit/chainlink-ccip/deployment/utils/datastore" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils/mcms" +) + +// ConfigureTokenPoolInput is the input for the ConfigureTokenPool changeset. It applies small, +// targeted configuration changes to existing token pools. Unlike TokenExpansion and +// SetTokenPoolRateLimits, it has no bidirectionality constraints: each entry configures one +// pool's local view of a lane and never requires a counterpart section. Counterpart entries +// MAY still be provided (to configure both sides in one changeset); no symmetry is enforced. +type ConfigureTokenPoolInput struct { + // Chains lists the per-chain pool configuration updates. + Chains []ConfigureTokenPoolPerChain `yaml:"input" json:"input"` + // MCMS configures the resulting proposal. + MCMS mcms.Input `yaml:"mcms,omitempty" json:"mcms"` +} + +// ConfigureTokenPoolPerChain groups pool updates for a single chain. +type ConfigureTokenPoolPerChain struct { + // ChainSelector identifies the chain on which the pools live. + ChainSelector uint64 `yaml:"selector,string" json:"selector,string"` + // Pools lists the pool configuration updates on this chain. + Pools []PoolConfigUpdate `yaml:"pools" json:"pools"` +} + +// PoolConfigUpdate describes a partial configuration update for a single token pool. +// Every field other than TokenPoolRef is optional: absent fields leave on-chain state +// untouched. To clear a value, provide it explicitly (e.g. the zero address). +type PoolConfigUpdate struct { + // TokenPoolRef is a reference to the token pool in the datastore. + TokenPoolRef datastore.AddressRef `yaml:"tokenPoolRef" json:"tokenPoolRef"` + // FinalityConfig, if set, is the allowed finality config to set on the pool (v2+ only). + FinalityConfig *finality.Config `yaml:"finalityConfig,omitempty" json:"finalityConfig,omitempty"` + // RateLimitAdmin, if set, is the desired rate limit admin address. + RateLimitAdmin *string `yaml:"rateLimitAdmin,omitempty" json:"rateLimitAdmin,omitempty"` + // FeeAdmin, if set, is the desired fee admin address (v2+ only). + FeeAdmin *string `yaml:"feeAdmin,omitempty" json:"feeAdmin,omitempty"` + // Remotes lists per-lane configuration updates. + Remotes []RemoteConfigUpdate `yaml:"remotes,omitempty" json:"remotes,omitempty"` +} + +// RemoteConfigUpdate describes partial per-lane configuration for one remote chain. +type RemoteConfigUpdate struct { + // RemoteChainSelector identifies the remote chain of the lane. + RemoteChainSelector uint64 `yaml:"selector,string" json:"selector,string"` + // TokenTransferFeeConfig, if set, is merged with the current on-chain fee config + // (user-set fields win; unset fields keep their on-chain values). + TokenTransferFeeConfig *PartialTokenTransferFeeConfig `yaml:"tokenTransferFeeConfig,omitempty" json:"tokenTransferFeeConfig,omitempty"` + // RateLimits lists the rate limit buckets to set on this lane (at most one per + // fastFinality flag). Values are human-readable token amounts; inbound gets +10%. + RateLimits []RateLimitBucketInput `yaml:"rateLimits,omitempty" json:"rateLimits,omitempty"` +} + +// RateLimitBucketInput is one outbound/inbound rate limit pair for a finality bucket. +type RateLimitBucketInput struct { + FastFinality bool `yaml:"fastFinality" json:"fastFinality"` + Outbound RateLimiterConfigFloatInput `yaml:"outbound" json:"outbound"` + Inbound RateLimiterConfigFloatInput `yaml:"inbound" json:"inbound"` +} + +// rateLimitBuckets converts the bucket list into two RemoteOutbounds (one per direction) +// so the structural validation rules from SetTokenPoolRateLimits can be reused verbatim. +func (r RemoteConfigUpdate) rateLimitBuckets() (outbounds, inbounds RemoteOutbounds) { + for _, b := range r.RateLimits { + outbounds.Outbounds = append(outbounds.Outbounds, RateLimitConfig{RateLimit: b.Outbound, FastFinality: b.FastFinality}) + inbounds.Outbounds = append(inbounds.Outbounds, RateLimitConfig{RateLimit: b.Inbound, FastFinality: b.FastFinality}) + } + return outbounds, inbounds +} + +// isEmpty reports whether the pool entry has nothing to apply. Empty entries are rejected in +// verify as probable YAML mistakes (e.g. indentation errors silently dropping fields). +func (p PoolConfigUpdate) isEmpty() bool { + return p.FinalityConfig == nil && p.RateLimitAdmin == nil && p.FeeAdmin == nil && len(p.Remotes) == 0 +} + +// ConfigureTokenPool returns a changeset that applies partial configuration updates to +// existing token pools. +func ConfigureTokenPool() cldf.ChangeSetV2[ConfigureTokenPoolInput] { + return cldf.CreateChangeSet(configureTokenPoolApply(), configureTokenPoolVerify()) +} + +func configureTokenPoolVerify() func(cldf.Environment, ConfigureTokenPoolInput) error { + return func(e cldf.Environment, cfg ConfigureTokenPoolInput) error { + if len(cfg.Chains) == 0 { + return errors.New("input must contain at least one chain entry") + } + registry := GetTokenAdapterRegistry() + + // First pass: purely structural checks (no datastore/on-chain resolution). Obvious + // input mistakes — bad selectors, empty updates, duplicate entries, malformed rate + // limits — must surface before any resolution error from a later pool would mask them. + seenPools := make(map[string]struct{}) + for _, chainCfg := range cfg.Chains { + if _, err := chain_selectors.GetSelectorFamily(chainCfg.ChainSelector); err != nil { + return fmt.Errorf("invalid chain selector %d: %w", chainCfg.ChainSelector, err) + } + if len(chainCfg.Pools) == 0 { + return fmt.Errorf("no pools provided for chain selector %d", chainCfg.ChainSelector) + } + for _, pool := range chainCfg.Pools { + if err := validatePoolConfigUpdate(chainCfg.ChainSelector, pool); err != nil { + return err + } + key := fmt.Sprintf("%d|%s", chainCfg.ChainSelector, datastore_utils.SprintRef(pool.TokenPoolRef)) + if _, dup := seenPools[key]; dup { + return fmt.Errorf("duplicate pool entry for chain selector %d and ref %s", chainCfg.ChainSelector, datastore_utils.SprintRef(pool.TokenPoolRef)) + } + seenPools[key] = struct{}{} + } + } + + // Second pass: resolution + version gating. The gate runs BEFORE adapter resolution so a + // pre-v2 pool yields the scope error even when no pre-v2 adapter is registered. + for _, chainCfg := range cfg.Chains { + for _, pool := range chainCfg.Pools { + fullPoolRef, err := ResolveTokenPoolRef(e, registry, chainCfg.ChainSelector, pool.TokenPoolRef) + if err != nil { + return fmt.Errorf("chain selector %d: failed to resolve token pool ref: %w", chainCfg.ChainSelector, err) + } + // PR#1 scope: EVM v2.0.0+ pools only. PR#2 adds EVM 1.5.x/1.6.x, PR#3 adds Solana. + if fullPoolRef.Version != nil && fullPoolRef.Version.LessThan(utils.Version_2_0_0) { + return fmt.Errorf( + "pool %s on chain selector %d has version %s: only v2.0.0+ pools are currently supported by this changeset", + fullPoolRef.Address, chainCfg.ChainSelector, fullPoolRef.Version, + ) + } + // A nil version falls through to ResolveAdapter, which reports it clearly. + if _, _, err := ResolveAdapter(registry, chainCfg.ChainSelector, fullPoolRef.Version); err != nil { + return fmt.Errorf("chain selector %d: %w", chainCfg.ChainSelector, err) + } + } + } + return nil + } +} + +// validatePoolConfigUpdate performs structural (non-resolving) validation of one pool entry. +// Address-format validation for admin roles is chain-specific and handled by the EVM +// SetTokenPoolAdmins sequence, keeping this top-level check chain-agnostic. +func validatePoolConfigUpdate(chainSelector uint64, pool PoolConfigUpdate) error { + if pool.isEmpty() { + return fmt.Errorf("pool entry %s on chain selector %d has no fields to update", datastore_utils.SprintRef(pool.TokenPoolRef), chainSelector) + } + if pool.FinalityConfig != nil { + if err := pool.FinalityConfig.Validate(); err != nil { + return fmt.Errorf("finality config for pool %s on chain selector %d: %w", datastore_utils.SprintRef(pool.TokenPoolRef), chainSelector, err) + } + } + seenRemotes := make(map[uint64]struct{}) + for _, remote := range pool.Remotes { + if remote.RemoteChainSelector == chainSelector { + return fmt.Errorf("remote chain selector %d must not equal the pool's own chain selector", remote.RemoteChainSelector) + } + if _, err := chain_selectors.GetSelectorFamily(remote.RemoteChainSelector); err != nil { + return fmt.Errorf("invalid remote chain selector %d: %w", remote.RemoteChainSelector, err) + } + if _, dup := seenRemotes[remote.RemoteChainSelector]; dup { + return fmt.Errorf("duplicate remote chain selector %d for pool on chain selector %d", remote.RemoteChainSelector, chainSelector) + } + seenRemotes[remote.RemoteChainSelector] = struct{}{} + if remote.TokenTransferFeeConfig == nil && len(remote.RateLimits) == 0 { + return fmt.Errorf("remote entry %d for pool on chain selector %d has nothing to update", remote.RemoteChainSelector, chainSelector) + } + if remote.TokenTransferFeeConfig != nil { + if v, ok := remote.TokenTransferFeeConfig.DestBytesOverhead.Get(); ok && v < 32 { + return fmt.Errorf("destBytesOverhead must be at least 32 for remote %d on chain selector %d, got %d", remote.RemoteChainSelector, chainSelector, v) + } + } + outbounds, inbounds := remote.rateLimitBuckets() + if err := outbounds.Validate(); err != nil { + return fmt.Errorf("outbound rate limits for remote %d on chain selector %d: %w", remote.RemoteChainSelector, chainSelector, err) + } + if err := inbounds.Validate(); err != nil { + return fmt.Errorf("inbound rate limits for remote %d on chain selector %d: %w", remote.RemoteChainSelector, chainSelector, err) + } + } + return nil +} + +func configureTokenPoolApply() func(cldf.Environment, ConfigureTokenPoolInput) (cldf.ChangesetOutput, error) { + return func(e cldf.Environment, cfg ConfigureTokenPoolInput) (cldf.ChangesetOutput, error) { + batchOps := make([]mcms_types.BatchOperation, 0) + reports := make([]cldf_ops.Report[any, any], 0) + registry := GetTokenAdapterRegistry() + mcmsRegistry := changesets.GetRegistry() + + for _, chainCfg := range cfg.Chains { + for _, pool := range chainCfg.Pools { + poolBatchOps, poolReports, err := applyPoolConfigUpdate(e, registry, chainCfg.ChainSelector, pool) + if err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("failed to configure pool %s on chain selector %d: %w", datastore_utils.SprintRef(pool.TokenPoolRef), chainCfg.ChainSelector, err) + } + batchOps = append(batchOps, poolBatchOps...) + reports = append(reports, poolReports...) + } + } + + return changesets.NewOutputBuilder(e, mcmsRegistry). + WithReports(reports). + WithBatchOps(batchOps). + Build(cfg.MCMS) + } +} + +// applyPoolConfigUpdate applies one pool entry: admin/finality updates first, then +// per-remote configs in input order, so proposal contents are deterministic across runs. +// Each step reads current on-chain state and emits no operations when it already matches. +func applyPoolConfigUpdate( + e cldf.Environment, + registry *TokenAdapterRegistry, + selector uint64, + pool PoolConfigUpdate, +) ([]mcms_types.BatchOperation, []cldf_ops.Report[any, any], error) { + adapter, family, fullPoolRef, fullTokenRef, err := ResolveAdapterAndRefs(e, registry, selector, pool.TokenPoolRef, datastore.AddressRef{}) + if err != nil { + return nil, nil, err + } + batchOps := make([]mcms_types.BatchOperation, 0) + reports := make([]cldf_ops.Report[any, any], 0) + + if pool.FinalityConfig != nil { + feeAdapter, ok := adapter.(TokenFeeAdapter) + if !ok { + return nil, nil, fmt.Errorf( + "adapter for chain selector %d (family %s, version %s) does not support finality config updates", + selector, family, fullPoolRef.Version, + ) + } + // The sequence reads the current on-chain finality config and emits no writes + // when it already matches the desired value. + report, err := cldf_ops.ExecuteSequence(e.OperationsBundle, feeAdapter.SetAllowedFinalityConfig(&e), e.BlockChains, SetAllowedFinalityConfigSequenceInput{ + Selector: selector, + Settings: map[string]finality.Config{fullPoolRef.Address: *pool.FinalityConfig}, + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to set finality config on pool %s: %w", fullPoolRef.Address, err) + } + batchOps = append(batchOps, report.Output.BatchOps...) + reports = append(reports, report.ExecutionReports...) + } + + if pool.RateLimitAdmin != nil || pool.FeeAdmin != nil { + adminAdapter, ok := adapter.(TokenPoolAdminAdapter) + if !ok { + return nil, nil, fmt.Errorf( + "adapter for chain selector %d (family %s, version %s) does not support admin role updates", + selector, family, fullPoolRef.Version, + ) + } + report, err := cldf_ops.ExecuteSequence(e.OperationsBundle, adminAdapter.SetTokenPoolAdmins(), e.BlockChains, SetTokenPoolAdminsSequenceInput{ + Selector: selector, + PoolAddress: fullPoolRef.Address, + RateLimitAdmin: pool.RateLimitAdmin, + FeeAdmin: pool.FeeAdmin, + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to set admin roles on pool %s: %w", fullPoolRef.Address, err) + } + batchOps = append(batchOps, report.Output.BatchOps...) + reports = append(reports, report.ExecutionReports...) + } + + // Resolve token decimals once (only when any remote needs rate-limit scaling). + var localDecimals uint8 + for _, remote := range pool.Remotes { + if len(remote.RateLimits) > 0 { + tokenBytes, err := adapter.AddressRefToBytes(fullTokenRef) + if err != nil { + return nil, nil, fmt.Errorf("failed to convert token ref to bytes on chain selector %d: %w", selector, err) + } + localDecimals, err = adapter.DeriveTokenDecimals(e, selector, fullPoolRef, tokenBytes) + if err != nil { + return nil, nil, fmt.Errorf("failed to get token decimals on chain selector %d: %w", selector, err) + } + break + } + } + + for _, remote := range pool.Remotes { + if remote.TokenTransferFeeConfig != nil { + feeBatchOps, feeReports, err := applyTokenTransferFeeConfig(e, selector, remote.RemoteChainSelector, fullPoolRef, fullTokenRef, *remote.TokenTransferFeeConfig) + if err != nil { + return nil, nil, fmt.Errorf("failed to apply fee config for remote chain selector %d: %w", remote.RemoteChainSelector, err) + } + batchOps = append(batchOps, feeBatchOps...) + reports = append(reports, feeReports...) + } + if len(remote.RateLimits) > 0 { + rlBatchOps, rlReports, err := applyRateLimitsForRemote(e, adapter, family, selector, remote.RemoteChainSelector, fullPoolRef, fullTokenRef, localDecimals, remote.RateLimits) + if err != nil { + return nil, nil, fmt.Errorf("failed to apply rate limits for remote chain selector %d: %w", remote.RemoteChainSelector, err) + } + batchOps = append(batchOps, rlBatchOps...) + reports = append(reports, rlReports...) + } + } + + return batchOps, reports, nil +} + +// applyRateLimitsForRemote scales the user's per-bucket floats (identical to +// SetTokenPoolRateLimits: outbound by local decimals, inbound by local decimals with the +// +10% bump for v2 pools), drops buckets whose on-chain state already matches, and applies +// the rest through the adapter's SetTokenPoolRateLimits sequence. No counterpart chain is +// read or validated: this changeset configures exactly what the user wrote. +// +// NOTE (PR#2): pre-1.6.1 EVM pools scale inbound values by REMOTE token decimals +// (DoesPoolUseLocalDecimals == false). Supporting them requires resolving the remote token's +// decimals, which this purely-local changeset does not do yet. Verify currently gates on +// version >= 2.0.0, for which local decimals are always correct. +func applyRateLimitsForRemote( + e cldf.Environment, + adapter TokenAdapter, + family string, + selector, remoteSelector uint64, + fullPoolRef, fullTokenRef datastore.AddressRef, + localDecimals uint8, + bucketInputs []RateLimitBucketInput, +) ([]mcms_types.BatchOperation, []cldf_ops.Report[any, any], error) { + reader, ok := adapter.(RateLimitReaderAdapter) + if !ok { + return nil, nil, fmt.Errorf( + "adapter for chain selector %d (family %s, version %s) does not implement RateLimitReaderAdapter; cannot perform idempotent rate limit updates", + selector, family, fullPoolRef.Version, + ) + } + + buckets := make([]TPRLRateLimitBucket, 0, len(bucketInputs)) + for _, in := range bucketInputs { + outbound, inbound := GenerateTPRLConfigs( + in.Outbound, in.Inbound, localDecimals, localDecimals, + family, fullPoolRef.Version, fullPoolRef.Type.String(), + ) + current, err := reader.GetOnchainRateLimits( + e.OperationsBundle, e.BlockChains, e.DataStore, selector, fullPoolRef, fullTokenRef, remoteSelector, in.FastFinality, + ) + if err != nil { + return nil, nil, fmt.Errorf("failed to read on-chain rate limits (chain %d, remote %d, fastFinality=%t): %w", selector, remoteSelector, in.FastFinality, err) + } + if current.Outbound.Equal(outbound) && current.Inbound.Equal(inbound) { + e.Logger.Infof("Skipping rate limit bucket (fastFinality=%t) for chain %d remote %d since the desired config is the same as the current on-chain config", in.FastFinality, selector, remoteSelector) + continue + } + buckets = append(buckets, TPRLRateLimitBucket{ + FastFinality: in.FastFinality, + OutboundRateLimiterConfig: outbound, + InboundRateLimiterConfig: inbound, + }) + } + if len(buckets) == 0 { + return nil, nil, nil + } + + tprl := TPRLRemotes{ + ChainSelector: selector, + RemoteChainSelector: remoteSelector, + TokenRef: fullTokenRef, + TokenPoolRef: fullPoolRef, + ExistingDataStore: e.DataStore, + RateLimitBuckets: buckets, + } + // Pre-v2 adapters consume the default-bucket scalars rather than RateLimitBuckets; + // populate them for forward compatibility with PR#2. + for _, b := range buckets { + if !b.FastFinality { + tprl.OutboundRateLimiterConfig = b.OutboundRateLimiterConfig + tprl.InboundRateLimiterConfig = b.InboundRateLimiterConfig + } + } + + report, err := cldf_ops.ExecuteSequence(e.OperationsBundle, adapter.SetTokenPoolRateLimits(), e.BlockChains, tprl) + if err != nil { + return nil, nil, fmt.Errorf("failed to set rate limits on pool %s for remote chain %d: %w", fullPoolRef.Address, remoteSelector, err) + } + return report.Output.BatchOps, report.ExecutionReports, nil +} diff --git a/deployment/tokens/product.go b/deployment/tokens/product.go index 8a3aaba9a..1b8d1899d 100644 --- a/deployment/tokens/product.go +++ b/deployment/tokens/product.go @@ -35,6 +35,26 @@ type TokenFeeAdapter interface { GetDefaultTokenTransferFeeConfig(src uint64, dst uint64) TokenTransferFeeConfig } +// TokenPoolAdminAdapter is an optional interface for adapters that support updating a token +// pool's admin roles. Implementations must read the current on-chain values and emit no +// writes when the desired values already match (idempotent apply). +type TokenPoolAdminAdapter interface { + SetTokenPoolAdmins() *cldf_ops.Sequence[SetTokenPoolAdminsSequenceInput, sequences.OnChainOutput, cldf_chain.BlockChains] +} + +// SetTokenPoolAdminsSequenceInput defines the input for updating a token pool's admin roles. +// Nil fields are left unchanged on-chain. +type SetTokenPoolAdminsSequenceInput struct { + // Selector is the chain selector for the chain on which the pool lives. + Selector uint64 `json:"selector" yaml:"selector"` + // PoolAddress is the token pool address (family-specific string form). + PoolAddress string `json:"poolAddress" yaml:"poolAddress"` + // RateLimitAdmin, if non-nil, is the desired rate limit admin. + RateLimitAdmin *string `json:"rateLimitAdmin,omitempty" yaml:"rateLimitAdmin,omitempty"` + // FeeAdmin, if non-nil, is the desired fee admin. + FeeAdmin *string `json:"feeAdmin,omitempty" yaml:"feeAdmin,omitempty"` +} + // TokenAdminRoleAdapter is an optional interface for chain families that support token admin role management. type TokenAdminRoleAdapter interface { RevokeTokenAdminRole() *cldf_ops.Sequence[RevokeTokenAdminRoleSequenceInput, sequences.OnChainOutput, cldf_chain.BlockChains] @@ -156,6 +176,29 @@ type RateLimiterConfig struct { Rate *big.Int } +// Equal compares two RateLimiterConfigs treating nil big.Ints as zero. +func (a RateLimiterConfig) Equal(b RateLimiterConfig) bool { + if a.IsEnabled != b.IsEnabled { + return false + } + + zero, aCap, bCap, aRate, bRate := big.NewInt(0), a.Capacity, b.Capacity, a.Rate, b.Rate + if aCap == nil { + aCap = zero + } + if bCap == nil { + bCap = zero + } + if aRate == nil { + aRate = zero + } + if bRate == nil { + bRate = zero + } + + return aCap.Cmp(bCap) == 0 && aRate.Cmp(bRate) == 0 +} + // OnchainRateLimits is the outbound/inbound rate limiter pair stored on-chain for a remote lane. type OnchainRateLimits struct { Outbound RateLimiterConfig diff --git a/integration-tests/deployment/configure_token_pool_test.go b/integration-tests/deployment/configure_token_pool_test.go new file mode 100644 index 000000000..43a750cf5 --- /dev/null +++ b/integration-tests/deployment/configure_token_pool_test.go @@ -0,0 +1,794 @@ +package deployment + +import ( + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + chainsel "github.com/smartcontractkit/chain-selectors" + "github.com/stretchr/testify/require" + // sigs.k8s.io/yaml decodes YAML by converting to JSON and using encoding/json, which + // honors the json `,string` selector tags. This mirrors how the migrations framework + // ultimately populates changeset inputs (YAML node -> JSON -> struct). Plain gopkg.in/yaml.v3 + // panics on the style-guide-mandated `,string` tag, so it cannot be used here. + "sigs.k8s.io/yaml" + + evm_datastore_utils "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/datastore" + evmadapters "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/adapters" + bnmERC20ops "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/operations/burn_mint_erc20" + bnmOpsV2_0_0 "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/operations/burn_mint_token_pool" + evm_testsetup "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/testsetup" + tokenpoolV2_0_0 "github.com/smartcontractkit/chainlink-ccip/chains/evm/gobindings/generated/v2_0_0/token_pool" + deployapi "github.com/smartcontractkit/chainlink-ccip/deployment/deploy" + "github.com/smartcontractkit/chainlink-ccip/deployment/finality" + "github.com/smartcontractkit/chainlink-ccip/deployment/testhelpers" + tokensapi "github.com/smartcontractkit/chainlink-ccip/deployment/tokens" + cciputils "github.com/smartcontractkit/chainlink-ccip/deployment/utils" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils/changesets" + datastore_utils "github.com/smartcontractkit/chainlink-ccip/deployment/utils/datastore" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils/mcms" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf_deployment "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" + + _ "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v2_0_0/adapters" +) + +func TestConfigureTokenPool_VerifyPreconditions(t *testing.T) { + sel := chainsel.TEST_90000001.Selector + dst := chainsel.TEST_90000002.Selector + env, err := environment.New(t.Context(), environment.WithEVMSimulated(t, []uint64{sel})) + require.NoError(t, err) + + cs := tokensapi.ConfigureTokenPool() + poolRef := datastore.AddressRef{Address: "0x1111111111111111111111111111111111111111"} + enabled := tokensapi.RateLimiterConfigFloatInput{IsEnabled: true, Capacity: 1000, Rate: 100} + + singlePoolInput := func(pool tokensapi.PoolConfigUpdate) tokensapi.ConfigureTokenPoolInput { + return tokensapi.ConfigureTokenPoolInput{ + MCMS: mcms.Input{}, + Chains: []tokensapi.ConfigureTokenPoolPerChain{ + {ChainSelector: sel, Pools: []tokensapi.PoolConfigUpdate{pool}}, + }, + } + } + + cases := []struct { + name string + input tokensapi.ConfigureTokenPoolInput + errors []string + }{ + { + name: "rejects_empty_input", + input: tokensapi.ConfigureTokenPoolInput{MCMS: mcms.Input{}}, + errors: []string{"at least one chain entry"}, + }, + { + name: "rejects_chain_entry_with_no_pools", + input: tokensapi.ConfigureTokenPoolInput{ + MCMS: mcms.Input{}, + Chains: []tokensapi.ConfigureTokenPoolPerChain{{ChainSelector: sel}}, + }, + errors: []string{"no pools provided"}, + }, + { + name: "rejects_empty_pool_update", + input: singlePoolInput(tokensapi.PoolConfigUpdate{TokenPoolRef: poolRef}), + errors: []string{"no fields to update"}, + }, + { + name: "rejects_empty_remote_update", + input: singlePoolInput(tokensapi.PoolConfigUpdate{ + TokenPoolRef: poolRef, + Remotes: []tokensapi.RemoteConfigUpdate{{RemoteChainSelector: dst}}, + }), + errors: []string{"nothing to update"}, + }, + { + name: "rejects_remote_equal_to_local_chain", + input: singlePoolInput(tokensapi.PoolConfigUpdate{ + TokenPoolRef: poolRef, + Remotes: []tokensapi.RemoteConfigUpdate{{ + RemoteChainSelector: sel, + RateLimits: []tokensapi.RateLimitBucketInput{{Outbound: enabled, Inbound: enabled}}, + }}, + }), + errors: []string{"must not equal the pool's own chain selector"}, + }, + { + name: "rejects_duplicate_remote_selectors", + input: singlePoolInput(tokensapi.PoolConfigUpdate{ + TokenPoolRef: poolRef, + Remotes: []tokensapi.RemoteConfigUpdate{ + {RemoteChainSelector: dst, RateLimits: []tokensapi.RateLimitBucketInput{{Outbound: enabled, Inbound: enabled}}}, + {RemoteChainSelector: dst, RateLimits: []tokensapi.RateLimitBucketInput{{Outbound: enabled, Inbound: enabled}}}, + }, + }), + errors: []string{"duplicate remote chain selector"}, + }, + { + name: "rejects_duplicate_pool_entries", + input: tokensapi.ConfigureTokenPoolInput{ + MCMS: mcms.Input{}, + Chains: []tokensapi.ConfigureTokenPoolPerChain{{ + ChainSelector: sel, + Pools: []tokensapi.PoolConfigUpdate{ + {TokenPoolRef: poolRef, RateLimitAdmin: ptrTo("0x2222222222222222222222222222222222222222")}, + {TokenPoolRef: poolRef, FeeAdmin: ptrTo("0x2222222222222222222222222222222222222222")}, + }, + }}, + }, + errors: []string{"duplicate pool entry"}, + }, + { + name: "rejects_three_rate_limit_buckets", + input: singlePoolInput(tokensapi.PoolConfigUpdate{ + TokenPoolRef: poolRef, + Remotes: []tokensapi.RemoteConfigUpdate{{ + RemoteChainSelector: dst, + RateLimits: []tokensapi.RateLimitBucketInput{ + {FastFinality: false, Outbound: enabled, Inbound: enabled}, + {FastFinality: true, Outbound: enabled, Inbound: enabled}, + {FastFinality: false, Outbound: enabled, Inbound: enabled}, + }, + }}, + }), + errors: []string{"at most two rate limit buckets allowed"}, + }, + { + name: "rejects_duplicate_default_buckets", + input: singlePoolInput(tokensapi.PoolConfigUpdate{ + TokenPoolRef: poolRef, + Remotes: []tokensapi.RemoteConfigUpdate{{ + RemoteChainSelector: dst, + RateLimits: []tokensapi.RateLimitBucketInput{ + {FastFinality: false, Outbound: enabled, Inbound: enabled}, + {FastFinality: false, Outbound: enabled, Inbound: enabled}, + }, + }}, + }), + errors: []string{"multiple rate limit buckets with fastFinality=false"}, + }, + { + name: "rejects_rate_greater_than_capacity", + input: singlePoolInput(tokensapi.PoolConfigUpdate{ + TokenPoolRef: poolRef, + Remotes: []tokensapi.RemoteConfigUpdate{{ + RemoteChainSelector: dst, + RateLimits: []tokensapi.RateLimitBucketInput{{ + Outbound: tokensapi.RateLimiterConfigFloatInput{IsEnabled: true, Capacity: 10, Rate: 100}, + Inbound: enabled, + }}, + }}, + }), + errors: []string{"rate greater than capacity"}, + }, + { + name: "rejects_disabled_with_nonzero_values", + input: singlePoolInput(tokensapi.PoolConfigUpdate{ + TokenPoolRef: poolRef, + Remotes: []tokensapi.RemoteConfigUpdate{{ + RemoteChainSelector: dst, + RateLimits: []tokensapi.RateLimitBucketInput{{ + Outbound: enabled, + Inbound: tokensapi.RateLimiterConfigFloatInput{IsEnabled: false, Capacity: 5}, + }}, + }}, + }), + errors: []string{"disabled but capacity or rate is non-zero"}, + }, + { + name: "rejects_zero_finality_config", + input: singlePoolInput(tokensapi.PoolConfigUpdate{ + TokenPoolRef: poolRef, + FinalityConfig: &finality.Config{}, + }), + errors: []string{"finality config is empty"}, + }, + { + name: "rejects_low_dest_bytes_overhead", + input: singlePoolInput(tokensapi.PoolConfigUpdate{ + TokenPoolRef: poolRef, + Remotes: []tokensapi.RemoteConfigUpdate{{ + RemoteChainSelector: dst, + TokenTransferFeeConfig: feeCfgWithDestBytesOverhead(16), + }}, + }), + errors: []string{"destBytesOverhead must be at least 32"}, + }, + { + name: "rejects_unresolvable_pool_ref", + input: singlePoolInput(tokensapi.PoolConfigUpdate{ + TokenPoolRef: poolRef, + RateLimitAdmin: ptrTo("0x2222222222222222222222222222222222222222"), + }), + errors: []string{"failed to resolve token pool ref"}, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + err := cs.VerifyPreconditions(*env, tt.input) + require.Error(t, err) + for _, substr := range tt.errors { + require.Contains(t, err.Error(), substr) + } + }) + } +} + +func ptrTo[T any](v T) *T { return &v } + +func feeCfgWithDestBytesOverhead(v uint32) *tokensapi.PartialTokenTransferFeeConfig { + return &tokensapi.PartialTokenTransferFeeConfig{DestBytesOverhead: cciputils.NewOptional(v)} +} + +type configureTestEnv struct { + env *cldf_deployment.Environment + selA, selB uint64 + poolA, poolB common.Address + clientA bind.ContractBackend + clientB bind.ContractBackend + tokenSymb string + decimalsA uint8 + decimalsB uint8 +} + +// setupV2PoolsForConfigure deploys v2.0.0 chain contracts plus a connected v2 burn-mint +// token/pool pair on two simulated chains, owned by the deployer key (no MCMS). +func setupV2PoolsForConfigure(t *testing.T, tokenSymb string) configureTestEnv { + return setupV2PoolsForConfigureImpl(t, tokenSymb, false) +} + +// setupV2PoolsForConfigureMCMS is like setupV2PoolsForConfigure but deploys MCMS/timelock and +// transfers pool ownership to the timelock, so ConfigureTokenPool produces MCMS proposals. +func setupV2PoolsForConfigureMCMS(t *testing.T, tokenSymb string) configureTestEnv { + return setupV2PoolsForConfigureImpl(t, tokenSymb, true) +} + +func setupV2PoolsForConfigureImpl(t *testing.T, tokenSymb string, useMCMS bool) configureTestEnv { + t.Helper() + const decimalsA = 18 + const decimalsB = 6 + + selA := chainsel.TEST_90000001.Selector + selB := chainsel.TEST_90000002.Selector + e, err := environment.New(t.Context(), environment.WithEVMSimulated(t, []uint64{selA, selB})) + require.NoError(t, err) + + cumulative := datastore.NewMemoryDataStore() + DeployChainContractsV2_0_0(t, e, cumulative, selA) + DeployChainContractsV2_0_0(t, e, cumulative, selB) + e.DataStore = cumulative.Seal() + + expansionMCMS := mcms.Input{} + if useMCMS { + // Register the EVM MCMS deployer/reader (idempotent) and deploy MCMS + timelock so the + // pool can be timelock-owned and ConfigureTokenPool can emit timelock proposals. + deployapi.GetRegistry().RegisterDeployer(chainsel.FamilyEVM, deployapi.MCMSVersion, &evmadapters.EVMDeployer{}) + changesets.GetRegistry().RegisterMCMSReader(chainsel.FamilyEVM, &evmadapters.EVMMCMSReader{}) + for _, sel := range []uint64{selA, selB} { + DeployMCMS(t, e, sel, []string{cciputils.CLLQualifier}) + } + expansionMCMS = NewDefaultInputForMCMS("configure token pool test setup") + } + + disabledOutbound := tokensapi.RateLimiterConfigFloatInput{IsEnabled: false} + deployerA := e.BlockChains.EVMChains()[selA].DeployerKey.From + deployerB := e.BlockChains.EVMChains()[selB].DeployerKey.From + + expansionOut, err := tokensapi.TokenExpansion().Apply(*e, tokensapi.TokenExpansionInput{ + ChainAdapterVersion: cciputils.Version_2_0_0, + MCMS: expansionMCMS, + TokenExpansionInputPerChain: map[uint64]tokensapi.TokenExpansionInputPerChain{ + selA: { + SkipOwnershipTransfer: !useMCMS, + TokenPoolVersion: bnmOpsV2_0_0.Version, + DeployTokenInput: &tokensapi.DeployTokenInput{ + Name: tokenSymb, Symbol: tokenSymb, Decimals: decimalsA, + ExternalAdmin: deployerA.Hex(), CCIPAdmin: deployerA.Hex(), + Type: bnmERC20ops.ContractType, + }, + DeployTokenPoolInput: &tokensapi.DeployTokenPoolInput{ + PoolType: string(bnmOpsV2_0_0.ContractType), + AllowedFinalityConfig: finality.Config{WaitForFinality: true}, + }, + TokenTransferConfig: &tokensapi.TokenTransferConfig{ + RemoteChains: map[uint64]tokensapi.RemoteChainConfig[*datastore.AddressRef, datastore.AddressRef]{ + selB: {OutboundRateLimiterConfig: &disabledOutbound}, + }, + }, + }, + selB: { + SkipOwnershipTransfer: !useMCMS, + TokenPoolVersion: bnmOpsV2_0_0.Version, + DeployTokenInput: &tokensapi.DeployTokenInput{ + Name: tokenSymb, Symbol: tokenSymb, Decimals: decimalsB, + ExternalAdmin: deployerB.Hex(), CCIPAdmin: deployerB.Hex(), + Type: bnmERC20ops.ContractType, + }, + DeployTokenPoolInput: &tokensapi.DeployTokenPoolInput{ + PoolType: string(bnmOpsV2_0_0.ContractType), + AllowedFinalityConfig: finality.Config{WaitForFinality: true}, + }, + TokenTransferConfig: &tokensapi.TokenTransferConfig{ + RemoteChains: map[uint64]tokensapi.RemoteChainConfig[*datastore.AddressRef, datastore.AddressRef]{ + selA: {OutboundRateLimiterConfig: &disabledOutbound}, + }, + }, + }, + }, + }) + require.NoError(t, err) + MergeAddresses(t, e, expansionOut.DataStore) + if useMCMS { + // Execute the ownership-transfer proposals so the pools become timelock-owned. + testhelpers.ProcessTimelockProposals(t, *e, expansionOut.MCMSTimelockProposals, false) + } + + fltrA := datastore.AddressRef{ChainSelector: selA, Type: datastore.ContractType(bnmOpsV2_0_0.ContractType), Version: bnmOpsV2_0_0.Version} + poolA, err := datastore_utils.FindAndFormatRef(e.DataStore, fltrA, selA, evm_datastore_utils.ToEVMAddress) + require.NoError(t, err) + fltrB := datastore.AddressRef{ChainSelector: selB, Type: datastore.ContractType(bnmOpsV2_0_0.ContractType), Version: bnmOpsV2_0_0.Version} + poolB, err := datastore_utils.FindAndFormatRef(e.DataStore, fltrB, selB, evm_datastore_utils.ToEVMAddress) + require.NoError(t, err) + + // The simulated backend under-estimates gas for setRateLimitConfig updates that rewrite + // existing bucket storage (SSTORE-refund accounting), causing an out-of-gas revert on the + // exact estimate. Force a manual gas limit to bypass estimation — matches real-chain + // behavior (production RPCs return a correct estimate / apply a buffer). Same fix the + // SetTokenPoolRateLimits tests use via forceSimGasLimit. + forceSimGasLimit(e, 5_000_000) + + return configureTestEnv{ + env: e, selA: selA, selB: selB, poolA: poolA, poolB: poolB, + clientA: e.BlockChains.EVMChains()[selA].Client, clientB: e.BlockChains.EVMChains()[selB].Client, + tokenSymb: tokenSymb, decimalsA: decimalsA, decimalsB: decimalsB, + } +} + +func TestConfigureTokenPool_FinalityConfig(t *testing.T) { + tc := setupV2PoolsForConfigure(t, "CTP_FIN") + + // Sanity: initial finality config comes from deployment. + validateFinalityConfigV2_0_0(t, tc.poolA, tc.clientA, finality.Config{WaitForFinality: true}) + + newCfg := finality.Config{WaitForSafe: true, BlockDepth: 5} + input := tokensapi.ConfigureTokenPoolInput{ + MCMS: mcms.Input{}, + Chains: []tokensapi.ConfigureTokenPoolPerChain{{ + ChainSelector: tc.selA, + Pools: []tokensapi.PoolConfigUpdate{{ + TokenPoolRef: datastore.AddressRef{Address: tc.poolA.Hex()}, + FinalityConfig: &newCfg, + }}, + }}, + } + require.NoError(t, tokensapi.ConfigureTokenPool().VerifyPreconditions(*tc.env, input)) + _, err := tokensapi.ConfigureTokenPool().Apply(*tc.env, input) + require.NoError(t, err) + + // The targeted pool changed; the untouched pool did not. + validateFinalityConfigV2_0_0(t, tc.poolA, tc.clientA, newCfg) + validateFinalityConfigV2_0_0(t, tc.poolB, tc.clientB, finality.Config{WaitForFinality: true}) + + // Idempotency: identical second apply sends no transactions (no new blocks mined). + refreshBundle(&tc) + before := currentBlock(t, tc, tc.selA) + _, err = tokensapi.ConfigureTokenPool().Apply(*tc.env, input) + require.NoError(t, err) + after := currentBlock(t, tc, tc.selA) + require.Equal(t, before, after, "second identical apply must not send transactions") +} + +// currentBlock returns the latest block number on the given chain. The simulated backend +// mines exactly one block per transaction, so an unchanged block number across an Apply +// proves no transaction was sent. +func currentBlock(t *testing.T, tc configureTestEnv, sel uint64) uint64 { + t.Helper() + header, err := tc.env.BlockChains.EVMChains()[sel].Client.HeaderByNumber(t.Context(), nil) + require.NoError(t, err) + return header.Number.Uint64() +} + +// refreshBundle swaps in a fresh operations reporter, mirroring production where every migration +// run applies a changeset against a fresh bundle. Idempotency re-applies must use this so they +// exercise the changeset's read-compare-skip against live on-chain state instead of replaying a +// memoized sequence report from the prior apply (operations.ExecuteSequence reuses prior successful +// reports on identical input, which would mask a redundant write/proposal). +func refreshBundle(tc *configureTestEnv) { + tc.env.OperationsBundle = evm_testsetup.BundleWithFreshReporter(tc.env.OperationsBundle) +} + +func TestConfigureTokenPool_Admins(t *testing.T) { + tc := setupV2PoolsForConfigure(t, "CTP_ADM") + + newRateLimitAdmin := "0x1111111111111111111111111111111111111111" + newFeeAdmin := "0x2222222222222222222222222222222222222222" + + // Set only the rate limit admin; feeAdmin and router must be untouched. + pool, err := tokenpoolV2_0_0.NewTokenPool(tc.poolA, tc.clientA) + require.NoError(t, err) + preCfg, err := pool.GetDynamicConfig(&bind.CallOpts{Context: t.Context()}) + require.NoError(t, err) + + input := tokensapi.ConfigureTokenPoolInput{ + MCMS: mcms.Input{}, + Chains: []tokensapi.ConfigureTokenPoolPerChain{{ + ChainSelector: tc.selA, + Pools: []tokensapi.PoolConfigUpdate{{ + TokenPoolRef: datastore.AddressRef{Address: tc.poolA.Hex()}, + RateLimitAdmin: ptrTo(newRateLimitAdmin), + }}, + }}, + } + require.NoError(t, tokensapi.ConfigureTokenPool().VerifyPreconditions(*tc.env, input)) + _, err = tokensapi.ConfigureTokenPool().Apply(*tc.env, input) + require.NoError(t, err) + + postCfg, err := pool.GetDynamicConfig(&bind.CallOpts{Context: t.Context()}) + require.NoError(t, err) + require.Equal(t, common.HexToAddress(newRateLimitAdmin), postCfg.RateLimitAdmin) + require.Equal(t, preCfg.FeeAdmin, postCfg.FeeAdmin, "feeAdmin must be preserved") + require.Equal(t, preCfg.Router, postCfg.Router, "router must be preserved") + + // Now set only the fee admin; the rate limit admin we just set must survive. + input.Chains[0].Pools[0] = tokensapi.PoolConfigUpdate{ + TokenPoolRef: datastore.AddressRef{Address: tc.poolA.Hex()}, + FeeAdmin: ptrTo(newFeeAdmin), + } + _, err = tokensapi.ConfigureTokenPool().Apply(*tc.env, input) + require.NoError(t, err) + postCfg, err = pool.GetDynamicConfig(&bind.CallOpts{Context: t.Context()}) + require.NoError(t, err) + require.Equal(t, common.HexToAddress(newFeeAdmin), postCfg.FeeAdmin) + require.Equal(t, common.HexToAddress(newRateLimitAdmin), postCfg.RateLimitAdmin, "rateLimitAdmin must be preserved") + + // Idempotency: setting both to their current values sends no transactions. + input.Chains[0].Pools[0] = tokensapi.PoolConfigUpdate{ + TokenPoolRef: datastore.AddressRef{Address: tc.poolA.Hex()}, + RateLimitAdmin: ptrTo(newRateLimitAdmin), + FeeAdmin: ptrTo(newFeeAdmin), + } + refreshBundle(&tc) + before := currentBlock(t, tc, tc.selA) + _, err = tokensapi.ConfigureTokenPool().Apply(*tc.env, input) + require.NoError(t, err) + after := currentBlock(t, tc, tc.selA) + require.Equal(t, before, after, "no-op admin update must not send transactions") + + // Invalid admin address formats are validated by the EVM SetTokenPoolAdmins sequence at + // apply time; the top-level changeset stays chain-agnostic and no longer checks formats. + input.Chains[0].Pools[0] = tokensapi.PoolConfigUpdate{ + TokenPoolRef: datastore.AddressRef{Address: tc.poolA.Hex()}, + RateLimitAdmin: ptrTo("not-an-address"), + } + require.NoError(t, tokensapi.ConfigureTokenPool().VerifyPreconditions(*tc.env, input)) + refreshBundle(&tc) + _, err = tokensapi.ConfigureTokenPool().Apply(*tc.env, input) + require.ErrorContains(t, err, "invalid rate limit admin address") +} + +func TestConfigureTokenPool_FeeConfig(t *testing.T) { + tc := setupV2PoolsForConfigure(t, "CTP_FEE") + + // First apply: enable a fee config on the A→B lane. On-chain config starts disabled + // (all-zero), so every field we care about must be provided explicitly here. + initial := &tokensapi.PartialTokenTransferFeeConfig{ + IsEnabled: cciputils.NewOptional(true), + DestBytesOverhead: cciputils.NewOptional(uint32(320)), + DestGasOverhead: cciputils.NewOptional(uint32(21_000)), + DefaultFinalityFeeUSDCents: cciputils.NewOptional(uint32(50)), + } + input := tokensapi.ConfigureTokenPoolInput{ + MCMS: mcms.Input{}, + Chains: []tokensapi.ConfigureTokenPoolPerChain{{ + ChainSelector: tc.selA, + Pools: []tokensapi.PoolConfigUpdate{{ + TokenPoolRef: datastore.AddressRef{Address: tc.poolA.Hex()}, + Remotes: []tokensapi.RemoteConfigUpdate{{ + RemoteChainSelector: tc.selB, + TokenTransferFeeConfig: initial, + }}, + }}, + }}, + } + require.NoError(t, tokensapi.ConfigureTokenPool().VerifyPreconditions(*tc.env, input)) + _, err := tokensapi.ConfigureTokenPool().Apply(*tc.env, input) + require.NoError(t, err) + + pool, err := tokenpoolV2_0_0.NewTokenPool(tc.poolA, tc.clientA) + require.NoError(t, err) + opts := &bind.CallOpts{Context: t.Context()} + cfg, err := pool.GetTokenTransferFeeConfig(opts, common.Address{}, tc.selB, [4]byte{}, nil) + require.NoError(t, err) + require.True(t, cfg.IsEnabled) + require.Equal(t, uint32(320), cfg.DestBytesOverhead) + require.Equal(t, uint32(21_000), cfg.DestGasOverhead) + require.Equal(t, uint32(50), cfg.FinalityFeeUSDCents) + + // Second apply: change ONLY destGasOverhead. All other fields must be preserved + // from on-chain state (merge-with-on-chain, not merge-with-defaults). + input.Chains[0].Pools[0].Remotes[0].TokenTransferFeeConfig = &tokensapi.PartialTokenTransferFeeConfig{ + DestGasOverhead: cciputils.NewOptional(uint32(31_000)), + } + _, err = tokensapi.ConfigureTokenPool().Apply(*tc.env, input) + require.NoError(t, err) + cfg, err = pool.GetTokenTransferFeeConfig(opts, common.Address{}, tc.selB, [4]byte{}, nil) + require.NoError(t, err) + require.Equal(t, uint32(31_000), cfg.DestGasOverhead, "targeted field must change") + require.True(t, cfg.IsEnabled, "isEnabled must be preserved from on-chain") + require.Equal(t, uint32(320), cfg.DestBytesOverhead, "destBytesOverhead must be preserved from on-chain") + require.Equal(t, uint32(50), cfg.FinalityFeeUSDCents, "minFee must be preserved from on-chain") + + // Idempotency: identical partial re-apply sends no transactions. + refreshBundle(&tc) + before := currentBlock(t, tc, tc.selA) + _, err = tokensapi.ConfigureTokenPool().Apply(*tc.env, input) + require.NoError(t, err) + after := currentBlock(t, tc, tc.selA) + require.Equal(t, before, after, "no-op fee update must not send transactions") +} + +func TestConfigureTokenPool_RateLimits(t *testing.T) { + tc := setupV2PoolsForConfigure(t, "CTP_RL") + + // Unidirectional: configure only pool A's view of the A→B lane. Pool B gets no entry. + buckets := []tokensapi.RateLimitBucketInput{ + { + FastFinality: false, + Outbound: tokensapi.RateLimiterConfigFloatInput{IsEnabled: true, Capacity: 1000, Rate: 100}, + Inbound: tokensapi.RateLimiterConfigFloatInput{IsEnabled: true, Capacity: 2000, Rate: 200}, + }, + { + FastFinality: true, + Outbound: tokensapi.RateLimiterConfigFloatInput{IsEnabled: true, Capacity: 500, Rate: 50}, + Inbound: tokensapi.RateLimiterConfigFloatInput{IsEnabled: true, Capacity: 600, Rate: 60}, + }, + } + input := tokensapi.ConfigureTokenPoolInput{ + MCMS: mcms.Input{}, + Chains: []tokensapi.ConfigureTokenPoolPerChain{{ + ChainSelector: tc.selA, + Pools: []tokensapi.PoolConfigUpdate{{ + TokenPoolRef: datastore.AddressRef{Address: tc.poolA.Hex()}, + Remotes: []tokensapi.RemoteConfigUpdate{{ + RemoteChainSelector: tc.selB, + RateLimits: buckets, + }}, + }}, + }}, + } + require.NoError(t, tokensapi.ConfigureTokenPool().VerifyPreconditions(*tc.env, input)) + _, err := tokensapi.ConfigureTokenPool().Apply(*tc.env, input) + require.NoError(t, err) + + // Pool A: outbound scaled by local decimals (no bump), inbound scaled with +10% bump. + defaultA, ffA := getRateLimits(t, cciputils.Version_2_0_0, tc.poolA, tc.clientA, tc.selB) + validateScaledTPRLBucket(t, "default poolA", tc.decimalsA, defaultA, buckets[0].Outbound, buckets[0].Inbound) + validateScaledTPRLBucket(t, "fast_finality poolA", tc.decimalsA, ffA, buckets[1].Outbound, buckets[1].Inbound) + + // Pool B: untouched (unidirectional update). + defaultB, ffB := getRateLimits(t, cciputils.Version_2_0_0, tc.poolB, tc.clientB, tc.selA) + require.False(t, defaultB.OutboundRateLimiterConfig.IsEnabled, "pool B default outbound must remain disabled") + require.False(t, ffB.OutboundRateLimiterConfig.IsEnabled, "pool B FF outbound must remain disabled") + + // Idempotency: identical second apply sends no transactions. + refreshBundle(&tc) + before := currentBlock(t, tc, tc.selA) + _, err = tokensapi.ConfigureTokenPool().Apply(*tc.env, input) + require.NoError(t, err) + after := currentBlock(t, tc, tc.selA) + require.Equal(t, before, after, "no-op rate limit update must not send transactions") + + // Partial change: modify only the default bucket; the FF bucket write must be skipped + // (single-bucket setRateLimitConfig call) and FF state preserved. + input.Chains[0].Pools[0].Remotes[0].RateLimits = []tokensapi.RateLimitBucketInput{ + { + FastFinality: false, + Outbound: tokensapi.RateLimiterConfigFloatInput{IsEnabled: true, Capacity: 3000, Rate: 300}, + Inbound: buckets[0].Inbound, + }, + } + _, err = tokensapi.ConfigureTokenPool().Apply(*tc.env, input) + require.NoError(t, err) + defaultA2, ffA2 := getRateLimits(t, cciputils.Version_2_0_0, tc.poolA, tc.clientA, tc.selB) + validateScaledTPRLBucket(t, "default poolA after partial", tc.decimalsA, defaultA2, + tokensapi.RateLimiterConfigFloatInput{IsEnabled: true, Capacity: 3000, Rate: 300}, buckets[0].Inbound) + validateScaledTPRLBucket(t, "fast_finality poolA preserved", tc.decimalsA, ffA2, buckets[1].Outbound, buckets[1].Inbound) +} + +func TestConfigureTokenPool_YAMLRoundTrip(t *testing.T) { + const payload = ` +mcms: + qualifier: CLL +input: + - selector: "909606746561742123" + pools: + - tokenPoolRef: { address: '0x1111111111111111111111111111111111111111' } + finalityConfig: { waitForSafe: true, blockDepth: 1, waitForFinality: false } + rateLimitAdmin: '0x1111111111111111111111111111111111111111' + feeAdmin: '0x1111111111111111111111111111111111111111' + remotes: + - selector: "5548718428018410741" + tokenTransferFeeConfig: + destBytesOverhead: 320 + destGasOverhead: 21000 + rateLimits: + - fastFinality: false + outbound: { isEnabled: true, capacity: 1000, rate: 100 } + inbound: { isEnabled: true, capacity: 1000, rate: 100 } + - fastFinality: true + outbound: { isEnabled: true, capacity: 1000, rate: 100 } + inbound: { isEnabled: true, capacity: 1000, rate: 100 } +` + var input tokensapi.ConfigureTokenPoolInput + require.NoError(t, yaml.Unmarshal([]byte(payload), &input)) + + require.Len(t, input.Chains, 1) + require.Equal(t, chainsel.TEST_90000001.Selector, input.Chains[0].ChainSelector) + require.Len(t, input.Chains[0].Pools, 1) + pool := input.Chains[0].Pools[0] + require.Equal(t, "0x1111111111111111111111111111111111111111", pool.TokenPoolRef.Address) + require.NotNil(t, pool.FinalityConfig) + require.True(t, pool.FinalityConfig.WaitForSafe) + require.Equal(t, uint16(1), pool.FinalityConfig.BlockDepth) + require.NotNil(t, pool.RateLimitAdmin) + require.NotNil(t, pool.FeeAdmin) + require.Len(t, pool.Remotes, 1) + remote := pool.Remotes[0] + require.Equal(t, chainsel.TEST_90000002.Selector, remote.RemoteChainSelector) + require.NotNil(t, remote.TokenTransferFeeConfig) + dbo, ok := remote.TokenTransferFeeConfig.DestBytesOverhead.Get() + require.True(t, ok) + require.Equal(t, uint32(320), dbo) + _, ok = remote.TokenTransferFeeConfig.IsEnabled.Get() + require.False(t, ok, "unset optional fields must stay unset after unmarshal") + require.Len(t, remote.RateLimits, 2) + require.False(t, remote.RateLimits[0].FastFinality) + require.True(t, remote.RateLimits[1].FastFinality) + require.Equal(t, float64(1000), remote.RateLimits[0].Outbound.Capacity) +} + +func TestConfigureTokenPool_RejectsPreV2Pools(t *testing.T) { + tc := setupV2PoolsForConfigure(t, "CTP_VER") + + // Inject a datastore entry whose resolved version is pre-v2 (all-digit address is + // checksum-invariant, so it resolves from the datastore without a chain call). Verify + // must reject it in PR#1 (v2.0.0+ only), regardless of which adapters are registered. + preV2Pool := "0x0000000000000000000000000000000000000001" + extra := datastore.NewMemoryDataStore() + require.NoError(t, extra.Addresses().Add(datastore.AddressRef{ + ChainSelector: tc.selA, + Address: preV2Pool, + Type: datastore.ContractType(bnmOpsV2_0_0.ContractType), + Version: semver.MustParse("1.6.1"), + })) + MergeAddresses(t, tc.env, extra) + + input := tokensapi.ConfigureTokenPoolInput{ + MCMS: mcms.Input{}, + Chains: []tokensapi.ConfigureTokenPoolPerChain{{ + ChainSelector: tc.selA, + Pools: []tokensapi.PoolConfigUpdate{{ + TokenPoolRef: datastore.AddressRef{Address: preV2Pool}, + RateLimitAdmin: ptrTo("0x2222222222222222222222222222222222222222"), + }}, + }}, + } + err := tokensapi.ConfigureTokenPool().VerifyPreconditions(*tc.env, input) + require.Error(t, err) + require.Contains(t, err.Error(), "only v2.0.0+ pools are currently supported") +} + +func TestConfigureTokenPool_CombinedUpdate(t *testing.T) { + tc := setupV2PoolsForConfigure(t, "CTP_ALL") + + admin := "0x3333333333333333333333333333333333333333" + newFinality := finality.Config{BlockDepth: 7} + rl := []tokensapi.RateLimitBucketInput{{ + FastFinality: false, + Outbound: tokensapi.RateLimiterConfigFloatInput{IsEnabled: true, Capacity: 1000, Rate: 100}, + Inbound: tokensapi.RateLimiterConfigFloatInput{IsEnabled: true, Capacity: 1000, Rate: 100}, + }} + fee := &tokensapi.PartialTokenTransferFeeConfig{ + IsEnabled: cciputils.NewOptional(true), + DestBytesOverhead: cciputils.NewOptional(uint32(320)), + DestGasOverhead: cciputils.NewOptional(uint32(21_000)), + } + + input := tokensapi.ConfigureTokenPoolInput{ + MCMS: mcms.Input{}, + Chains: []tokensapi.ConfigureTokenPoolPerChain{ + { + ChainSelector: tc.selA, + Pools: []tokensapi.PoolConfigUpdate{{ + TokenPoolRef: datastore.AddressRef{Address: tc.poolA.Hex()}, + FinalityConfig: &newFinality, + RateLimitAdmin: ptrTo(admin), + FeeAdmin: ptrTo(admin), + Remotes: []tokensapi.RemoteConfigUpdate{{ + RemoteChainSelector: tc.selB, + TokenTransferFeeConfig: fee, + RateLimits: rl, + }}, + }}, + }, + { + ChainSelector: tc.selB, + Pools: []tokensapi.PoolConfigUpdate{{ + TokenPoolRef: datastore.AddressRef{Address: tc.poolB.Hex()}, + Remotes: []tokensapi.RemoteConfigUpdate{{ + RemoteChainSelector: tc.selA, + RateLimits: rl, + }}, + }}, + }, + }, + } + require.NoError(t, tokensapi.ConfigureTokenPool().VerifyPreconditions(*tc.env, input)) + out, err := tokensapi.ConfigureTokenPool().Apply(*tc.env, input) + require.NoError(t, err) + require.Empty(t, out.MCMSTimelockProposals, "EOA-owned pools must not produce MCMS proposals") + + // Spot-check each feature landed. + validateFinalityConfigV2_0_0(t, tc.poolA, tc.clientA, newFinality) + pool, err := tokenpoolV2_0_0.NewTokenPool(tc.poolA, tc.clientA) + require.NoError(t, err) + dynCfg, err := pool.GetDynamicConfig(&bind.CallOpts{Context: t.Context()}) + require.NoError(t, err) + require.Equal(t, common.HexToAddress(admin), dynCfg.RateLimitAdmin) + require.Equal(t, common.HexToAddress(admin), dynCfg.FeeAdmin) + defaultA, _ := getRateLimits(t, cciputils.Version_2_0_0, tc.poolA, tc.clientA, tc.selB) + validateScaledTPRLBucket(t, "combined default poolA", tc.decimalsA, defaultA, rl[0].Outbound, rl[0].Inbound) + defaultB, _ := getRateLimits(t, cciputils.Version_2_0_0, tc.poolB, tc.clientB, tc.selA) + validateScaledTPRLBucket(t, "combined default poolB", tc.decimalsB, defaultB, rl[0].Outbound, rl[0].Inbound) + + // Full idempotency across every feature at once. + refreshBundle(&tc) + beforeA := currentBlock(t, tc, tc.selA) + beforeB := currentBlock(t, tc, tc.selB) + out, err = tokensapi.ConfigureTokenPool().Apply(*tc.env, input) + require.NoError(t, err) + require.Empty(t, out.MCMSTimelockProposals, "combined no-op must not emit proposals") + afterA := currentBlock(t, tc, tc.selA) + afterB := currentBlock(t, tc, tc.selB) + require.Equal(t, beforeA, afterA, "combined no-op must not send transactions on chain A") + require.Equal(t, beforeB, afterB, "combined no-op must not send transactions on chain B") +} + +func TestConfigureTokenPool_MCMSOwnedPool(t *testing.T) { + tc := setupV2PoolsForConfigureMCMS(t, "CTP_MCMS") + + newCfg := finality.Config{BlockDepth: 9} + input := tokensapi.ConfigureTokenPoolInput{ + MCMS: NewDefaultInputForMCMS("configure token pool finality"), + Chains: []tokensapi.ConfigureTokenPoolPerChain{{ + ChainSelector: tc.selA, + Pools: []tokensapi.PoolConfigUpdate{{ + TokenPoolRef: datastore.AddressRef{Address: tc.poolA.Hex()}, + FinalityConfig: &newCfg, + }}, + }}, + } + require.NoError(t, tokensapi.ConfigureTokenPool().VerifyPreconditions(*tc.env, input)) + out, err := tokensapi.ConfigureTokenPool().Apply(*tc.env, input) + require.NoError(t, err) + + // Timelock-owned pool: the change is proposed, not executed. + require.Len(t, out.MCMSTimelockProposals, 1, "timelock-owned pool must produce an MCMS proposal") + validateFinalityConfigV2_0_0(t, tc.poolA, tc.clientA, finality.Config{WaitForFinality: true}) // unchanged until execution + + // Execute the proposal and confirm the change lands. + testhelpers.ProcessTimelockProposals(t, *tc.env, out.MCMSTimelockProposals, false) + validateFinalityConfigV2_0_0(t, tc.poolA, tc.clientA, newCfg) + + // Idempotency (the design's core guarantee, asserted on the output not just block height): + // re-applying the now-satisfied config against a fresh bundle must emit ZERO proposals, so + // no redundant timelock op / MCMS predecessor conflict is produced. + refreshBundle(&tc) + out2, err := tokensapi.ConfigureTokenPool().Apply(*tc.env, input) + require.NoError(t, err) + require.Empty(t, out2.MCMSTimelockProposals, "no-op re-apply must not emit an MCMS proposal") +} diff --git a/integration-tests/go.mod b/integration-tests/go.mod index 8dd76d427..8ddb7891c 100644 --- a/integration-tests/go.mod +++ b/integration-tests/go.mod @@ -38,6 +38,7 @@ require ( github.com/smartcontractkit/chainlink-evm/gethwrappers v0.0.0-20260710181111-6417709a55ee github.com/smartcontractkit/mcms v0.51.0 github.com/stretchr/testify v1.11.1 + sigs.k8s.io/yaml v1.4.0 ) require ( @@ -359,7 +360,6 @@ require ( sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect - sigs.k8s.io/yaml v1.4.0 // indirect ) // gotron-sdk is not longer maintained