diff --git a/legacy/mcms/changesets/set_config_mcms_test.go b/legacy/mcms/changesets/set_config_mcms_test.go index 8a64147..5dd6c5e 100644 --- a/legacy/mcms/changesets/set_config_mcms_test.go +++ b/legacy/mcms/changesets/set_config_mcms_test.go @@ -18,7 +18,7 @@ import ( solstate "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana" "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana/changesets" soltestutils "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana/testutils" - linkchangesets "github.com/smartcontractkit/cld-changesets/link/changesets" + linkchangesets "github.com/smartcontractkit/cld-changesets/tokens/link/changesets" cldfproposalutils "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils" @@ -300,7 +300,9 @@ func TestValidateV2(t *testing.T) { // Deploy MCMS and Timelock err = rt.Exec( - runtime.ChangesetTask(cldf.CreateLegacyChangeSet(linkchangesets.DeployLinkToken), []uint64{evmSelector}), + runtime.ChangesetTask(linkchangesets.DeployLinkTokenChangeset{}, linkchangesets.DeployLinkTokenInput{ + EVM: map[uint64]linkchangesets.EVMLinkConfig{evmSelector: {}}, + }), runtime.ChangesetTask(cldf.CreateLegacyChangeSet(DeployMCMSWithTimelockV2), map[uint64]cldfproposalutils.MCMSWithTimelockConfig{ evmSelector: config, solSelector: config, diff --git a/legacy/mcms/changesets/transfer_to_mcms_with_timelock_test.go b/legacy/mcms/changesets/transfer_to_mcms_with_timelock_test.go index 5a428e9..31b2346 100644 --- a/legacy/mcms/changesets/transfer_to_mcms_with_timelock_test.go +++ b/legacy/mcms/changesets/transfer_to_mcms_with_timelock_test.go @@ -10,18 +10,17 @@ import ( "github.com/ethereum/go-ethereum/common" chain_selectors "github.com/smartcontractkit/chain-selectors" cldf_evm "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" linkcontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/link" "github.com/smartcontractkit/chainlink-evm/gethwrappers/shared/generated/initial/link_token" "github.com/stretchr/testify/require" "github.com/smartcontractkit/cld-changesets/internal/semvers" evmstate "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/evm" - linkchangesets "github.com/smartcontractkit/cld-changesets/link/changesets" + linkchangesets "github.com/smartcontractkit/cld-changesets/tokens/link/changesets" cldfproposalutils "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils" - "github.com/smartcontractkit/chainlink-deployments-framework/datastore" - "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" @@ -42,9 +41,10 @@ func TestTransferToMCMSWithTimelockV2(t *testing.T) { chain := rt.Environment().BlockChains.EVMChains()[selector] - // Setup contracts err = rt.Exec( - runtime.ChangesetTask(cldf.CreateLegacyChangeSet(linkchangesets.DeployLinkToken), []uint64{selector}), + runtime.ChangesetTask(linkchangesets.DeployLinkTokenChangeset{}, linkchangesets.DeployLinkTokenInput{ + EVM: map[uint64]linkchangesets.EVMLinkConfig{selector: {}}, + }), runtime.ChangesetTask(cldf.CreateLegacyChangeSet(DeployMCMSWithTimelockV2), map[uint64]cldfproposalutils.MCMSWithTimelockConfig{ selector: cldftesthelpers.SingleGroupTimelockConfig(t), }), @@ -57,7 +57,7 @@ func TestTransferToMCMSWithTimelockV2(t *testing.T) { state, err := evmstate.MaybeLoadMCMSWithTimelockChainState(chain, addrs) require.NoError(t, err) - linkToken, err := loadLinkTokenFromAddressBook(chain, addrs) + linkToken, err := loadLinkTokenFromDataStore(chain, rt.State().DataStore) require.NoError(t, err) err = rt.Exec( @@ -75,15 +75,10 @@ func TestTransferToMCMSWithTimelockV2(t *testing.T) { require.Len(t, rt.State().Proposals, 1) require.True(t, rt.State().Proposals[0].IsExecuted) - // We expect now that the link token is owned by the MCMS timelock. - linkToken, err = loadLinkTokenFromAddressBook(chain, addrs) - require.NoError(t, err) - o, err := linkToken.Owner(nil) require.NoError(t, err) require.Equal(t, state.Timelock.Address(), o) - // Try a rollback to the deployer. err = rt.Exec( runtime.ChangesetTask(cldf.CreateLegacyChangeSet(TransferToDeployer), TransferToDeployerConfig{ ContractAddress: linkToken.Address(), @@ -108,9 +103,10 @@ func TestTransferToMCMSWithTimelockV2DataStore(t *testing.T) { chain := rt.Environment().BlockChains.EVMChains()[selector] - // Setup contracts err = rt.Exec( - runtime.ChangesetTask(cldf.CreateLegacyChangeSet(linkchangesets.DeployLinkToken), []uint64{selector}), + runtime.ChangesetTask(linkchangesets.DeployLinkTokenChangeset{}, linkchangesets.DeployLinkTokenInput{ + EVM: map[uint64]linkchangesets.EVMLinkConfig{selector: {}}, + }), runtime.ChangesetTask(cldf.CreateLegacyChangeSet(DeployMCMSWithTimelockV2), map[uint64]cldfproposalutils.MCMSWithTimelockConfig{ selector: cldftesthelpers.SingleGroupTimelockConfig(t), }), @@ -126,16 +122,6 @@ func TestTransferToMCMSWithTimelockV2DataStore(t *testing.T) { linkToken, err := loadLinkTokenFromDataStore(chain, rt.State().DataStore) require.NoError(t, err) - // Remove LinkToken from AddressBook to simulate datastore only having the contract address - // TODO: migrate DeployLinkToken to use datastore only - linkAb := cldf.NewMemoryAddressBookFromMap(map[uint64]map[string]cldf.TypeAndVersion{ - selector: { - linkToken.Address().String(): cldf.NewTypeAndVersion(linkcontracts.LinkToken, semvers.V1_0_0), - }, - }) - err = rt.State().AddressBook.Remove(linkAb) - require.NoError(t, err) - err = rt.Exec( runtime.ChangesetTask(cldf.CreateLegacyChangeSet(TransferToMCMSWithTimelockV2), TransferToMCMSWithTimelockConfig{ ContractsByChain: map[uint64][]common.Address{ @@ -151,12 +137,10 @@ func TestTransferToMCMSWithTimelockV2DataStore(t *testing.T) { require.Len(t, rt.State().Proposals, 1) require.True(t, rt.State().Proposals[0].IsExecuted) - // We expect now that the link token is owned by the MCMS timelock. o, err := linkToken.Owner(nil) require.NoError(t, err) require.Equal(t, state.Timelock.Address(), o) - // Try a rollback to the deployer. err = rt.Exec( runtime.ChangesetTask(cldf.CreateLegacyChangeSet(TransferToDeployer), TransferToDeployerConfig{ ContractAddress: linkToken.Address(), @@ -181,7 +165,6 @@ func TestRenounceTimelockDeployerConfigValidate(t *testing.T) { )) require.NoError(t, err) - // Deploy MCMS to selector 1 only, so we have a chain without MCMS err = rt.Exec( runtime.ChangesetTask(cldf.CreateLegacyChangeSet(DeployMCMSWithTimelockV2), map[uint64]cldfproposalutils.MCMSWithTimelockConfig{ selector1: cldftesthelpers.SingleGroupTimelockConfig(t), @@ -224,7 +207,6 @@ func TestRenounceTimelockDeployerConfigValidate(t *testing.T) { config: RenounceTimelockDeployerConfig{ ChainSel: selector2, }, - // chain does not match any existing addresses err: "timelock not found on chain 5548718428018410741", }, } { @@ -272,7 +254,6 @@ func TestRenounceTimelockDeployer(t *testing.T) { //nolint:paralleltest require.NoError(t, err) require.Equal(t, int64(2), r.Int64()) - // Revoke Deployer err = rt.Exec( runtime.ChangesetTask(cldf.CreateLegacyChangeSet(RenounceTimelockDeployer), RenounceTimelockDeployerConfig{ ChainSel: selector, @@ -280,44 +261,20 @@ func TestRenounceTimelockDeployer(t *testing.T) { //nolint:paralleltest ) require.NoError(t, err) - // Check that the deployer is no longer an admin r, err = tl.GetRoleMemberCount(&bind.CallOpts{}, adminRole) require.NoError(t, err) require.Equal(t, int64(1), r.Int64()) - // Retrieve the admin address admin, err := tl.GetRoleMember(&bind.CallOpts{}, adminRole, big.NewInt(0)) require.NoError(t, err) - // Check that the admin is the timelock require.Equal(t, tl.Address(), admin) } -func loadLinkTokenFromAddressBook(chain cldf_evm.Chain, addresses map[string]cldf.TypeAndVersion) (*link_token.LinkToken, error) { - linkToken := cldf.NewTypeAndVersion(linkcontracts.LinkToken, semvers.V1_0_0) - - // Convert map keys to a slice - wantTypes := []cldf.TypeAndVersion{linkToken} - - // Ensure we either have the bundle or not. - _, err := cldf.EnsureDeduped(addresses, wantTypes) - if err != nil { - return nil, fmt.Errorf("unable to check link token on chain %s error: %w", chain.Name(), err) - } - - for address, tvStr := range addresses { - if tvStr.Type == linkToken.Type && tvStr.Version.String() == linkToken.Version.String() { - return link_token.NewLinkToken(common.HexToAddress(address), chain.Client) - } - } - - return nil, fmt.Errorf("link token not found on chain %s", chain.Name()) -} - -func loadLinkTokenFromDataStore(chain cldf_evm.Chain, dataStore datastore.DataStore) (*link_token.LinkToken, error) { - linkToken := cldf.NewTypeAndVersion(linkcontracts.LinkToken, semvers.V1_0_0) +func loadLinkTokenFromDataStore(chain cldf_evm.Chain, ds datastore.DataStore) (*link_token.LinkToken, error) { + linkTokenTV := cldf.NewTypeAndVersion(linkcontracts.LinkToken, semvers.V1_0_0) - refs, err := dataStore.Addresses().Fetch() + refs, err := ds.Addresses().Fetch() if err != nil { return nil, err } @@ -327,7 +284,7 @@ func loadLinkTokenFromDataStore(chain cldf_evm.Chain, dataStore datastore.DataSt continue } - if ref.Type == datastore.ContractType(linkToken.Type.String()) && ref.Version.String() == linkToken.Version.String() { + if ref.Type == datastore.ContractType(linkTokenTV.Type.String()) && ref.Version != nil && ref.Version.String() == linkTokenTV.Version.String() { return link_token.NewLinkToken(common.HexToAddress(ref.Address), chain.Client) } } diff --git a/tokens/link/changesets/deploy_link_token.go b/tokens/link/changesets/deploy_link_token.go new file mode 100644 index 0000000..cbc8435 --- /dev/null +++ b/tokens/link/changesets/deploy_link_token.go @@ -0,0 +1,170 @@ +// Package changesets provides reusable LINK token changesets. +package changesets + +import ( + "errors" + "fmt" + + "github.com/gagliardetto/solana-go" + chainsel "github.com/smartcontractkit/chain-selectors" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + cldfops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + + opsevm "github.com/smartcontractkit/cld-changesets/pkg/family/evm/operations" + linkops "github.com/smartcontractkit/cld-changesets/tokens/link/operations" +) + +var _ cldf.ChangeSetV2[DeployLinkTokenInput] = DeployLinkTokenChangeset{} + +// EVMLinkVariant selects the EVM LINK token contract to deploy. +// The zero value deploys the burn/mint ERC677 variant (the default for all modern chains). +type EVMLinkVariant string + +const ( + // EVMLinkBurnMint deploys a burn/mint ERC677 LINK token. This is the default. + EVMLinkBurnMint EVMLinkVariant = "" + // EVMLinkStatic deploys a non-burn/mint static LINK token for chains that do not + // support the burn/mint interface. + EVMLinkStatic EVMLinkVariant = "static" +) + +// EVMLinkConfig holds per-chain configuration for EVM LINK token deployment. +type EVMLinkConfig struct { + Variant EVMLinkVariant `yaml:"variant,omitempty" json:"variant,omitempty"` + Qualifier string `yaml:"qualifier,omitempty" json:"qualifier,omitempty"` +} + +// SolanaLinkConfig holds per-chain configuration for Solana LINK token deployment. +type SolanaLinkConfig struct { + TokenPrivKey solana.PrivateKey `yaml:"tokenPrivKey" json:"tokenPrivKey"` + TokenDecimals uint8 `yaml:"tokenDecimals" json:"tokenDecimals"` + Qualifier string `yaml:"qualifier,omitempty" json:"qualifier,omitempty"` +} + +// DeployLinkTokenInput specifies which chains to deploy LINK tokens to and how. +type DeployLinkTokenInput struct { + EVM map[uint64]EVMLinkConfig `yaml:"evm,omitempty" json:"evm,omitempty"` + Solana map[uint64]SolanaLinkConfig `yaml:"solana,omitempty" json:"solana,omitempty"` +} + +// DeployLinkTokenChangeset deploys LINK tokens across EVM and Solana chains. +type DeployLinkTokenChangeset struct{} + +func (DeployLinkTokenChangeset) VerifyPreconditions(e cldf.Environment, input DeployLinkTokenInput) error { + if len(input.EVM) == 0 && len(input.Solana) == 0 { + return errors.New("no chains specified: at least one EVM or Solana chain is required") + } + + for sel, cfg := range input.EVM { + if !e.BlockChains.Exists(sel) { + return fmt.Errorf("EVM chain %d not found in environment", sel) + } + + if err := validateSelectorsFamily([]uint64{sel}, chainsel.FamilyEVM); err != nil { + return err + } + + if cfg.Variant != EVMLinkBurnMint && cfg.Variant != EVMLinkStatic { + return fmt.Errorf("unknown EVM LINK variant %q for chain %d: must be %q or %q", cfg.Variant, sel, EVMLinkBurnMint, EVMLinkStatic) + } + + tv := linkTokenTypeAndVersion() + if cfg.Variant == EVMLinkStatic { + tv = staticLinkTokenTypeAndVersion() + } + + if err := validateNoExistingContract(e, []uint64{sel}, tv, cfg.Qualifier); err != nil { + return err + } + } + + for sel, cfg := range input.Solana { + if !e.BlockChains.Exists(sel) { + return fmt.Errorf("solana chain %d not found in environment", sel) + } + + if err := validateSelectorsFamily([]uint64{sel}, chainsel.FamilySolana); err != nil { + return err + } + + if len(cfg.TokenPrivKey) == 0 { + return fmt.Errorf("solana chain %d: TokenPrivKey must be set", sel) + } + + if err := validateNoExistingContract(e, []uint64{sel}, linkTokenTypeAndVersion(), cfg.Qualifier); err != nil { + return err + } + } + + return nil +} + +func (DeployLinkTokenChangeset) Apply(e cldf.Environment, input DeployLinkTokenInput) (cldf.ChangesetOutput, error) { + ds := datastore.NewMemoryDataStore() + allReports := make([]cldfops.Report[any, any], 0, len(input.EVM)+len(input.Solana)) + + for sel, cfg := range input.EVM { + chain, ok := e.BlockChains.EVMChains()[sel] + if !ok { + return cldf.ChangesetOutput{}, fmt.Errorf("EVM chain not found in environment: %d", sel) + } + + op := linkops.OpEVMDeployLinkToken + tv := linkTokenTypeAndVersion() + if cfg.Variant == EVMLinkStatic { + op = linkops.OpEVMDeployStaticLinkToken + tv = staticLinkTokenTypeAndVersion() + } + + qualifier := cfg.Qualifier + report, err := cldfops.ExecuteOperation( + e.OperationsBundle, + op, + chain, + opsevm.EVMDeployInput[any]{ChainSelector: sel, Qualifier: &qualifier}, + ) + if err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("failed to deploy link token for chain %d: %w", sel, err) + } + + addr := report.Output.Address.String() + if err := saveAddressRef(ds, sel, addr, tv, cfg.Qualifier); err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("failed to save link token address for chain %d: %w", sel, err) + } + + allReports = append(allReports, report.ToGenericReport()) + e.Logger.Infow("Deployed link token", "chain", sel, "addr", addr, "variant", tv.Type) + } + + tv := linkTokenTypeAndVersion() + for sel, cfg := range input.Solana { + chain, ok := e.BlockChains.SolanaChains()[sel] + if !ok { + return cldf.ChangesetOutput{}, fmt.Errorf("solana chain not found in environment: %d", sel) + } + + report, err := cldfops.ExecuteOperation( + e.OperationsBundle, + linkops.OpSolanaDeployLinkToken, + chain, + linkops.SolanaLinkDeployInput{ + MintKey: cfg.TokenPrivKey, + Decimals: cfg.TokenDecimals, + }, + ) + if err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("failed to deploy Solana link token for chain %d: %w", sel, err) + } + + addr := report.Output.Address + if err := saveAddressRef(ds, sel, addr, tv, cfg.Qualifier); err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("failed to save Solana link token address for chain %d: %w", sel, err) + } + + allReports = append(allReports, report.ToGenericReport()) + e.Logger.Infow("Deployed Solana link token", "chain", sel, "addr", addr) + } + + return cldf.ChangesetOutput{DataStore: ds, Reports: allReports}, nil +} diff --git a/tokens/link/changesets/deploy_link_token_test.go b/tokens/link/changesets/deploy_link_token_test.go new file mode 100644 index 0000000..85efcec --- /dev/null +++ b/tokens/link/changesets/deploy_link_token_test.go @@ -0,0 +1,312 @@ +package changesets + +import ( + "testing" + + "github.com/gagliardetto/solana-go" + chain_selectors "github.com/smartcontractkit/chain-selectors" + "github.com/stretchr/testify/require" + + cldf_chain "github.com/smartcontractkit/chainlink-deployments-framework/chain" + cldf_evm "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm" + cldf_solana "github.com/smartcontractkit/chainlink-deployments-framework/chain/solana" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + linkcontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/link" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/runtime" + + "github.com/smartcontractkit/cld-changesets/internal/semvers" +) + +func TestDeployLinkToken(t *testing.T) { + t.Parallel() + + selectors := []uint64{ + chain_selectors.TEST_90000001.Selector, + chain_selectors.TEST_90000002.Selector, + } + rt, err := runtime.New(t.Context(), runtime.WithEnvOpts( + environment.WithEVMSimulated(t, selectors), + )) + require.NoError(t, err) + + err = rt.Exec(runtime.ChangesetTask(DeployLinkTokenChangeset{}, DeployLinkTokenInput{ + EVM: map[uint64]EVMLinkConfig{ + selectors[0]: {}, + selectors[1]: {}, + }, + })) + require.NoError(t, err) + + refs, err := rt.State().DataStore.Addresses().Fetch() + require.NoError(t, err) + require.Len(t, refs, len(selectors)) + for _, ref := range refs { + require.Equal(t, datastore.ContractType(linkcontracts.LinkToken), ref.Type) + require.True(t, semvers.V1_0_0.Equal(ref.Version)) + } +} + +func TestDeployStaticLinkToken(t *testing.T) { + t.Parallel() + + selector := chain_selectors.TEST_90000001.Selector + rt, err := runtime.New(t.Context(), runtime.WithEnvOpts( + environment.WithEVMSimulated(t, []uint64{selector}), + )) + require.NoError(t, err) + + err = rt.Exec(runtime.ChangesetTask(DeployLinkTokenChangeset{}, DeployLinkTokenInput{ + EVM: map[uint64]EVMLinkConfig{ + selector: {Variant: EVMLinkStatic}, + }, + })) + require.NoError(t, err) + + refs, err := rt.State().DataStore.Addresses().Fetch() + require.NoError(t, err) + require.Len(t, refs, 1) + require.Equal(t, datastore.ContractType(linkcontracts.StaticLinkToken), refs[0].Type) + require.True(t, semvers.V1_0_0.Equal(refs[0].Version)) +} + +func TestDeployLinkTokenZk(t *testing.T) { + t.Skip("https://smartcontract-it.atlassian.net/browse/CCIP-6427") + t.Parallel() + + selector := chain_selectors.TEST_90000050.Selector + rt, err := runtime.New(t.Context(), runtime.WithEnvOpts( + environment.WithZKSyncContainer(t, []uint64{selector}), + )) + require.NoError(t, err) + + err = rt.Exec(runtime.ChangesetTask(DeployLinkTokenChangeset{}, DeployLinkTokenInput{ + EVM: map[uint64]EVMLinkConfig{selector: {}}, + })) + require.NoError(t, err) + + refs, err := rt.State().DataStore.Addresses().Fetch() + require.NoError(t, err) + require.Len(t, refs, 1) + require.Equal(t, datastore.ContractType(linkcontracts.LinkToken), refs[0].Type) + require.True(t, semvers.V1_0_0.Equal(refs[0].Version)) +} + +func TestDeploySolanaLinkToken(t *testing.T) { + t.Skip("requires Solana validator container") + t.Parallel() + + solSelector := chain_selectors.TEST_22222222222222222222222222222222222222222222.Selector + rt, err := runtime.New(t.Context(), runtime.WithEnvOpts( + environment.WithSolanaContainer(t, []uint64{solSelector}, "", nil), + )) + require.NoError(t, err) + + mintKey, err := solana.NewRandomPrivateKey() + require.NoError(t, err) + + err = rt.Exec(runtime.ChangesetTask(DeployLinkTokenChangeset{}, DeployLinkTokenInput{ + Solana: map[uint64]SolanaLinkConfig{ + solSelector: {TokenPrivKey: mintKey, TokenDecimals: 9}, + }, + })) + require.NoError(t, err) + + refs, err := rt.State().DataStore.Addresses().Fetch() + require.NoError(t, err) + require.Len(t, refs, 1) + require.Equal(t, datastore.ContractType(linkcontracts.LinkToken), refs[0].Type) + require.True(t, semvers.V1_0_0.Equal(refs[0].Version)) + require.Equal(t, mintKey.PublicKey().String(), refs[0].Address) +} + +func TestDeployLinkTokenRejectsWrongFamilyBeforeDeploy(t *testing.T) { + t.Parallel() + + evmSelector := chain_selectors.TEST_90000001.Selector + solSelector := chain_selectors.TEST_22222222222222222222222222222222222222222222.Selector + env := cldf.Environment{ + BlockChains: cldf_chain.NewBlockChainsFromSlice([]cldf_chain.BlockChain{ + cldf_evm.Chain{Selector: evmSelector}, + cldf_solana.Chain{Selector: solSelector}, + }), + } + + err := DeployLinkTokenChangeset{}.VerifyPreconditions(env, DeployLinkTokenInput{ + EVM: map[uint64]EVMLinkConfig{solSelector: {}}, + }) + require.ErrorContains(t, err, "is not in the evm family") + + err = DeployLinkTokenChangeset{}.VerifyPreconditions(env, DeployLinkTokenInput{ + Solana: map[uint64]SolanaLinkConfig{evmSelector: {}}, + }) + require.ErrorContains(t, err, "is not in the solana family") +} + +func TestDeployLinkTokenRejectsExistingStateBeforeDeploy(t *testing.T) { + t.Parallel() + + evmSelector := chain_selectors.TEST_90000001.Selector + solSelector := chain_selectors.TEST_22222222222222222222222222222222222222222222.Selector + const ( + evmAddress = "0xeC91988D7dD84d8adE801b739172ad15c860A700" + solAddress = "J6oVJ42pE6eXdTCcCidhjzHWS7Sxz6yMsXHxXphT1U7Y" + ) + + tcs := []struct { + name string + env cldf.Environment + input DeployLinkTokenInput + wantErr string + }{ + { + name: "burn/mint link token already exists", + env: cldf.Environment{ + BlockChains: cldf_chain.NewBlockChainsFromSlice([]cldf_chain.BlockChain{ + cldf_evm.Chain{Selector: evmSelector}, + }), + DataStore: datastoreWith(t, evmSelector, evmAddress, linkTokenTypeAndVersion(), ""), + }, + input: DeployLinkTokenInput{EVM: map[uint64]EVMLinkConfig{evmSelector: {}}}, + wantErr: "LinkToken contract already exists", + }, + { + name: "static link token already exists", + env: cldf.Environment{ + BlockChains: cldf_chain.NewBlockChainsFromSlice([]cldf_chain.BlockChain{ + cldf_evm.Chain{Selector: evmSelector}, + }), + DataStore: datastoreWith(t, evmSelector, evmAddress, staticLinkTokenTypeAndVersion(), ""), + }, + input: DeployLinkTokenInput{EVM: map[uint64]EVMLinkConfig{evmSelector: {Variant: EVMLinkStatic}}}, + wantErr: "StaticLinkToken contract already exists", + }, + { + name: "solana link token already exists", + env: cldf.Environment{ + BlockChains: cldf_chain.NewBlockChainsFromSlice([]cldf_chain.BlockChain{ + cldf_solana.Chain{Selector: solSelector}, + }), + DataStore: datastoreWith(t, solSelector, solAddress, linkTokenTypeAndVersion(), ""), + }, + input: func() DeployLinkTokenInput { + key, err := solana.NewRandomPrivateKey() + require.NoError(t, err) + + return DeployLinkTokenInput{Solana: map[uint64]SolanaLinkConfig{solSelector: {TokenPrivKey: key}}} + }(), + wantErr: "LinkToken contract already exists", + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := DeployLinkTokenChangeset{}.VerifyPreconditions(tc.env, tc.input) + require.ErrorContains(t, err, tc.wantErr) + }) + } +} + +func TestDeployLinkTokenEmptyInputRejected(t *testing.T) { + t.Parallel() + + err := DeployLinkTokenChangeset{}.VerifyPreconditions(cldf.Environment{}, DeployLinkTokenInput{}) + require.ErrorContains(t, err, "no chains specified") +} + +func TestDeployLinkTokenRejectsMissingMintKey(t *testing.T) { + t.Parallel() + + solSelector := chain_selectors.TEST_22222222222222222222222222222222222222222222.Selector + env := cldf.Environment{ + BlockChains: cldf_chain.NewBlockChainsFromSlice([]cldf_chain.BlockChain{ + cldf_solana.Chain{Selector: solSelector}, + }), + } + + err := DeployLinkTokenChangeset{}.VerifyPreconditions(env, DeployLinkTokenInput{ + Solana: map[uint64]SolanaLinkConfig{solSelector: {}}, + }) + require.ErrorContains(t, err, "TokenPrivKey must be set") +} + +func TestDeployLinkTokenRejectsUnknownVariant(t *testing.T) { + t.Parallel() + + evmSelector := chain_selectors.TEST_90000001.Selector + env := cldf.Environment{ + BlockChains: cldf_chain.NewBlockChainsFromSlice([]cldf_chain.BlockChain{ + cldf_evm.Chain{Selector: evmSelector}, + }), + } + + err := DeployLinkTokenChangeset{}.VerifyPreconditions(env, DeployLinkTokenInput{ + EVM: map[uint64]EVMLinkConfig{evmSelector: {Variant: "invalid"}}, + }) + require.ErrorContains(t, err, "unknown EVM LINK variant") +} + +func TestDeployLinkTokenBurnMintAndStaticSameInput(t *testing.T) { + t.Parallel() + + selectors := []uint64{ + chain_selectors.TEST_90000001.Selector, + chain_selectors.TEST_90000002.Selector, + } + rt, err := runtime.New(t.Context(), runtime.WithEnvOpts( + environment.WithEVMSimulated(t, selectors), + )) + require.NoError(t, err) + + err = rt.Exec(runtime.ChangesetTask(DeployLinkTokenChangeset{}, DeployLinkTokenInput{ + EVM: map[uint64]EVMLinkConfig{ + selectors[0]: {}, + selectors[1]: {Variant: EVMLinkStatic}, + }, + })) + require.NoError(t, err) + + refs, err := rt.State().DataStore.Addresses().Fetch() + require.NoError(t, err) + require.Len(t, refs, 2) + + byChain := make(map[uint64]datastore.ContractType, 2) + for _, ref := range refs { + byChain[ref.ChainSelector] = ref.Type + } + + require.Equal(t, datastore.ContractType(linkcontracts.LinkToken), byChain[selectors[0]]) + require.Equal(t, datastore.ContractType(linkcontracts.StaticLinkToken), byChain[selectors[1]]) +} + +func TestDeployLinkTokenDifferentQualifierDoesNotBlock(t *testing.T) { + t.Parallel() + + evmSelector := chain_selectors.TEST_90000001.Selector + const evmAddress = "0xeC91988D7dD84d8adE801b739172ad15c860A700" + + // A LINK token with qualifier "migrated" already exists. + // Deploying with qualifier "" (primary) should not be blocked. + env := cldf.Environment{ + BlockChains: cldf_chain.NewBlockChainsFromSlice([]cldf_chain.BlockChain{ + cldf_evm.Chain{Selector: evmSelector}, + }), + DataStore: datastoreWith(t, evmSelector, evmAddress, linkTokenTypeAndVersion(), "migrated"), + } + + err := DeployLinkTokenChangeset{}.VerifyPreconditions(env, DeployLinkTokenInput{ + EVM: map[uint64]EVMLinkConfig{evmSelector: {}}, + }) + require.NoError(t, err) +} + +func datastoreWith(t *testing.T, selector uint64, address string, tv cldf.TypeAndVersion, qualifier string) datastore.DataStore { + t.Helper() + + ds := datastore.NewMemoryDataStore() + require.NoError(t, saveAddressRef(ds, selector, address, tv, qualifier)) + + return ds.Seal() +} diff --git a/tokens/link/changesets/transfer_link_token.go b/tokens/link/changesets/transfer_link_token.go new file mode 100644 index 0000000..80bb0c7 --- /dev/null +++ b/tokens/link/changesets/transfer_link_token.go @@ -0,0 +1,176 @@ +package changesets + +import ( + "encoding/json" + "errors" + "fmt" + "math" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + linkcontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/link" + mcmscontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/mcms" + "github.com/smartcontractkit/chainlink-evm/gethwrappers/shared/generated/initial/link_token" + + "github.com/smartcontractkit/cld-changesets/internal/semvers" + + "github.com/smartcontractkit/mcms" + mcmstypes "github.com/smartcontractkit/mcms/types" +) + +const defaultProposalValidFor = 24 * time.Hour + +var _ cldf.ChangeSetV2[TransferLinkTokenInput] = TransferLinkTokenChangeset{} + +// TransferLinkTokenInput holds the parameters for an MCMS-gated LINK token transfer. +type TransferLinkTokenInput struct { + ChainSelector uint64 `json:"chainSelector" yaml:"chainSelector"` + To common.Address `json:"to" yaml:"to"` + Amount *big.Int `json:"amount" yaml:"amount"` + TimelockDelay mcmstypes.Duration `json:"timelockDelay" yaml:"timelockDelay"` + Qualifier string `json:"qualifier,omitempty" yaml:"qualifier,omitempty"` + ValidUntil *time.Time `json:"validUntil,omitempty" yaml:"validUntil,omitempty"` +} + +// TransferLinkTokenChangeset creates an MCMS Timelock proposal to transfer LINK tokens +// from a Timelock-controlled address to a recipient. +type TransferLinkTokenChangeset struct{} + +func (TransferLinkTokenChangeset) VerifyPreconditions(e cldf.Environment, input TransferLinkTokenInput) error { + if input.ChainSelector == 0 { + return errors.New("chain selector must be non-zero") + } + if input.To == (common.Address{}) { + return errors.New("recipient address must be non-zero") + } + if input.Amount == nil || input.Amount.Sign() <= 0 { + return errors.New("amount must be positive") + } + if e.DataStore == nil { + return errors.New("datastore is required") + } + + if _, err := e.DataStore.Addresses().Get(datastore.NewAddressRefKey( + input.ChainSelector, + datastore.ContractType(linkcontracts.LinkToken), + &semvers.V1_0_0, + input.Qualifier, + )); err != nil { + return fmt.Errorf("no LinkToken address found for chain selector %d: %w", input.ChainSelector, err) + } + if _, err := e.DataStore.Addresses().Get(datastore.NewAddressRefKey( + input.ChainSelector, + datastore.ContractType(mcmscontracts.ProposerManyChainMultisig), + &semvers.V1_0_0, + input.Qualifier, + )); err != nil { + return fmt.Errorf("no ProposerManyChainMultisig address found for chain selector %d: %w", input.ChainSelector, err) + } + if _, err := e.DataStore.Addresses().Get(datastore.NewAddressRefKey( + input.ChainSelector, + datastore.ContractType(mcmscontracts.RBACTimelock), + &semvers.V1_0_0, + input.Qualifier, + )); err != nil { + return fmt.Errorf("no RBACTimelock address found for chain selector %d: %w", input.ChainSelector, err) + } + + return nil +} + +func (TransferLinkTokenChangeset) Apply(e cldf.Environment, input TransferLinkTokenInput) (cldf.ChangesetOutput, error) { + chain, ok := e.BlockChains.EVMChains()[input.ChainSelector] + if !ok { + return cldf.ChangesetOutput{}, fmt.Errorf("chain not found in environment: %d", input.ChainSelector) + } + + linkRef, err := e.DataStore.Addresses().Get(datastore.NewAddressRefKey( + input.ChainSelector, + datastore.ContractType(linkcontracts.LinkToken), + &semvers.V1_0_0, + input.Qualifier, + )) + if err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("no LinkToken address found for chain selector %d: %w", input.ChainSelector, err) + } + proposerRef, err := e.DataStore.Addresses().Get(datastore.NewAddressRefKey( + input.ChainSelector, + datastore.ContractType(mcmscontracts.ProposerManyChainMultisig), + &semvers.V1_0_0, + input.Qualifier, + )) + if err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("no ProposerManyChainMultisig address found for chain selector %d: %w", input.ChainSelector, err) + } + timelockRef, err := e.DataStore.Addresses().Get(datastore.NewAddressRefKey( + input.ChainSelector, + datastore.ContractType(mcmscontracts.RBACTimelock), + &semvers.V1_0_0, + input.Qualifier, + )) + if err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("no RBACTimelock address found for chain selector %d: %w", input.ChainSelector, err) + } + + token, err := link_token.NewLinkToken(common.HexToAddress(linkRef.Address), chain.Client) + if err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("failed to instantiate link token contract: %w", err) + } + + tx, err := token.Transfer(cldf.SimTransactOpts(), input.To, input.Amount) + if err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("failed to simulate link token transfer: %w", err) + } + + validUntil := time.Now().Add(defaultProposalValidFor) + if input.ValidUntil != nil { + validUntil = *input.ValidUntil + } + + unixTs := validUntil.Unix() + if unixTs < 0 || unixTs > math.MaxUint32 { + return cldf.ChangesetOutput{}, fmt.Errorf("validUntil %s is out of uint32 unix range", validUntil.Format(time.RFC3339)) + } + + proposal, err := mcms.NewTimelockProposalBuilder(). + SetAction(mcmstypes.TimelockActionSchedule). + SetTimelockAddresses(map[mcmstypes.ChainSelector]string{ + mcmstypes.ChainSelector(input.ChainSelector): timelockRef.Address, + }). + SetVersion("v1"). + SetValidUntil(uint32(unixTs)). + SetChainMetadata(map[mcmstypes.ChainSelector]mcmstypes.ChainMetadata{ + mcmstypes.ChainSelector(input.ChainSelector): { + StartingOpCount: 0, + MCMAddress: proposerRef.Address, + }, + }). + SetOperations([]mcmstypes.BatchOperation{ + { + ChainSelector: mcmstypes.ChainSelector(input.ChainSelector), + Transactions: []mcmstypes.Transaction{ + { + OperationMetadata: mcmstypes.OperationMetadata{ + ContractType: "LinkToken", + Tags: []string{"transfer"}, + }, + To: token.Address().Hex(), + Data: tx.Data(), + AdditionalFields: json.RawMessage(`{"value": 0}`), + }, + }, + }, + }). + SetDelay(input.TimelockDelay). + Build() + if err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("failed to build transfer proposal: %w", err) + } + + return cldf.ChangesetOutput{ + MCMSTimelockProposals: []mcms.TimelockProposal{*proposal}, + }, nil +} diff --git a/tokens/link/changesets/transfer_link_token_test.go b/tokens/link/changesets/transfer_link_token_test.go new file mode 100644 index 0000000..0cf36fb --- /dev/null +++ b/tokens/link/changesets/transfer_link_token_test.go @@ -0,0 +1,242 @@ +package changesets + +import ( + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + chain_selectors "github.com/smartcontractkit/chain-selectors" + cldf_chain "github.com/smartcontractkit/chainlink-deployments-framework/chain" + cldf_evm "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + mcmscontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/mcms" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" + "github.com/smartcontractkit/chainlink-evm/gethwrappers/shared/generated/initial/link_token" + mcmstypes "github.com/smartcontractkit/mcms/types" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cld-changesets/internal/semvers" +) + +func TestTransferLinkTokenRejectsPreconditions(t *testing.T) { + t.Parallel() + + selector := chain_selectors.TEST_90000001.Selector + + linkTV := linkTokenTypeAndVersion() + proposerTV := cldf.NewTypeAndVersion(mcmscontracts.ProposerManyChainMultisig, semvers.V1_0_0) + timelockTV := cldf.NewTypeAndVersion(mcmscontracts.RBACTimelock, semvers.V1_0_0) + + const ( + linkAddr = "0x1111111111111111111111111111111111111111" + proposerAddr = "0x2222222222222222222222222222222222222222" + timelockAddr = "0x3333333333333333333333333333333333333333" + ) + + fullDS := func(t *testing.T) datastore.DataStore { + t.Helper() + ds := datastore.NewMemoryDataStore() + require.NoError(t, saveAddressRef(ds, selector, linkAddr, linkTV, "")) + require.NoError(t, saveAddressRef(ds, selector, proposerAddr, proposerTV, "")) + require.NoError(t, saveAddressRef(ds, selector, timelockAddr, timelockTV, "")) + + return ds.Seal() + } + + baseEnv := cldf.Environment{ + BlockChains: cldf_chain.NewBlockChainsFromSlice([]cldf_chain.BlockChain{ + cldf_evm.Chain{Selector: selector}, + }), + } + + validInput := TransferLinkTokenInput{ + ChainSelector: selector, + To: common.HexToAddress("0x00000000000000000000000000000000000000Ab"), + Amount: big.NewInt(1_000_000), + } + + tcs := []struct { + name string + env cldf.Environment + input TransferLinkTokenInput + wantErr string + }{ + { + name: "zero chain selector", + env: cldf.Environment{DataStore: fullDS(t)}, + input: TransferLinkTokenInput{ChainSelector: 0, To: validInput.To, Amount: validInput.Amount}, + wantErr: "chain selector must be non-zero", + }, + { + name: "zero recipient", + env: cldf.Environment{DataStore: fullDS(t)}, + input: TransferLinkTokenInput{ChainSelector: selector, To: common.Address{}, Amount: validInput.Amount}, + wantErr: "recipient address must be non-zero", + }, + { + name: "nil amount", + env: cldf.Environment{DataStore: fullDS(t)}, + input: TransferLinkTokenInput{ChainSelector: selector, To: validInput.To, Amount: nil}, + wantErr: "amount must be positive", + }, + { + name: "nil datastore", + env: cldf.Environment{}, + input: validInput, + wantErr: "datastore is required", + }, + { + name: "missing link token", + env: func() cldf.Environment { + ds := datastore.NewMemoryDataStore() + require.NoError(t, saveAddressRef(ds, selector, proposerAddr, proposerTV, "")) + require.NoError(t, saveAddressRef(ds, selector, timelockAddr, timelockTV, "")) + e := baseEnv + e.DataStore = ds.Seal() + + return e + }(), + input: validInput, + wantErr: "no LinkToken address found", + }, + { + name: "missing proposer", + env: func() cldf.Environment { + ds := datastore.NewMemoryDataStore() + require.NoError(t, saveAddressRef(ds, selector, linkAddr, linkTV, "")) + require.NoError(t, saveAddressRef(ds, selector, timelockAddr, timelockTV, "")) + e := baseEnv + e.DataStore = ds.Seal() + + return e + }(), + input: validInput, + wantErr: "no ProposerManyChainMultisig address found", + }, + { + name: "missing timelock", + env: func() cldf.Environment { + ds := datastore.NewMemoryDataStore() + require.NoError(t, saveAddressRef(ds, selector, linkAddr, linkTV, "")) + require.NoError(t, saveAddressRef(ds, selector, proposerAddr, proposerTV, "")) + e := baseEnv + e.DataStore = ds.Seal() + + return e + }(), + input: validInput, + wantErr: "no RBACTimelock address found", + }, + { + name: "link token exists only under different qualifier", + env: func() cldf.Environment { + ds := datastore.NewMemoryDataStore() + require.NoError(t, saveAddressRef(ds, selector, linkAddr, linkTV, "staging")) + require.NoError(t, saveAddressRef(ds, selector, proposerAddr, proposerTV, "")) + require.NoError(t, saveAddressRef(ds, selector, timelockAddr, timelockTV, "")) + e := baseEnv + e.DataStore = ds.Seal() + + return e + }(), + input: validInput, // Qualifier defaults to "" + wantErr: "no LinkToken address found", + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := TransferLinkTokenChangeset{}.VerifyPreconditions(tc.env, tc.input) + require.ErrorContains(t, err, tc.wantErr) + }) + } +} + +func TestTransferLinkTokenBuildsProposal(t *testing.T) { + t.Parallel() + + selector := chain_selectors.TEST_90000001.Selector + env, err := environment.New(t.Context(), environment.WithEVMSimulated(t, []uint64{selector})) + require.NoError(t, err) + + chain := env.BlockChains.EVMChains()[selector] + + linkAddr, deployTx, _, err := link_token.DeployLinkToken(chain.DeployerKey, chain.Client) + require.NoError(t, err) + _, err = chain.Confirm(deployTx) + require.NoError(t, err) + + proposerTV := cldf.NewTypeAndVersion(mcmscontracts.ProposerManyChainMultisig, semvers.V1_0_0) + timelockTV := cldf.NewTypeAndVersion(mcmscontracts.RBACTimelock, semvers.V1_0_0) + const ( + proposerAddr = "0x2222222222222222222222222222222222222222" + timelockAddr = "0x3333333333333333333333333333333333333333" + ) + + ds := datastore.NewMemoryDataStore() + require.NoError(t, saveAddressRef(ds, selector, linkAddr.Hex(), linkTokenTypeAndVersion(), "")) + require.NoError(t, saveAddressRef(ds, selector, proposerAddr, proposerTV, "")) + require.NoError(t, saveAddressRef(ds, selector, timelockAddr, timelockTV, "")) + env.DataStore = ds.Seal() + + input := TransferLinkTokenInput{ + ChainSelector: selector, + To: common.HexToAddress("0x00000000000000000000000000000000000000Ab"), + Amount: big.NewInt(1_000_000), + TimelockDelay: mcmstypes.Duration{}, + } + + err = TransferLinkTokenChangeset{}.VerifyPreconditions(*env, input) + require.NoError(t, err) + + output, err := TransferLinkTokenChangeset{}.Apply(*env, input) + require.NoError(t, err) + require.Len(t, output.MCMSTimelockProposals, 1) + require.Len(t, output.MCMSTimelockProposals[0].Operations, 1) + require.Equal(t, linkAddr.Hex(), output.MCMSTimelockProposals[0].Operations[0].Transactions[0].To) +} + +func TestTransferLinkTokenValidUntilIsRespected(t *testing.T) { + t.Parallel() + + selector := chain_selectors.TEST_90000001.Selector + env, err := environment.New(t.Context(), environment.WithEVMSimulated(t, []uint64{selector})) + require.NoError(t, err) + + chain := env.BlockChains.EVMChains()[selector] + + linkAddr, deployTx, _, err := link_token.DeployLinkToken(chain.DeployerKey, chain.Client) + require.NoError(t, err) + _, err = chain.Confirm(deployTx) + require.NoError(t, err) + + proposerTV := cldf.NewTypeAndVersion(mcmscontracts.ProposerManyChainMultisig, semvers.V1_0_0) + timelockTV := cldf.NewTypeAndVersion(mcmscontracts.RBACTimelock, semvers.V1_0_0) + const ( + proposerAddr = "0x2222222222222222222222222222222222222222" + timelockAddr = "0x3333333333333333333333333333333333333333" + ) + + ds := datastore.NewMemoryDataStore() + require.NoError(t, saveAddressRef(ds, selector, linkAddr.Hex(), linkTokenTypeAndVersion(), "")) + require.NoError(t, saveAddressRef(ds, selector, proposerAddr, proposerTV, "")) + require.NoError(t, saveAddressRef(ds, selector, timelockAddr, timelockTV, "")) + env.DataStore = ds.Seal() + + // Use a fixed expiry far enough in the future that the proposal is valid. + fixedExpiry := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC) + input := TransferLinkTokenInput{ + ChainSelector: selector, + To: common.HexToAddress("0x00000000000000000000000000000000000000Ab"), + Amount: big.NewInt(1_000_000), + ValidUntil: &fixedExpiry, + } + + output, err := TransferLinkTokenChangeset{}.Apply(*env, input) + require.NoError(t, err) + // #nosec G115 -- fixedExpiry (year 2030) is well within uint32 range + require.Equal(t, uint32(fixedExpiry.Unix()), output.MCMSTimelockProposals[0].ValidUntil) +} diff --git a/tokens/link/changesets/validation.go b/tokens/link/changesets/validation.go new file mode 100644 index 0000000..d064e54 --- /dev/null +++ b/tokens/link/changesets/validation.go @@ -0,0 +1,66 @@ +package changesets + +import ( + "fmt" + + chainsel "github.com/smartcontractkit/chain-selectors" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + linkcontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/link" + + "github.com/smartcontractkit/cld-changesets/internal/semvers" +) + +func linkTokenTypeAndVersion() cldf.TypeAndVersion { + return cldf.NewTypeAndVersion(linkcontracts.LinkToken, semvers.V1_0_0) +} + +func staticLinkTokenTypeAndVersion() cldf.TypeAndVersion { + return cldf.NewTypeAndVersion(linkcontracts.StaticLinkToken, semvers.V1_0_0) +} + +func saveAddressRef(ds datastore.MutableDataStore, chainSelector uint64, address string, tv cldf.TypeAndVersion, qualifier string) error { + return ds.Addresses().Add(datastore.AddressRef{ + ChainSelector: chainSelector, + Address: address, + Type: datastore.ContractType(tv.Type.String()), + Version: &tv.Version, + Qualifier: qualifier, + Labels: datastore.NewLabelSet(), + }) +} + +func validateSelectorsFamily(chains []uint64, family string) error { + for _, chain := range chains { + selectorFamily, err := chainsel.GetSelectorFamily(chain) + if err != nil { + return fmt.Errorf("failed to get family for chain selector %d: %w", chain, err) + } + if selectorFamily != family { + return fmt.Errorf("chain selector %d is not in the %s family", chain, family) + } + } + + return nil +} + +func validateNoExistingContract(e cldf.Environment, chains []uint64, tv cldf.TypeAndVersion, qualifier string) error { + if e.DataStore == nil { + return nil + } + + for _, chain := range chains { + refs := e.DataStore.Addresses().Filter( + datastore.AddressRefByChainSelector(chain), + datastore.AddressRefByType(datastore.ContractType(tv.Type.String())), + datastore.AddressRefByQualifier(qualifier), + ) + for _, ref := range refs { + if ref.Version == nil || ref.Version.Equal(&tv.Version) { + return fmt.Errorf("%s contract already exists for chain selector %d in datastore", tv.Type, chain) + } + } + } + + return nil +} diff --git a/tokens/link/operations/evm_link_token.go b/tokens/link/operations/evm_link_token.go new file mode 100644 index 0000000..94b1c76 --- /dev/null +++ b/tokens/link/operations/evm_link_token.go @@ -0,0 +1,26 @@ +package operations + +import ( + "github.com/Masterminds/semver/v3" + "github.com/ethereum/go-ethereum/common" + linkcontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/link" + "github.com/smartcontractkit/chainlink-evm/gethwrappers/shared/generated/initial/link_token" + + "github.com/smartcontractkit/cld-changesets/internal/semvers" + opsevm "github.com/smartcontractkit/cld-changesets/pkg/family/evm/operations" +) + +// OpEVMDeployLinkToken deploys a burn/mint ERC677 LINK token contract, with ZkSync support. +var OpEVMDeployLinkToken = opsevm.NewEVMDeployOperation( + "evm-link-token-deploy", + semver.MustParse("1.0.0"), + "Deploys LINK token (burn/mint ERC677) contract", + linkcontracts.LinkToken, + link_token.LinkTokenMetaData, + &opsevm.ContractOpts{ + Version: &semvers.V1_0_0, + EVMBytecode: common.FromHex(link_token.LinkTokenBin), + ZkSyncVMBytecode: link_token.ZkBytecode, + }, + func(_ any) []any { return []any{} }, +) diff --git a/tokens/link/operations/evm_static_link_token.go b/tokens/link/operations/evm_static_link_token.go new file mode 100644 index 0000000..265ece7 --- /dev/null +++ b/tokens/link/operations/evm_static_link_token.go @@ -0,0 +1,25 @@ +package operations + +import ( + "github.com/Masterminds/semver/v3" + "github.com/ethereum/go-ethereum/common" + linkcontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/link" + "github.com/smartcontractkit/chainlink-evm/gethwrappers/generated/link_token_interface" + + "github.com/smartcontractkit/cld-changesets/internal/semvers" + opsevm "github.com/smartcontractkit/cld-changesets/pkg/family/evm/operations" +) + +// OpEVMDeployStaticLinkToken deploys a non-burn/mint static LINK token contract. +var OpEVMDeployStaticLinkToken = opsevm.NewEVMDeployOperation( + "evm-static-link-token-deploy", + semver.MustParse("1.0.0"), + "Deploys static LINK token (non-burn/mint) contract", + linkcontracts.StaticLinkToken, + link_token_interface.LinkTokenMetaData, + &opsevm.ContractOpts{ + Version: &semvers.V1_0_0, + EVMBytecode: common.FromHex(link_token_interface.LinkTokenBin), + }, + func(_ any) []any { return []any{} }, +) diff --git a/tokens/link/operations/solana_link_token.go b/tokens/link/operations/solana_link_token.go new file mode 100644 index 0000000..6297d06 --- /dev/null +++ b/tokens/link/operations/solana_link_token.go @@ -0,0 +1,47 @@ +package operations + +import ( + "fmt" + + "github.com/Masterminds/semver/v3" + "github.com/gagliardetto/solana-go" + solCommonUtil "github.com/smartcontractkit/chainlink-ccip/chains/solana/utils/common" + solTokenUtil "github.com/smartcontractkit/chainlink-ccip/chains/solana/utils/tokens" + cldf_solana "github.com/smartcontractkit/chainlink-deployments-framework/chain/solana" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" +) + +type SolanaLinkDeployInput struct { + MintKey solana.PrivateKey + Decimals uint8 +} + +type SolanaLinkDeployOutput struct { + Address string +} + +// OpSolanaDeployLinkToken deploys a LINK SPL token on a Solana chain. +var OpSolanaDeployLinkToken = operations.NewOperation( + "solana-link-token-deploy", + semver.MustParse("1.0.0"), + "Deploys LINK token (SPL token) on a Solana chain", + func(b operations.Bundle, chain cldf_solana.Chain, input SolanaLinkDeployInput) (SolanaLinkDeployOutput, error) { + instructions, err := solTokenUtil.CreateToken( + b.GetContext(), + solana.TokenProgramID, + input.MintKey.PublicKey(), + chain.DeployerKey.PublicKey(), + input.Decimals, + chain.Client, + cldf_solana.SolDefaultCommitment, + ) + if err != nil { + return SolanaLinkDeployOutput{}, fmt.Errorf("failed to generate token instructions: %w", err) + } + if err = chain.Confirm(instructions, solCommonUtil.AddSigners(input.MintKey)); err != nil { + return SolanaLinkDeployOutput{}, fmt.Errorf("failed to confirm token deployment: %w", err) + } + + return SolanaLinkDeployOutput{Address: input.MintKey.PublicKey().String()}, nil + }, +)