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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions ccv/chains/evm/deployment/v1_7_0/changesets/withdraw_fee_tokens.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package changesets

import (
"fmt"

"github.com/ethereum/go-ethereum/common"
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/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-ccip/ccv/chains/evm/deployment/v1_7_0/sequences"
)

// WithdrawFeeTokensCfg is the YAML/pipeline input for the WithdrawFeeTokens changeset.
type WithdrawFeeTokensCfg struct {
ChainSel uint64
// ContractRefs identifies the contracts to withdraw from.
ContractRefs []datastore.AddressRef
// FeeTokens is the list of ERC-20 token addresses to withdraw from each contract.
FeeTokens []common.Address
// Recipient is required when any ref is a TokenPool. Ignored for OnRamp/CommitteeVerifier.
Recipient common.Address
}

// ChainSelector implements the single-chain config interface required by
// ResolveEVMChainDep, which looks up the evm.Chain from the environment.
func (c WithdrawFeeTokensCfg) ChainSelector() uint64 {
return c.ChainSel
}

// WithdrawFeeTokens wraps the withdraw-fee-tokens sequence into a changeset.
// It resolves each user-supplied AddressRef against the datastore to obtain on-chain
// addresses, validates that every ref is a known FeeTokenHandler, and delegates
// execution to the sequence. The result is an MCMS proposal containing all withdrawals.
var WithdrawFeeTokens = changesets.NewFromOnChainSequence(changesets.NewFromOnChainSequenceParams[
sequences.WithdrawFeeTokensInput,
evm.Chain,
WithdrawFeeTokensCfg,
]{
Sequence: sequences.WithdrawFeeTokens,

// ResolveInput converts the user-facing config into the sequence's input by:
// 1. Validating each ref is a supported FeeTokenHandler type.
// 2. Looking up the deployed address in the environment's datastore.
// 3. Building fully-resolved AddressRefs with the on-chain address populated.
ResolveInput: func(e cldf_deployment.Environment, cfg WithdrawFeeTokensCfg) (sequences.WithdrawFeeTokensInput, error) {
resolvedRefs := make([]datastore.AddressRef, 0, len(cfg.ContractRefs))
for _, ref := range cfg.ContractRefs {
// Reject unknown contract types early, before hitting the datastore.
if !sequences.IsFeeTokenHandler(ref.Type) {
return sequences.WithdrawFeeTokensInput{}, fmt.Errorf(
"contract type %q is not a supported FeeTokenHandler", ref.Type,
)
}
// Look up the contract's deployed address from the datastore.
resolvedAddr, err := datastore_utils.FindAndFormatRef(e.DataStore, ref, cfg.ChainSel, evm_datastore_utils.ToEVMAddress)
if err != nil {
return sequences.WithdrawFeeTokensInput{}, fmt.Errorf(
"failed to resolve contract ref (type=%s, version=%v, qualifier=%s) on chain %d: %w",
ref.Type, ref.Version, ref.Qualifier, cfg.ChainSel, err,
)
}
resolvedRefs = append(resolvedRefs, datastore.AddressRef{
Address: resolvedAddr.Hex(),
ChainSelector: cfg.ChainSel,
Type: ref.Type,
Version: ref.Version,
Qualifier: ref.Qualifier,
})
}

return sequences.WithdrawFeeTokensInput{
ChainSelector: cfg.ChainSel,
ContractRefs: resolvedRefs,
FeeTokens: cfg.FeeTokens,
Recipient: cfg.Recipient,
}, nil
},

// ResolveDep looks up the evm.Chain object from the environment using ChainSel.
ResolveDep: evm_sequences.ResolveEVMChainDep[WithdrawFeeTokensCfg],
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
package changesets_test

import (
"testing"

"github.com/Masterminds/semver/v3"
"github.com/ethereum/go-ethereum/common"
"github.com/stretchr/testify/require"

"github.com/smartcontractkit/chainlink-ccip/ccv/chains/evm/deployment/latest/operations/burn_mint_token_pool"
"github.com/smartcontractkit/chainlink-ccip/ccv/chains/evm/deployment/latest/operations/committee_verifier"
"github.com/smartcontractkit/chainlink-ccip/ccv/chains/evm/deployment/latest/operations/onramp"
"github.com/smartcontractkit/chainlink-ccip/ccv/chains/evm/deployment/latest/operations/token_pool"
"github.com/smartcontractkit/chainlink-ccip/ccv/chains/evm/deployment/v1_7_0/changesets"
"github.com/smartcontractkit/chainlink-ccip/ccv/chains/evm/deployment/v1_7_0/operations/create2_factory"
"github.com/smartcontractkit/chainlink-ccip/ccv/chains/evm/deployment/v1_7_0/testsetup"
contract_utils "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/operations/contract"
cs_core "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"
)

const testChainSelector = 5009297550715157269

// TestWithdrawFeeTokens_VerifyPreconditions tests that the changeset rejects
// invalid configurations during precondition validation (before any on-chain work).
func TestWithdrawFeeTokens_VerifyPreconditions(t *testing.T) {
e, err := environment.New(t.Context(),
environment.WithEVMSimulated(t, []uint64{testChainSelector}),
)
require.NoError(t, err)

tests := []struct {
desc string
input cs_core.WithMCMS[changesets.WithdrawFeeTokensCfg]
expectedErr string
}{
{
desc: "valid input with OnRamp ref",
input: cs_core.WithMCMS[changesets.WithdrawFeeTokensCfg]{
MCMS: mcms.Input{},
Cfg: changesets.WithdrawFeeTokensCfg{
ChainSel: testChainSelector,
ContractRefs: []datastore.AddressRef{
{
Type: datastore.ContractType(onramp.ContractType),
Version: onramp.Version,
},
},
FeeTokens: []common.Address{common.HexToAddress("0x01")},
},
},
expectedErr: "expected to find exactly 1 ref",
},
{
desc: "invalid chain selector",
input: cs_core.WithMCMS[changesets.WithdrawFeeTokensCfg]{
MCMS: mcms.Input{},
Cfg: changesets.WithdrawFeeTokensCfg{
ChainSel: 12345,
ContractRefs: []datastore.AddressRef{
{
Type: datastore.ContractType(onramp.ContractType),
Version: onramp.Version,
},
},
FeeTokens: []common.Address{common.HexToAddress("0x01")},
},
},
expectedErr: "failed to resolve contract ref",
},
{
desc: "unsupported contract type",
input: cs_core.WithMCMS[changesets.WithdrawFeeTokensCfg]{
MCMS: mcms.Input{},
Cfg: changesets.WithdrawFeeTokensCfg{
ChainSel: testChainSelector,
ContractRefs: []datastore.AddressRef{
{
Type: "UnsupportedContract",
Version: semver.MustParse("1.0.0"),
},
},
FeeTokens: []common.Address{common.HexToAddress("0x01")},
},
},
expectedErr: "not a supported FeeTokenHandler",
},
}

for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
mcmsRegistry := cs_core.GetRegistry()
err := changesets.WithdrawFeeTokens(mcmsRegistry).VerifyPreconditions(*e, test.input)
if test.expectedErr != "" {
require.ErrorContains(t, err, test.expectedErr)
} else {
require.NoError(t, err)
}
})
}
}

