From 89e34972e0357cfcfa094f6c7ab0cff5b683904d Mon Sep 17 00:00:00 2001 From: Finley Decker Date: Tue, 10 Mar 2026 20:50:15 -1000 Subject: [PATCH] Add withdrawFeeTokens changeset using auto-generated operations All operations use auto-generated latest/operations/ packages. Includes OnRamp, CommitteeVerifier, and all 11 TokenPool variant types. --- .../v1_7_0/changesets/withdraw_fee_tokens.go | 86 ++++++ .../changesets/withdraw_fee_tokens_test.go | 283 ++++++++++++++++++ .../v1_7_0/sequences/withdraw_fee_tokens.go | 170 +++++++++++ 3 files changed, 539 insertions(+) create mode 100644 ccv/chains/evm/deployment/v1_7_0/changesets/withdraw_fee_tokens.go create mode 100644 ccv/chains/evm/deployment/v1_7_0/changesets/withdraw_fee_tokens_test.go create mode 100644 ccv/chains/evm/deployment/v1_7_0/sequences/withdraw_fee_tokens.go diff --git a/ccv/chains/evm/deployment/v1_7_0/changesets/withdraw_fee_tokens.go b/ccv/chains/evm/deployment/v1_7_0/changesets/withdraw_fee_tokens.go new file mode 100644 index 0000000000..f0b708160d --- /dev/null +++ b/ccv/chains/evm/deployment/v1_7_0/changesets/withdraw_fee_tokens.go @@ -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], +}) diff --git a/ccv/chains/evm/deployment/v1_7_0/changesets/withdraw_fee_tokens_test.go b/ccv/chains/evm/deployment/v1_7_0/changesets/withdraw_fee_tokens_test.go new file mode 100644 index 0000000000..0c093f74b1 --- /dev/null +++ b/ccv/chains/evm/deployment/v1_7_0/changesets/withdraw_fee_tokens_test.go @@ -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") + }) + } +} diff --git a/ccv/chains/evm/deployment/v1_7_0/sequences/withdraw_fee_tokens.go b/ccv/chains/evm/deployment/v1_7_0/sequences/withdraw_fee_tokens.go new file mode 100644 index 0000000000..b58aa3db5c --- /dev/null +++ b/ccv/chains/evm/deployment/v1_7_0/sequences/withdraw_fee_tokens.go @@ -0,0 +1,170 @@ +package sequences + +import ( + "fmt" + + "github.com/Masterminds/semver/v3" + "github.com/ethereum/go-ethereum/common" + "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + mcms_types "github.com/smartcontractkit/mcms/types" + + contract_utils "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/operations/contract" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils/sequences" + + "github.com/smartcontractkit/chainlink-ccip/ccv/chains/evm/deployment/latest/operations/burn_from_mint_token_pool" + "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/burn_mint_with_lock_release_flag_token_pool" + "github.com/smartcontractkit/chainlink-ccip/ccv/chains/evm/deployment/latest/operations/burn_to_address_mint_token_pool" + "github.com/smartcontractkit/chainlink-ccip/ccv/chains/evm/deployment/latest/operations/burn_with_from_mint_token_pool" + "github.com/smartcontractkit/chainlink-ccip/ccv/chains/evm/deployment/latest/operations/cctp_through_ccv_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/lock_release_token_pool" + "github.com/smartcontractkit/chainlink-ccip/ccv/chains/evm/deployment/latest/operations/lombard_token_pool" + "github.com/smartcontractkit/chainlink-ccip/ccv/chains/evm/deployment/latest/operations/onramp" + "github.com/smartcontractkit/chainlink-ccip/ccv/chains/evm/deployment/latest/operations/siloed_lock_release_token_pool" + "github.com/smartcontractkit/chainlink-ccip/ccv/chains/evm/deployment/latest/operations/siloed_usdc_token_pool" + "github.com/smartcontractkit/chainlink-ccip/ccv/chains/evm/deployment/latest/operations/token_pool" +) + +// tokenPoolTypes lists contract types that use the TokenPool ABI for withdrawFeeTokens. +// All subtypes inherit from the base TokenPool Solidity contract and share the same +// withdrawFeeTokens(address[],address) signature. +var tokenPoolTypes = map[datastore.ContractType]bool{ + datastore.ContractType(token_pool.ContractType): true, + datastore.ContractType(burn_mint_token_pool.ContractType): true, + datastore.ContractType(burn_from_mint_token_pool.ContractType): true, + datastore.ContractType(burn_with_from_mint_token_pool.ContractType): true, + datastore.ContractType(burn_to_address_mint_token_pool.ContractType): true, + datastore.ContractType(burn_mint_with_lock_release_flag_token_pool.ContractType): true, + datastore.ContractType(lock_release_token_pool.ContractType): true, + datastore.ContractType(siloed_lock_release_token_pool.ContractType): true, + datastore.ContractType(lombard_token_pool.ContractType): true, + datastore.ContractType(cctp_through_ccv_token_pool.ContractType): true, + datastore.ContractType(siloed_usdc_token_pool.ContractType): true, +} + +// feeTokenHandlerTypes is the union of non-pool handlers (OnRamp, CommitteeVerifier) +// and all TokenPool variants. Built once at init to avoid duplication with tokenPoolTypes. +var feeTokenHandlerTypes map[datastore.ContractType]bool + +func init() { + feeTokenHandlerTypes = map[datastore.ContractType]bool{ + datastore.ContractType(onramp.ContractType): true, + datastore.ContractType(committee_verifier.ContractType): true, + } + for ct := range tokenPoolTypes { + feeTokenHandlerTypes[ct] = true + } +} + +// IsFeeTokenHandler returns true if the given contract type supports withdrawFeeTokens. +// This is used by both the sequence and the changeset to validate user-supplied refs. +func IsFeeTokenHandler(contractType datastore.ContractType) bool { + return feeTokenHandlerTypes[contractType] +} + +// IsTokenPoolType returns true if the given contract type is any variant of TokenPool. +func IsTokenPoolType(contractType datastore.ContractType) bool { + return tokenPoolTypes[contractType] +} + +// WithdrawFeeTokensInput is the resolved input for the WithdrawFeeTokens sequence. +// All AddressRefs should already have their Address field populated (done by the +// changeset's ResolveInput via datastore lookup). +type WithdrawFeeTokensInput struct { + ChainSelector uint64 + ContractRefs []datastore.AddressRef + FeeTokens []common.Address + // Recipient receives withdrawn tokens for TokenPool contracts. + // OnRamp and CommitteeVerifier ignore this; they send to their configured feeAggregator. + Recipient common.Address +} + +// WithdrawFeeTokens is the core sequence that iterates over the supplied contract refs, +// dispatches the appropriate withdrawFeeTokens operation for each contract type, and +// collects all resulting write outputs into a single MCMS BatchOperation. +// +// TokenPool has a different Solidity signature (requires a recipient address), so it +// is handled as a separate case from OnRamp and CommitteeVerifier. +var WithdrawFeeTokens = cldf_ops.NewSequence( + "withdraw-fee-tokens", + semver.MustParse("1.7.0"), + "Withdraws fee tokens from one or more fee-handling contracts on an EVM chain", + func(b cldf_ops.Bundle, chain evm.Chain, input WithdrawFeeTokensInput) (sequences.OnChainOutput, error) { + if len(input.ContractRefs) == 0 { + return sequences.OnChainOutput{}, fmt.Errorf("at least one contract ref is required") + } + if len(input.FeeTokens) == 0 { + return sequences.OnChainOutput{}, fmt.Errorf("at least one fee token address is required") + } + + writes := make([]contract_utils.WriteOutput, 0, len(input.ContractRefs)) + + for _, ref := range input.ContractRefs { + if !IsFeeTokenHandler(ref.Type) { + return sequences.OnChainOutput{}, fmt.Errorf( + "contract type %q is not a supported FeeTokenHandler", + ref.Type, + ) + } + if !common.IsHexAddress(ref.Address) { + return sequences.OnChainOutput{}, fmt.Errorf( + "invalid contract address %q for type %s", ref.Address, ref.Type, + ) + } + addr := common.HexToAddress(ref.Address) + + switch { + case ref.Type == datastore.ContractType(onramp.ContractType): + report, err := cldf_ops.ExecuteOperation(b, onramp.WithdrawFeeTokens, chain, contract_utils.FunctionInput[[]common.Address]{ + ChainSelector: input.ChainSelector, + Address: addr, + Args: input.FeeTokens, + }) + if err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to withdraw fee tokens from OnRamp %s: %w", ref.Address, err) + } + writes = append(writes, report.Output) + + case ref.Type == datastore.ContractType(committee_verifier.ContractType): + report, err := cldf_ops.ExecuteOperation(b, committee_verifier.WithdrawFeeTokens, chain, contract_utils.FunctionInput[[]common.Address]{ + ChainSelector: input.ChainSelector, + Address: addr, + Args: input.FeeTokens, + }) + if err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to withdraw fee tokens from CommitteeVerifier %s: %w", ref.Address, err) + } + writes = append(writes, report.Output) + + case IsTokenPoolType(ref.Type): + if input.Recipient == (common.Address{}) { + return sequences.OnChainOutput{}, fmt.Errorf("recipient is required when withdrawing fee tokens from %s %s", ref.Type, ref.Address) + } + report, err := cldf_ops.ExecuteOperation(b, token_pool.WithdrawFeeTokens, chain, contract_utils.FunctionInput[token_pool.WithdrawFeeTokensArgs]{ + ChainSelector: input.ChainSelector, + Address: addr, + Args: token_pool.WithdrawFeeTokensArgs{ + FeeTokens: input.FeeTokens, + Recipient: input.Recipient, + }, + }) + if err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to withdraw fee tokens from %s %s: %w", ref.Type, ref.Address, err) + } + writes = append(writes, report.Output) + } + } + + batchOp, err := contract_utils.NewBatchOperationFromWrites(writes) + if err != nil { + return sequences.OnChainOutput{}, fmt.Errorf("failed to create batch operation from writes: %w", err) + } + + return sequences.OnChainOutput{ + BatchOps: []mcms_types.BatchOperation{batchOp}, + }, nil + }, +)