diff --git a/deployment/ccip/changeset/ccip-attestation/cs_add_evm_signer_registry_signers.go b/deployment/ccip/changeset/ccip-attestation/cs_add_evm_signer_registry_signers.go new file mode 100644 index 00000000000..693a1fd911c --- /dev/null +++ b/deployment/ccip/changeset/ccip-attestation/cs_add_evm_signer_registry_signers.go @@ -0,0 +1,352 @@ +package ccip_attestation + +import ( + "errors" + "fmt" + "slices" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + chain_selectors "github.com/smartcontractkit/chain-selectors" + mcms_types "github.com/smartcontractkit/mcms/types" + + cldf_evm "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm" + "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm/operations/contract" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + cldf_ops "github.com/smartcontractkit/chainlink-deployments-framework/operations" + + evm_datastore_utils "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/utils/datastore" + // Register the EVM MCMS reader used for owner and proposal resolution. + _ "github.com/smartcontractkit/chainlink-ccip/chains/evm/deployment/v1_0_0/adapters" + ccip_changesets "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/deployment" + "github.com/smartcontractkit/chainlink/deployment/ccip/shared" + signer_registry "github.com/smartcontractkit/chainlink/deployment/ccip/shared/bindings/signer_registry" +) + +// EVMSignerRegistryAddSignersChangeset adds missing signer entries to existing +// SignerRegistry 1.0.0 contracts on supported Base chains. +var EVMSignerRegistryAddSignersChangeset = cldf.CreateChangeSet( + addSigners, + verifyAddSigners, +) + +// Signer is one SignerRegistry entry. NewEVMAddress is optional and starts the +// signer with an in-progress key rotation. +type Signer struct { + EVMAddress common.Address `json:"evmAddress" yaml:"evmAddress"` + NewEVMAddress common.Address `json:"newEVMAddress" yaml:"newEVMAddress"` +} + +// AddSignersConfig defines the signer entries that should be present on each +// supported Base chain and the settings for any required MCMS proposal. +type AddSignersConfig struct { + SignersByChain map[uint64][]Signer `json:"signersByChain" yaml:"signersByChain"` + MCMS mcms.Input `json:"mcms" yaml:"mcms"` +} + +type addSignersPlan struct { + chain cldf_evm.Chain + registryAddress common.Address + signers []Signer + governed bool +} + +func verifyAddSigners(e cldf.Environment, cfg AddSignersConfig) error { + plans, err := prepareAddSigners(e, cfg) + if err != nil { + return err + } + return validateAddSignersProposal(e, cfg.MCMS, plans) +} + +func addSigners(e cldf.Environment, cfg AddSignersConfig) (cldf.ChangesetOutput, error) { + plans, err := prepareAddSigners(e, cfg) + if err != nil { + return cldf.ChangesetOutput{}, err + } + if err := validateAddSignersProposal(e, cfg.MCMS, plans); err != nil { + return cldf.ChangesetOutput{}, err + } + + reports := make([]cldf_ops.Report[any, any], 0, len(plans)) + batchOps := make([]mcms_types.BatchOperation, 0, len(plans)) + for _, plan := range plans { + e.Logger.Infow( + "Adding SignerRegistry entries", + "chainSelector", plan.chain.Selector, + "registry", plan.registryAddress, + "signerCount", len(plan.signers), + ) + input := contract.FunctionInput[[]Signer]{ + Address: plan.registryAddress, + ChainSelector: plan.chain.Selector, + Args: plan.signers, + } + // Live-state reconciliation provides idempotency. Force execution prevents a + // prior successful report from suppressing a legitimate add after removal. + report, executeErr := cldf_ops.ExecuteOperation( + e.OperationsBundle, + addSignersOperation, + plan.chain, + input, + cldf_ops.WithForceExecute[contract.FunctionInput[[]Signer], cldf_evm.Chain](), + ) + if report.ID != "" { + reports = append(reports, report.ToGenericReport()) + } + if executeErr != nil { + return cldf.ChangesetOutput{Reports: reports}, fmt.Errorf( + "failed to add signers to SignerRegistry on chain %d: %w", plan.chain.Selector, executeErr, + ) + } + + batchOp, batchErr := contract.NewBatchOperationFromWrites([]contract.WriteOutput{report.Output}) + if batchErr != nil { + return cldf.ChangesetOutput{Reports: reports}, fmt.Errorf( + "failed to create SignerRegistry batch operation on chain %d: %w", plan.chain.Selector, batchErr, + ) + } + if len(batchOp.Transactions) > 0 { + batchOps = append(batchOps, batchOp) + } + } + + return ccip_changesets.NewOutputBuilder(e, ccip_changesets.GetRegistry()). + WithReports(reports). + WithBatchOps(batchOps). + Build(cfg.MCMS) +} + +func prepareAddSigners( + e cldf.Environment, + cfg AddSignersConfig, +) ([]addSignersPlan, error) { + selectors, err := validateAddSignersConfig(e, cfg) + if err != nil { + return nil, err + } + + plans := make([]addSignersPlan, 0, len(selectors)) + for _, selector := range selectors { + chain := e.BlockChains.EVMChains()[selector] + registryAddress, err := resolveSignerRegistry(e, selector) + if err != nil { + return nil, fmt.Errorf("failed to resolve SignerRegistry on chain %d: %w", selector, err) + } + registry, err := signer_registry.NewSignerRegistry(registryAddress, chain.Client) + if err != nil { + return nil, fmt.Errorf("failed to bind SignerRegistry %s on chain %d: %w", registryAddress, selector, err) + } + + callOpts := &bind.CallOpts{Context: e.GetContext()} + currentSigners, err := registry.GetSigners(callOpts) + if err != nil { + return nil, fmt.Errorf("failed to read SignerRegistry signers on chain %d: %w", selector, err) + } + maxSigners, err := registry.GetMaxSigners(callOpts) + if err != nil { + return nil, fmt.Errorf("failed to read SignerRegistry capacity on chain %d: %w", selector, err) + } + owner, err := registry.Owner(callOpts) + if err != nil { + return nil, fmt.Errorf("failed to read SignerRegistry owner on chain %d: %w", selector, err) + } + if maxSigners == nil || !maxSigners.IsUint64() { + return nil, fmt.Errorf("SignerRegistry on chain %d returned invalid capacity %v", selector, maxSigners) + } + currentCount := uint64(len(currentSigners)) + maxCapacity := maxSigners.Uint64() + if currentCount > maxCapacity { + return nil, fmt.Errorf( + "SignerRegistry on chain %d has %d signers, exceeding its capacity %d", + selector, currentCount, maxCapacity, + ) + } + + governed, err := validateSignerRegistryOwner(e, cfg.MCMS, chain, owner) + if err != nil { + return nil, err + } + additions, err := reconcileSigners(selector, currentSigners, cfg.SignersByChain[selector]) + if err != nil { + return nil, err + } + if len(additions) == 0 { + e.Logger.Infow( + "SignerRegistry additions already applied; skipping", + "chainSelector", selector, + "registry", registryAddress, + ) + continue + } + additionCount := uint64(len(additions)) + if additionCount > maxCapacity-currentCount { + return nil, fmt.Errorf( + "adding %d signers on chain %d exceeds SignerRegistry capacity: %d current, %d maximum", + len(additions), selector, len(currentSigners), maxCapacity, + ) + } + + plans = append(plans, addSignersPlan{ + chain: chain, + registryAddress: registryAddress, + signers: additions, + governed: governed, + }) + } + + return plans, nil +} + +func validateAddSignersConfig( + e cldf.Environment, + cfg AddSignersConfig, +) ([]uint64, error) { + if len(cfg.SignersByChain) == 0 { + return nil, errors.New("no signer additions provided") + } + if e.DataStore == nil { + return nil, errors.New("environment DataStore is required") + } + + selectors := make([]uint64, 0, len(cfg.SignersByChain)) + for selector, signers := range cfg.SignersByChain { + if selector != BaseMainnetSelector && selector != BaseSepoliaSelector { + return nil, fmt.Errorf("chain selector %d is not a supported Base chain", selector) + } + chain, exists := e.BlockChains.EVMChains()[selector] + if !exists { + return nil, fmt.Errorf("EVM chain selector %d not found in environment", selector) + } + if chain.DeployerKey == nil { + return nil, fmt.Errorf("EVM chain selector %d has no deployer key", selector) + } + if err := validateSigners(signers); err != nil { + return nil, fmt.Errorf("invalid signer additions for chain %d: %w", selector, err) + } + selectors = append(selectors, selector) + } + slices.Sort(selectors) + return selectors, nil +} + +func resolveSignerRegistry(e cldf.Environment, selector uint64) (common.Address, error) { + return datastore_utils.FindAndFormatRef( + e.DataStore, + datastore.AddressRef{ + Type: datastore.ContractType(shared.EVMSignerRegistry), + Version: &deployment.Version1_0_0, + }, + selector, + evm_datastore_utils.ToNonZeroEVMAddress, + ) +} + +func validateSignerRegistryOwner( + e cldf.Environment, + mcmsCfg mcms.Input, + chain cldf_evm.Chain, + owner common.Address, +) (bool, error) { + if owner == chain.DeployerKey.From { + return false, nil + } + + reader, ok := ccip_changesets.GetRegistry().GetMCMSReader(chain_selectors.FamilyEVM) + if !ok { + return false, errors.New("no MCMS reader registered for EVM chains") + } + timelockRef, err := reader.GetTimelockRef(e, chain.Selector, mcmsCfg) + if err != nil { + return false, fmt.Errorf("failed to resolve MCMS timelock owner on chain %d: %w", chain.Selector, err) + } + timelockAddress, err := evm_datastore_utils.ToNonZeroEVMAddress(timelockRef) + if err != nil { + return false, fmt.Errorf("failed to resolve MCMS timelock owner on chain %d: %w", chain.Selector, err) + } + if owner != timelockAddress { + return false, fmt.Errorf( + "SignerRegistry on chain %d has unsupported owner %s; expected deployer %s or MCMS timelock %s", + chain.Selector, owner.Hex(), chain.DeployerKey.From.Hex(), timelockAddress.Hex(), + ) + } + return true, nil +} + +func reconcileSigners( + selector uint64, + current []signer_registry.ISignerRegistrySigner, + requested []Signer, +) ([]Signer, error) { + activeSigners := make(map[common.Address]signer_registry.ISignerRegistrySigner, len(current)) + usedKeys := make(map[common.Address]struct{}, len(current)*2) + for _, signer := range current { + activeSigners[signer.EvmAddress] = signer + usedKeys[signer.EvmAddress] = struct{}{} + if signer.NewEVMAddress != (common.Address{}) { + usedKeys[signer.NewEVMAddress] = struct{}{} + } + } + + additions := make([]Signer, 0, len(requested)) + for _, signer := range requested { + if existing, exists := activeSigners[signer.EVMAddress]; exists { + if signer.NewEVMAddress == (common.Address{}) || signer.NewEVMAddress == existing.NewEVMAddress { + continue + } + return nil, fmt.Errorf( + "signer %s on chain %d already exists with pending key %s; add-signers cannot change it to %s", + signer.EVMAddress.Hex(), selector, existing.NewEVMAddress.Hex(), signer.NewEVMAddress.Hex(), + ) + } + if _, exists := usedKeys[signer.EVMAddress]; exists { + return nil, fmt.Errorf("active key %s is already in use on chain %d", signer.EVMAddress.Hex(), selector) + } + if signer.NewEVMAddress != (common.Address{}) { + if _, exists := usedKeys[signer.NewEVMAddress]; exists { + return nil, fmt.Errorf("pending key %s is already in use on chain %d", signer.NewEVMAddress.Hex(), selector) + } + } + additions = append(additions, signer) + } + return additions, nil +} + +func validateAddSignersProposal( + e cldf.Environment, + mcmsCfg mcms.Input, + plans []addSignersPlan, +) error { + hasGovernedWrite := false + for _, plan := range plans { + if plan.governed { + hasGovernedWrite = true + break + } + } + if !hasGovernedWrite { + return nil + } + if err := mcmsCfg.Validate(); err != nil { + return fmt.Errorf("invalid MCMS proposal configuration: %w", err) + } + + reader, ok := ccip_changesets.GetRegistry().GetMCMSReader(chain_selectors.FamilyEVM) + if !ok { + return errors.New("no MCMS reader registered for EVM chains") + } + for _, plan := range plans { + if !plan.governed { + continue + } + if _, err := reader.GetChainMetadata(e, plan.chain.Selector, mcmsCfg); err != nil { + return fmt.Errorf("failed to validate MCMS metadata on chain %d: %w", plan.chain.Selector, err) + } + } + return nil +} diff --git a/deployment/ccip/changeset/ccip-attestation/cs_add_evm_signer_registry_signers_test.go b/deployment/ccip/changeset/ccip-attestation/cs_add_evm_signer_registry_signers_test.go new file mode 100644 index 00000000000..040ddf5af9c --- /dev/null +++ b/deployment/ccip/changeset/ccip-attestation/cs_add_evm_signer_registry_signers_test.go @@ -0,0 +1,421 @@ +package ccip_attestation + +import ( + "crypto/ecdsa" + "fmt" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + mcmschangesets "github.com/smartcontractkit/cld-changesets/legacy/mcms/changesets" + mcms_types "github.com/smartcontractkit/mcms/types" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm/operations/contract" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + cldfproposalutils "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils" + cldftesthelpers "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils/testhelpers" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/onchain" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/runtime" + + cciputils "github.com/smartcontractkit/chainlink-ccip/deployment/utils" + "github.com/smartcontractkit/chainlink-ccip/deployment/utils/mcms" + + "github.com/smartcontractkit/chainlink/deployment" + "github.com/smartcontractkit/chainlink/deployment/ccip/shared" + signer_registry "github.com/smartcontractkit/chainlink/deployment/ccip/shared/bindings/signer_registry" +) + +func TestEVMSignerRegistryAddSigners_DirectExpansionRetryAndReAdd(t *testing.T) { + initial := makeContractSigners(1, 5) + e := newAddSignersEnvironment(t, []uint64{BaseSepoliaSelector}, initial) + registry := getTestSignerRegistry(t, e, BaseSepoliaSelector) + additions := makeConfigSigners(101, 5) + cfg := AddSignersConfig{ + SignersByChain: map[uint64][]Signer{BaseSepoliaSelector: additions}, + MCMS: validAddSignersMCMSInput(), + } + + require.NoError(t, EVMSignerRegistryAddSignersChangeset.VerifyPreconditions(*e, cfg)) + out, err := EVMSignerRegistryAddSignersChangeset.Apply(*e, cfg) + require.NoError(t, err) + require.Len(t, out.Reports, 1) + require.Empty(t, out.MCMSTimelockProposals) + require.Nil(t, out.DataStore) + require.Equal(t, append(initial, toContractSigners(additions)...), readTestSigners(t, e, registry)) + + staleRetry := cfg + staleRetry.MCMS.ValidUntil = 1 + out, err = EVMSignerRegistryAddSignersChangeset.Apply(*e, staleRetry) + require.NoError(t, err) + require.Empty(t, out.Reports) + require.Empty(t, out.MCMSTimelockProposals) + + chain := e.BlockChains.EVMChains()[BaseSepoliaSelector] + removed := make([]common.Address, len(additions)) + for i, signer := range additions { + removed[i] = signer.EVMAddress + } + tx, err := registry.RemoveSigners(chain.DeployerKey, removed) + require.NoError(t, err) + _, err = chain.Confirm(tx) + require.NoError(t, err) + + require.Equal(t, initial, readTestSigners(t, e, registry)) + out, err = EVMSignerRegistryAddSignersChangeset.Apply(*e, cfg) + require.NoError(t, err) + require.Len(t, out.Reports, 1) + require.Len(t, readTestSigners(t, e, registry), 10) +} + +func TestEVMSignerRegistryAddSigners_MultiChainDeterministicAndPreflighted(t *testing.T) { + initial := makeContractSigners(1, 5) + selectors := []uint64{BaseMainnetSelector, BaseSepoliaSelector} + e := newAddSignersEnvironment(t, selectors, initial) + cfg := AddSignersConfig{ + SignersByChain: map[uint64][]Signer{ + BaseMainnetSelector: {makeConfigSigner(301)}, + BaseSepoliaSelector: {makeConfigSigner(201)}, + }, + } + + out, err := EVMSignerRegistryAddSignersChangeset.Apply(*e, cfg) + require.NoError(t, err) + require.Len(t, out.Reports, 2) + firstInput, ok := out.Reports[0].Input.(contract.FunctionInput[[]Signer]) + require.True(t, ok) + secondInput, ok := out.Reports[1].Input.(contract.FunctionInput[[]Signer]) + require.True(t, ok) + require.Equal(t, uint64(BaseSepoliaSelector), firstInput.ChainSelector) + require.Equal(t, uint64(BaseMainnetSelector), secondInput.ChainSelector) + + beforeSepolia := readTestSigners(t, e, getTestSignerRegistry(t, e, BaseSepoliaSelector)) + beforeMainnet := readTestSigners(t, e, getTestSignerRegistry(t, e, BaseMainnetSelector)) + overCapacity := AddSignersConfig{ + SignersByChain: map[uint64][]Signer{ + BaseSepoliaSelector: {makeConfigSigner(401)}, + BaseMainnetSelector: makeConfigSigners(500, 15), + }, + } + _, err = EVMSignerRegistryAddSignersChangeset.Apply(*e, overCapacity) + require.ErrorContains(t, err, "exceeds SignerRegistry capacity") + require.Equal(t, beforeSepolia, readTestSigners(t, e, getTestSignerRegistry(t, e, BaseSepoliaSelector))) + require.Equal(t, beforeMainnet, readTestSigners(t, e, getTestSignerRegistry(t, e, BaseMainnetSelector))) +} + +func TestEVMSignerRegistryAddSigners_RotationAndKeyReconciliation(t *testing.T) { + active := testAddress(1) + e := newAddSignersEnvironment(t, []uint64{BaseSepoliaSelector}, []signer_registry.ISignerRegistrySigner{{EvmAddress: active}}) + registry := getTestSignerRegistry(t, e, BaseSepoliaSelector) + chain := e.BlockChains.EVMChains()[BaseSepoliaSelector] + pending := testAddress(2) + tx, err := registry.SetNewSignerAddresses(chain.DeployerKey, []common.Address{active}, []common.Address{pending}) + require.NoError(t, err) + _, err = chain.Confirm(tx) + require.NoError(t, err) + + for _, signer := range []Signer{ + {EVMAddress: active}, + {EVMAddress: active, NewEVMAddress: pending}, + } { + out, applyErr := EVMSignerRegistryAddSignersChangeset.Apply(*e, AddSignersConfig{ + SignersByChain: map[uint64][]Signer{BaseSepoliaSelector: {signer}}, + }) + require.NoError(t, applyErr) + require.Empty(t, out.Reports) + } + + _, err = EVMSignerRegistryAddSignersChangeset.Apply(*e, AddSignersConfig{ + SignersByChain: map[uint64][]Signer{ + BaseSepoliaSelector: {{EVMAddress: active, NewEVMAddress: testAddress(3)}}, + }, + }) + require.ErrorContains(t, err, "add-signers cannot change") + + _, err = EVMSignerRegistryAddSignersChangeset.Apply(*e, AddSignersConfig{ + SignersByChain: map[uint64][]Signer{ + BaseSepoliaSelector: {{EVMAddress: testAddress(4), NewEVMAddress: pending}}, + }, + }) + require.ErrorContains(t, err, "already in use") +} + +func TestEVMSignerRegistryAddSigners_ValidationDatastoreAndYAML(t *testing.T) { + valid := makeConfigSigner(10) + tests := []struct { + name string + signers []Signer + wantErr string + }{ + {name: "empty", wantErr: "no signers provided"}, + {name: "zero active", signers: []Signer{{}}, wantErr: "zero EVM address"}, + {name: "same active and pending", signers: []Signer{{EVMAddress: valid.EVMAddress, NewEVMAddress: valid.EVMAddress}}, wantErr: "identical"}, + {name: "duplicate across roles", signers: []Signer{valid, {EVMAddress: testAddress(11), NewEVMAddress: valid.EVMAddress}}, wantErr: "reuses"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.ErrorContains(t, validateSigners(tt.signers), tt.wantErr) + }) + } + + e := newAddSignersEnvironment(t, []uint64{BaseSepoliaSelector}, makeContractSigners(1, 5)) + cfg := AddSignersConfig{ + SignersByChain: map[uint64][]Signer{BaseSepoliaSelector: {valid}}, + } + withoutRegistry := datastore.NewMemoryDataStore() + refs, err := e.DataStore.Addresses().Fetch() + require.NoError(t, err) + for _, ref := range refs { + if ref.Type == datastore.ContractType(shared.EVMSignerRegistry) && ref.Version.Equal(&deployment.Version1_0_0) { + continue + } + require.NoError(t, withoutRegistry.Addresses().Add(ref)) + } + missing := e.Clone() + missing.DataStore = withoutRegistry.Seal() + _, err = EVMSignerRegistryAddSignersChangeset.Apply(missing, cfg) + require.ErrorContains(t, err, "expected to find exactly 1 ref") + + yamlInput := fmt.Sprintf(`signersByChain: + %d: + - evmAddress: "%s" + newEVMAddress: "%s" +`, uint64(BaseSepoliaSelector), testAddress(700).Hex(), testAddress(701).Hex()) + var decoded AddSignersConfig + require.NoError(t, yaml.Unmarshal([]byte(yamlInput), &decoded)) + require.Equal(t, testAddress(700), decoded.SignersByChain[BaseSepoliaSelector][0].EVMAddress) + require.Equal(t, testAddress(701), decoded.SignersByChain[BaseSepoliaSelector][0].NewEVMAddress) + require.Equal(t, mcms.Input{}, decoded.MCMS) + require.NoError(t, EVMSignerRegistryAddSignersChangeset.VerifyPreconditions(*e, decoded)) +} + +func TestEVMSignerRegistryAddSigners_ConfigValidation(t *testing.T) { + e := newAddSignersEnvironment(t, []uint64{BaseSepoliaSelector}, nil) + tests := []struct { + name string + cfg AddSignersConfig + wantErr string + }{ + {name: "empty config", cfg: AddSignersConfig{}, wantErr: "no signer additions provided"}, + { + name: "empty signer list", + cfg: AddSignersConfig{ + SignersByChain: map[uint64][]Signer{BaseSepoliaSelector: nil}, + }, + wantErr: "no signers provided", + }, + { + name: "unsupported chain", + cfg: AddSignersConfig{ + SignersByChain: map[uint64][]Signer{1: {{EVMAddress: testAddress(10)}}}, + }, + wantErr: "not a supported Base chain", + }, + { + name: "missing Base chain", + cfg: AddSignersConfig{ + SignersByChain: map[uint64][]Signer{BaseMainnetSelector: {{EVMAddress: testAddress(10)}}}, + }, + wantErr: "not found in environment", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.ErrorContains(t, EVMSignerRegistryAddSignersChangeset.VerifyPreconditions(*e, tt.cfg), tt.wantErr) + }) + } + + withoutDataStore := e.Clone() + withoutDataStore.DataStore = nil + require.ErrorContains(t, EVMSignerRegistryAddSignersChangeset.VerifyPreconditions(withoutDataStore, AddSignersConfig{ + SignersByChain: map[uint64][]Signer{BaseSepoliaSelector: {{EVMAddress: testAddress(10)}}}, + }), "environment DataStore is required") +} + +func TestEVMSignerRegistryAddSigners_MCMSExecutionAndRetry(t *testing.T) { + selector := uint64(BaseSepoliaSelector) + initial := []signer_registry.ISignerRegistrySigner{{EvmAddress: testAddress(1)}} + e := newAddSignersEnvironment(t, []uint64{selector}, initial) + registry := getTestSignerRegistry(t, e, selector) + rt := runtime.NewFromEnvironment(*e) + + qualifier := cciputils.CLLQualifier + mcmsConfig := cldftesthelpers.SingleGroupTimelockConfig(t) + mcmsConfig.Qualifier = &qualifier + require.NoError(t, rt.Exec(runtime.ChangesetTask( + cldf.CreateLegacyChangeSet(mcmschangesets.DeployMCMSWithTimelockV2), + map[uint64]cldfproposalutils.MCMSWithTimelockConfig{selector: mcmsConfig}, + ))) + + cfg := AddSignersConfig{ + SignersByChain: map[uint64][]Signer{ + selector: {{EVMAddress: testAddress(10), NewEVMAddress: testAddress(11)}}, + }, + MCMS: validAddSignersMCMSInput(), + } + require.NoError(t, EVMSignerRegistryAddSignersChangeset.VerifyPreconditions(rt.Environment(), cfg)) + + require.NoError(t, rt.Exec( + runtime.ChangesetTask( + cldf.CreateLegacyChangeSet(mcmschangesets.TransferToMCMSWithTimelockV2), + mcmschangesets.TransferToMCMSWithTimelockConfig{ + ContractsByChain: map[uint64][]common.Address{selector: {registry.Address()}}, + MCMSConfig: cldfproposalutils.TimelockConfig{ + MinDelay: 0, + TimelockQualifierPerChain: map[uint64]string{ + selector: cciputils.CLLQualifier, + }, + }, + }, + ), + runtime.SignAndExecuteProposalsTask([]*ecdsa.PrivateKey{cldftesthelpers.TestXXXMCMSSigner}), + )) + invalidCfg := cfg + invalidCfg.MCMS.ValidUntil = 1 + require.ErrorContains(t, EVMSignerRegistryAddSignersChangeset.VerifyPreconditions(rt.Environment(), invalidCfg), "invalid MCMS proposal configuration") + + proposalTask := runtime.ChangesetTask(EVMSignerRegistryAddSignersChangeset, cfg) + require.NoError(t, rt.Exec(proposalTask)) + require.Equal(t, initial, readTestSigners(t, e, registry)) + require.Len(t, rt.State().GetPendingProposals(), 1) + + proposalOutput := rt.State().Outputs[proposalTask.ID()] + require.Len(t, proposalOutput.Reports, 1) + require.Len(t, proposalOutput.MCMSTimelockProposals, 1) + require.Len(t, proposalOutput.MCMSTimelockProposals[0].Operations, 1) + require.Len(t, proposalOutput.MCMSTimelockProposals[0].Operations[0].Transactions, 1) + + require.NoError(t, rt.Exec( + runtime.SignAndExecuteProposalsTask([]*ecdsa.PrivateKey{cldftesthelpers.TestXXXMCMSSigner}), + )) + require.Equal(t, []signer_registry.ISignerRegistrySigner{ + {EvmAddress: testAddress(1)}, + {EvmAddress: testAddress(10), NewEVMAddress: testAddress(11)}, + }, readTestSigners(t, e, registry)) + + proposalCount := len(rt.State().Proposals) + retryCfg := cfg + retryCfg.MCMS.ValidUntil = 1 + retryTask := runtime.ChangesetTask(EVMSignerRegistryAddSignersChangeset, retryCfg) + require.NoError(t, rt.Exec(retryTask)) + require.Len(t, rt.State().Proposals, proposalCount) + require.Empty(t, rt.State().Outputs[retryTask.ID()].Reports) + require.Empty(t, rt.State().Outputs[retryTask.ID()].MCMSTimelockProposals) +} + +func TestEVMSignerRegistryAddSigners_RejectsArbitraryOwner(t *testing.T) { + selector := uint64(BaseSepoliaSelector) + e := newAddSignersEnvironment(t, []uint64{selector}, []signer_registry.ISignerRegistrySigner{ + {EvmAddress: testAddress(10)}, + }) + registry := getTestSignerRegistry(t, e, selector) + chain := e.BlockChains.EVMChains()[selector] + require.NotEmpty(t, chain.Users) + rt := runtime.NewFromEnvironment(*e) + + qualifier := cciputils.CLLQualifier + mcmsConfig := cldftesthelpers.SingleGroupTimelockConfig(t) + mcmsConfig.Qualifier = &qualifier + require.NoError(t, rt.Exec(runtime.ChangesetTask( + cldf.CreateLegacyChangeSet(mcmschangesets.DeployMCMSWithTimelockV2), + map[uint64]cldfproposalutils.MCMSWithTimelockConfig{selector: mcmsConfig}, + ))) + + tx, err := registry.TransferOwnership(chain.DeployerKey, chain.Users[0].From) + require.NoError(t, err) + _, err = chain.Confirm(tx) + require.NoError(t, err) + tx, err = registry.AcceptOwnership(chain.Users[0]) + require.NoError(t, err) + _, err = chain.Confirm(tx) + require.NoError(t, err) + + err = EVMSignerRegistryAddSignersChangeset.VerifyPreconditions(rt.Environment(), AddSignersConfig{ + SignersByChain: map[uint64][]Signer{ + selector: {{EVMAddress: testAddress(10)}}, + }, + MCMS: validAddSignersMCMSInput(), + }) + require.ErrorContains(t, err, "unsupported owner") +} + +func newAddSignersEnvironment( + t *testing.T, + selectors []uint64, + initial []signer_registry.ISignerRegistrySigner, +) *cldf.Environment { + t.Helper() + e, err := environment.New(t.Context(), + environment.WithEVMSimulatedWithConfig(t, selectors, onchain.EVMSimLoaderConfig{ + NumAdditionalAccounts: 1, + }), + environment.WithLogger(logger.Test(t)), + ) + require.NoError(t, err) + out, err := EVMSignerRegistryDeploymentChangeset.Apply(*e, SignerRegistryChangesetConfig{ + MaxSigners: MaxSigners, + Signers: initial, + }) + require.NoError(t, err) + require.NotNil(t, out.DataStore) + ds := datastore.NewMemoryDataStore() + require.NoError(t, ds.Merge(e.DataStore)) + require.NoError(t, ds.Merge(out.DataStore.Seal())) + e.DataStore = ds.Seal() + return e +} + +func getTestSignerRegistry(t *testing.T, e *cldf.Environment, selector uint64) *signer_registry.SignerRegistry { + t.Helper() + address, err := resolveSignerRegistry(*e, selector) + require.NoError(t, err) + registry, err := signer_registry.NewSignerRegistry(address, e.BlockChains.EVMChains()[selector].Client) + require.NoError(t, err) + return registry +} + +func readTestSigners(t *testing.T, e *cldf.Environment, registry *signer_registry.SignerRegistry) []signer_registry.ISignerRegistrySigner { + t.Helper() + signers, err := registry.GetSigners(nil) + require.NoError(t, err) + return signers +} + +func makeContractSigners(start, count int64) []signer_registry.ISignerRegistrySigner { + signers := make([]signer_registry.ISignerRegistrySigner, count) + for i := range signers { + signers[i] = signer_registry.ISignerRegistrySigner{EvmAddress: testAddress(start + int64(i))} + } + return signers +} + +func makeConfigSigners(start, count int64) []Signer { + signers := make([]Signer, count) + for i := range signers { + signers[i] = makeConfigSigner(start + int64(i)) + } + return signers +} + +func makeConfigSigner(value int64) Signer { + return Signer{EVMAddress: testAddress(value)} +} + +func testAddress(value int64) common.Address { + return common.BigToAddress(big.NewInt(value)) +} + +func validAddSignersMCMSInput() mcms.Input { + return mcms.Input{ + ValidUntil: 4_000_000_000, + TimelockDelay: mcms_types.NewDuration(0), + TimelockAction: mcms_types.TimelockActionSchedule, + Qualifier: cciputils.CLLQualifier, + Description: "Add CCIP attestation signer entries", + } +} diff --git a/deployment/ccip/changeset/ccip-attestation/evm_signer_registry_operations.go b/deployment/ccip/changeset/ccip-attestation/evm_signer_registry_operations.go new file mode 100644 index 00000000000..db0ed2111c5 --- /dev/null +++ b/deployment/ccip/changeset/ccip-attestation/evm_signer_registry_operations.go @@ -0,0 +1,71 @@ +package ccip_attestation + +import ( + "errors" + "fmt" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + + "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm/operations/contract" + + "github.com/smartcontractkit/chainlink/deployment" + "github.com/smartcontractkit/chainlink/deployment/ccip/shared" + signer_registry "github.com/smartcontractkit/chainlink/deployment/ccip/shared/bindings/signer_registry" +) + +var addSignersOperation = contract.NewWrite(contract.WriteParams[[]Signer, *signer_registry.SignerRegistry]{ + Name: "ccip-attestation:signer-registry:add-signers", + Version: &deployment.Version1_0_0, + Description: "Adds signers to an EVM SignerRegistry", + ContractType: shared.EVMSignerRegistry, + ContractABI: signer_registry.SignerRegistryABI, + NewContract: signer_registry.NewSignerRegistry, + IsAllowedCaller: contract.OnlyOwner[*signer_registry.SignerRegistry, []Signer], + Validate: validateSigners, + CallContract: func(registry *signer_registry.SignerRegistry, opts *bind.TransactOpts, signers []Signer) (*types.Transaction, error) { + return registry.AddSigners(opts, toContractSigners(signers)) + }, +}) + +func validateSigners(signers []Signer) error { + if len(signers) == 0 { + return errors.New("no signers provided") + } + + seen := make(map[common.Address]struct{}, len(signers)*2) + for i, signer := range signers { + if signer.EVMAddress == (common.Address{}) { + return fmt.Errorf("signer %d has a zero EVM address", i) + } + if signer.EVMAddress == signer.NewEVMAddress { + return fmt.Errorf("signer %d has identical active and pending EVM addresses", i) + } + if _, exists := seen[signer.EVMAddress]; exists { + return fmt.Errorf("signer %d reuses EVM address %s", i, signer.EVMAddress.Hex()) + } + seen[signer.EVMAddress] = struct{}{} + + if signer.NewEVMAddress == (common.Address{}) { + continue + } + if _, exists := seen[signer.NewEVMAddress]; exists { + return fmt.Errorf("signer %d reuses pending EVM address %s", i, signer.NewEVMAddress.Hex()) + } + seen[signer.NewEVMAddress] = struct{}{} + } + + return nil +} + +func toContractSigners(signers []Signer) []signer_registry.ISignerRegistrySigner { + contractSigners := make([]signer_registry.ISignerRegistrySigner, len(signers)) + for i, signer := range signers { + contractSigners[i] = signer_registry.ISignerRegistrySigner{ + EvmAddress: signer.EVMAddress, + NewEVMAddress: signer.NewEVMAddress, + } + } + return contractSigners +}