// TestWithdrawFeeTokens_Apply deploys a full set of chain contracts on a simulated
// chain, then verifies the changeset can successfully withdraw fee tokens from
// OnRamp, CommitteeVerifier, and multiple contracts at once.
func TestWithdrawFeeTokens_Apply(t *testing.T) {
e, err := environment.New(t.Context(),
environment.WithEVMSimulated(t, []uint64{testChainSelector}),
)
require.NoError(t, err)

mcmsRegistry := cs_core.GetRegistry()

create2FactoryRef, err := contract_utils.MaybeDeployContract(
e.OperationsBundle, create2_factory.Deploy,
e.BlockChains.EVMChains()[testChainSelector],
contract_utils.DeployInput[create2_factory.ConstructorArgs]{
TypeAndVersion: deployment.NewTypeAndVersion(create2_factory.ContractType, *semver.MustParse("1.7.0")),
ChainSelector: testChainSelector,
Args: create2_factory.ConstructorArgs{
AllowList: []common.Address{e.BlockChains.EVMChains()[testChainSelector].DeployerKey.From},
},
}, nil,
)
require.NoError(t, err, "Failed to deploy CREATE2Factory")

deployOut, err := changesets.DeployChainContracts(mcmsRegistry).Apply(*e, cs_core.WithMCMS[changesets.DeployChainContractsCfg]{
MCMS: mcms.Input{},
Cfg: changesets.DeployChainContractsCfg{
ChainSel: testChainSelector,
CREATE2Factory: common.HexToAddress(create2FactoryRef.Address),
Params: testsetup.CreateBasicContractParams(),
},
})
require.NoError(t, err, "Failed to deploy chain contracts")

deployedAddrs, err := deployOut.DataStore.Addresses().Fetch()
require.NoError(t, err)

var wethAddr common.Address
for _, ref := range deployedAddrs {
if ref.Type == "WETH9" {
wethAddr = common.HexToAddress(ref.Address)
break
}
}
require.NotEqual(t, common.Address{}, wethAddr, "WETH should be deployed")

ds := datastore.NewMemoryDataStore()
for _, ref := range deployedAddrs {
require.NoError(t, ds.Addresses().Add(ref))
}
e.DataStore = ds.Seal()

tests := []struct {
desc string
input cs_core.WithMCMS[changesets.WithdrawFeeTokensCfg]
}{
{
desc: "withdraw from OnRamp",
input: cs_core.WithMCMS[changesets.WithdrawFeeTokensCfg]{
MCMS: mcms.Input{},
Cfg: changesets.WithdrawFeeTokensCfg{
ChainSel: testChainSelector,
ContractRefs: []datastore.AddressRef{
{
Type: datastore.ContractType(onramp.ContractType),
Version: onramp.Version,
},
},
FeeTokens: []common.Address{wethAddr},
},
},
},
{
desc: "withdraw from CommitteeVerifier",
input: cs_core.WithMCMS[changesets.WithdrawFeeTokensCfg]{
MCMS: mcms.Input{},
Cfg: changesets.WithdrawFeeTokensCfg{
ChainSel: testChainSelector,
ContractRefs: []datastore.AddressRef{
{
Type: datastore.ContractType(committee_verifier.ContractType),
Version: committee_verifier.Version,
Qualifier: "alpha",
},
},
FeeTokens: []common.Address{wethAddr},
},
},
},
{
desc: "withdraw from multiple contracts",
input: cs_core.WithMCMS[changesets.WithdrawFeeTokensCfg]{
MCMS: mcms.Input{},
Cfg: changesets.WithdrawFeeTokensCfg{
ChainSel: testChainSelector,
ContractRefs: []datastore.AddressRef{
{
Type: datastore.ContractType(onramp.ContractType),
Version: onramp.Version,
},
{
Type: datastore.ContractType(committee_verifier.ContractType),
Version: committee_verifier.Version,
Qualifier: "alpha",
},
},
FeeTokens: []common.Address{wethAddr},
},
},
},
}

for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
_, err := changesets.WithdrawFeeTokens(mcmsRegistry).Apply(*e, test.input)
require.NoError(t, err)
})
}
}

