From 7cbcc42a8a5e5b4830b68808c171f31d64652004 Mon Sep 17 00:00:00 2001 From: george-dorin Date: Thu, 23 Apr 2026 14:29:18 +0300 Subject: [PATCH 1/5] Add AcceptOwnershipForwarder implementation and tests --- deployment/cre/forwarder/accept_ownership.go | 76 +++++++++++++ .../cre/forwarder/accept_ownership_test.go | 102 ++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 deployment/cre/forwarder/accept_ownership.go create mode 100644 deployment/cre/forwarder/accept_ownership_test.go diff --git a/deployment/cre/forwarder/accept_ownership.go b/deployment/cre/forwarder/accept_ownership.go new file mode 100644 index 00000000000..afd8548299e --- /dev/null +++ b/deployment/cre/forwarder/accept_ownership.go @@ -0,0 +1,76 @@ +package forwarder + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + + forwarderwrapper "github.com/smartcontractkit/chainlink-evm/gethwrappers/keystone/generated/forwarder_1_0_0" + + "github.com/smartcontractkit/chainlink/deployment/cre/contracts" +) + +// AcceptOwnershipInput identifies which KeystoneForwarder contract should accept pending ownership. +type AcceptOwnershipInput struct { + // ChainSelector of the chain where the forwarder is deployed. + ChainSelector uint64 `json:"chainSelector" yaml:"chainSelector"` + // Qualifier optionally disambiguates the forwarder in the datastore. + // Leave empty if there is only one forwarder on the chain. + Qualifier string `json:"qualifier,omitempty" yaml:"qualifier,omitempty"` +} + +// AcceptOwnershipForwarder directly calls AcceptOwnership on a KeystoneForwarder contract +// using the environment's deployer key. Use this after the previous owner has called +// transferOwnership to the deployer EOA. +type AcceptOwnershipForwarder struct{} + +var _ cldf.ChangeSetV2[AcceptOwnershipInput] = AcceptOwnershipForwarder{} + +func (AcceptOwnershipForwarder) VerifyPreconditions(e cldf.Environment, input AcceptOwnershipInput) error { + if _, ok := e.BlockChains.EVMChains()[input.ChainSelector]; !ok { + return fmt.Errorf("chain selector %d not found in environment", input.ChainSelector) + } + filters := []datastore.FilterFunc[datastore.AddressRefKey, datastore.AddressRef]{ + datastore.AddressRefByChainSelector(input.ChainSelector), + datastore.AddressRefByType(datastore.ContractType(contracts.KeystoneForwarder)), + } + if input.Qualifier != "" { + filters = append(filters, datastore.AddressRefByQualifier(input.Qualifier)) + } + refs := e.DataStore.Addresses().Filter(filters...) + if len(refs) == 0 { + return fmt.Errorf("no KeystoneForwarder found for chain %d (qualifier %q)", input.ChainSelector, input.Qualifier) + } + return nil +} + +func (AcceptOwnershipForwarder) Apply(e cldf.Environment, input AcceptOwnershipInput) (cldf.ChangesetOutput, error) { + chain := e.BlockChains.EVMChains()[input.ChainSelector] + + filters := []datastore.FilterFunc[datastore.AddressRefKey, datastore.AddressRef]{ + datastore.AddressRefByChainSelector(input.ChainSelector), + datastore.AddressRefByType(datastore.ContractType(contracts.KeystoneForwarder)), + } + if input.Qualifier != "" { + filters = append(filters, datastore.AddressRefByQualifier(input.Qualifier)) + } + + refs := e.DataStore.Addresses().Filter(filters...) + for _, ref := range refs { + contract, err := forwarderwrapper.NewKeystoneForwarder(common.HexToAddress(ref.Address), chain.Client) + if err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("failed to instantiate forwarder %s on chain %d: %w", ref.Address, input.ChainSelector, err) + } + + tx, err := contract.AcceptOwnership(chain.DeployerKey) + if _, confErr := cldf.ConfirmIfNoError(chain, tx, err); confErr != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("AcceptOwnership failed for %s on chain %d: %w", ref.Address, input.ChainSelector, confErr) + } + + e.Logger.Infow("Accepted ownership of KeystoneForwarder", "address", ref.Address, "chainSelector", input.ChainSelector) + } + + return cldf.ChangesetOutput{}, nil +} diff --git a/deployment/cre/forwarder/accept_ownership_test.go b/deployment/cre/forwarder/accept_ownership_test.go new file mode 100644 index 00000000000..b9054baf527 --- /dev/null +++ b/deployment/cre/forwarder/accept_ownership_test.go @@ -0,0 +1,102 @@ +package forwarder_test + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + chainsel "github.com/smartcontractkit/chain-selectors" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + + forwarderwrapper "github.com/smartcontractkit/chainlink-evm/gethwrappers/keystone/generated/forwarder_1_0_0" + + "github.com/smartcontractkit/chainlink/deployment/cre/contracts" + "github.com/smartcontractkit/chainlink/deployment/cre/forwarder" +) + +func TestAcceptOwnershipForwarder(t *testing.T) { + t.Parallel() + + selector := chainsel.TEST_90000001.Selector + + env, err := environment.New(t.Context(), + environment.WithEVMSimulated(t, []uint64{selector}), + environment.WithLogger(logger.Test(t)), + ) + require.NoError(t, err) + + // Deploy a KeystoneForwarder contract + deployOut, err := operations.ExecuteSequence(env.OperationsBundle, forwarder.DeploySequence, + forwarder.DeploySequenceDeps{Env: env}, + forwarder.DeploySequenceInput{ + Targets: []uint64{selector}, + Qualifier: "test-accept-ownership", + }, + ) + require.NoError(t, err) + + env.DataStore = deployOut.Output.Datastore + + refs := env.DataStore.Addresses().Filter( + datastore.AddressRefByChainSelector(selector), + datastore.AddressRefByType(datastore.ContractType(contracts.KeystoneForwarder)), + ) + require.Len(t, refs, 1) + + chain := env.BlockChains.EVMChains()[selector] + + // Simulate a pending ownership transfer: the current owner (deployer) calls + // transferOwnership(deployer), making itself the pending owner. This mirrors the + // real scenario where a previous owner has called transferOwnership() + // before this changeset is run. + contract, err := forwarderwrapper.NewKeystoneForwarder(common.HexToAddress(refs[0].Address), chain.Client) + require.NoError(t, err) + + tx, err := contract.TransferOwnership(chain.DeployerKey, chain.DeployerKey.From) + _, err = cldf.ConfirmIfNoError(chain, tx, err) + require.NoError(t, err) + + // Apply the changeset — deployer accepts the pending ownership + _, err = forwarder.AcceptOwnershipForwarder{}.Apply(*env, forwarder.AcceptOwnershipInput{ + ChainSelector: selector, + Qualifier: "test-accept-ownership", + }) + require.NoError(t, err) + + owner, err := contract.Owner(nil) + require.NoError(t, err) + require.Equal(t, chain.DeployerKey.From, owner) +} + +func TestAcceptOwnershipForwarder_VerifyPreconditions(t *testing.T) { + t.Parallel() + + selector := chainsel.TEST_90000001.Selector + + env, err := environment.New(t.Context(), + environment.WithEVMSimulated(t, []uint64{selector}), + environment.WithLogger(logger.Test(t)), + ) + require.NoError(t, err) + + t.Run("unknown chain selector", func(t *testing.T) { + err := forwarder.AcceptOwnershipForwarder{}.VerifyPreconditions(*env, forwarder.AcceptOwnershipInput{ + ChainSelector: 0, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "not found in environment") + }) + + t.Run("no forwarder in datastore", func(t *testing.T) { + err := forwarder.AcceptOwnershipForwarder{}.VerifyPreconditions(*env, forwarder.AcceptOwnershipInput{ + ChainSelector: selector, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "no KeystoneForwarder found") + }) +} From 4cd570cd9143a3e0101fa71b02f0f29925390f2a Mon Sep 17 00:00:00 2001 From: george-dorin Date: Thu, 23 Apr 2026 14:36:08 +0300 Subject: [PATCH 2/5] Remove unused imports in accept_ownership.go --- deployment/cre/forwarder/accept_ownership.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/deployment/cre/forwarder/accept_ownership.go b/deployment/cre/forwarder/accept_ownership.go index afd8548299e..8639a03710b 100644 --- a/deployment/cre/forwarder/accept_ownership.go +++ b/deployment/cre/forwarder/accept_ownership.go @@ -6,9 +6,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" - forwarderwrapper "github.com/smartcontractkit/chainlink-evm/gethwrappers/keystone/generated/forwarder_1_0_0" - "github.com/smartcontractkit/chainlink/deployment/cre/contracts" ) From 3281c06b7422fb4bb525182525f0177e27f4cd28 Mon Sep 17 00:00:00 2001 From: george-dorin Date: Thu, 23 Apr 2026 14:44:19 +0300 Subject: [PATCH 3/5] Fix lint --- deployment/cre/forwarder/accept_ownership.go | 1 + 1 file changed, 1 insertion(+) diff --git a/deployment/cre/forwarder/accept_ownership.go b/deployment/cre/forwarder/accept_ownership.go index 8639a03710b..d4d1c6852cd 100644 --- a/deployment/cre/forwarder/accept_ownership.go +++ b/deployment/cre/forwarder/accept_ownership.go @@ -7,6 +7,7 @@ import ( "github.com/smartcontractkit/chainlink-deployments-framework/datastore" cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" forwarderwrapper "github.com/smartcontractkit/chainlink-evm/gethwrappers/keystone/generated/forwarder_1_0_0" + "github.com/smartcontractkit/chainlink/deployment/cre/contracts" ) From 5ddf7cb2b05c00608b0ac6155c7f45b703321e4a Mon Sep 17 00:00:00 2001 From: george-dorin Date: Thu, 23 Apr 2026 14:51:35 +0300 Subject: [PATCH 4/5] Fix lint --- deployment/cre/forwarder/accept_ownership.go | 1 + 1 file changed, 1 insertion(+) diff --git a/deployment/cre/forwarder/accept_ownership.go b/deployment/cre/forwarder/accept_ownership.go index d4d1c6852cd..cf72d1e77ce 100644 --- a/deployment/cre/forwarder/accept_ownership.go +++ b/deployment/cre/forwarder/accept_ownership.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/ethereum/go-ethereum/common" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" forwarderwrapper "github.com/smartcontractkit/chainlink-evm/gethwrappers/keystone/generated/forwarder_1_0_0" From 935e7c82d333073fa1bdfead4db2007b5064f75f Mon Sep 17 00:00:00 2001 From: george-dorin Date: Thu, 23 Apr 2026 15:43:34 +0300 Subject: [PATCH 5/5] Update accept_ownership_test to include new owner onboarding logic --- .../cre/forwarder/accept_ownership_test.go | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/deployment/cre/forwarder/accept_ownership_test.go b/deployment/cre/forwarder/accept_ownership_test.go index b9054baf527..f3f2dda2c95 100644 --- a/deployment/cre/forwarder/accept_ownership_test.go +++ b/deployment/cre/forwarder/accept_ownership_test.go @@ -8,9 +8,11 @@ import ( "github.com/stretchr/testify/require" "github.com/smartcontractkit/chainlink-common/pkg/logger" + cldfchain "github.com/smartcontractkit/chainlink-deployments-framework/chain" "github.com/smartcontractkit/chainlink-deployments-framework/datastore" cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/onchain" "github.com/smartcontractkit/chainlink-deployments-framework/operations" forwarderwrapper "github.com/smartcontractkit/chainlink-evm/gethwrappers/keystone/generated/forwarder_1_0_0" @@ -24,13 +26,17 @@ func TestAcceptOwnershipForwarder(t *testing.T) { selector := chainsel.TEST_90000001.Selector + // One additional funded account acts as the "new owner" that will accept ownership, + // mirroring the real scenario where the previous owner transfers to the deployer EOA. env, err := environment.New(t.Context(), - environment.WithEVMSimulated(t, []uint64{selector}), + environment.WithEVMSimulatedWithConfig(t, []uint64{selector}, onchain.EVMSimLoaderConfig{ + NumAdditionalAccounts: 1, + }), environment.WithLogger(logger.Test(t)), ) require.NoError(t, err) - // Deploy a KeystoneForwarder contract + // Deploy a KeystoneForwarder — deployer becomes the initial owner. deployOut, err := operations.ExecuteSequence(env.OperationsBundle, forwarder.DeploySequence, forwarder.DeploySequenceDeps{Env: env}, forwarder.DeploySequenceInput{ @@ -49,19 +55,30 @@ func TestAcceptOwnershipForwarder(t *testing.T) { require.Len(t, refs, 1) chain := env.BlockChains.EVMChains()[selector] + require.NotEmpty(t, chain.Users, "expected at least one additional funded account") + + // Users[0] is the new owner — the deployer (current owner) transfers to it. + newOwner := chain.Users[0] - // Simulate a pending ownership transfer: the current owner (deployer) calls - // transferOwnership(deployer), making itself the pending owner. This mirrors the - // real scenario where a previous owner has called transferOwnership() - // before this changeset is run. contract, err := forwarderwrapper.NewKeystoneForwarder(common.HexToAddress(refs[0].Address), chain.Client) require.NoError(t, err) - tx, err := contract.TransferOwnership(chain.DeployerKey, chain.DeployerKey.From) + tx, err := contract.TransferOwnership(chain.DeployerKey, newOwner.From) _, err = cldf.ConfirmIfNoError(chain, tx, err) require.NoError(t, err) - // Apply the changeset — deployer accepts the pending ownership + // Rebuild the environment's BlockChains with newOwner as the deployer key + // so the changeset signs acceptOwnership as the pending owner. + chain.DeployerKey = newOwner + evmChains := env.BlockChains.EVMChains() + evmChains[selector] = chain + blockChainMap := make(map[uint64]cldfchain.BlockChain, len(evmChains)) + for k, v := range evmChains { + blockChainMap[k] = v + } + env.BlockChains = cldfchain.NewBlockChains(blockChainMap) + + // Apply the changeset — new owner accepts the pending ownership transfer. _, err = forwarder.AcceptOwnershipForwarder{}.Apply(*env, forwarder.AcceptOwnershipInput{ ChainSelector: selector, Qualifier: "test-accept-ownership", @@ -70,7 +87,7 @@ func TestAcceptOwnershipForwarder(t *testing.T) { owner, err := contract.Owner(nil) require.NoError(t, err) - require.Equal(t, chain.DeployerKey.From, owner) + require.Equal(t, newOwner.From, owner) } func TestAcceptOwnershipForwarder_VerifyPreconditions(t *testing.T) {