// TestWithdrawFeeTokens_TokenPoolRequiresRecipient verifies that the sequence rejects
// TokenPool withdrawals when no recipient is specified. Covers both the generic
// TokenPool type and a concrete subtype (BurnMintTokenPool) to ensure all pool
// variants are routed through the same validation path.
func TestWithdrawFeeTokens_TokenPoolRequiresRecipient(t *testing.T) {
e, err := environment.New(t.Context(),
environment.WithEVMSimulated(t, []uint64{testChainSelector}),
)
require.NoError(t, err)

mcmsRegistry := cs_core.GetRegistry()

tests := []struct {
desc string
contractType datastore.ContractType
version *semver.Version
}{
{
desc: "generic TokenPool",
contractType: datastore.ContractType(token_pool.ContractType),
version: token_pool.Version,
},
{
desc: "BurnMintTokenPool subtype",
contractType: datastore.ContractType(burn_mint_token_pool.ContractType),
version: burn_mint_token_pool.Version,
},
}

for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
ds := datastore.NewMemoryDataStore()
err := ds.Addresses().Add(datastore.AddressRef{
ChainSelector: testChainSelector,
Type: tt.contractType,
Version: tt.version,
Address: common.HexToAddress("0xDEAD").Hex(),
})
require.NoError(t, err)
e.DataStore = ds.Seal()

_, err = changesets.WithdrawFeeTokens(mcmsRegistry).Apply(*e, cs_core.WithMCMS[changesets.WithdrawFeeTokensCfg]{
MCMS: mcms.Input{},
Cfg: changesets.WithdrawFeeTokensCfg{
ChainSel: testChainSelector,
ContractRefs: []datastore.AddressRef{
{
Type: tt.contractType,
Version: tt.version,
},
},
FeeTokens: []common.Address{common.HexToAddress("0x01")},
},
})
require.ErrorContains(t, err, "recipient is required")
})
}
}
Loading
Loading