From 48bf791093cd6ede9d95e3e0c335ec5442a146c7 Mon Sep 17 00:00:00 2001 From: chris-de-leon-cll <147140544+chris-de-leon-cll@users.noreply.github.com> Date: Wed, 15 Jul 2026 03:08:25 -0700 Subject: [PATCH 1/7] feat: best-effort lookup --- deployment/deploy/transfer_ownership.go | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/deployment/deploy/transfer_ownership.go b/deployment/deploy/transfer_ownership.go index 1330fe5f0..0f507cac0 100644 --- a/deployment/deploy/transfer_ownership.go +++ b/deployment/deploy/transfer_ownership.go @@ -45,17 +45,15 @@ func acceptOwnershipApply(cr *TransferOwnershipAdapterRegistry, mcmsRegistry *ch if err != nil { return cldf.ChangesetOutput{}, err } - // if partial refs are provided, resolve to full refs for i, contractRef := range perChainInputs.ContractRef { - normRef, err := TryNormalizeAddressRef(perChainInputs.ChainSelector, contractRef) + addrRef, err := TryNormalizeAddressRef(perChainInputs.ChainSelector, contractRef) if err != nil { return cldf.ChangesetOutput{}, fmt.Errorf("failed to normalize contract ref %s for chain %d: %w", datastore_utils.SprintRef(contractRef), perChainInputs.ChainSelector, err) } - fullRef, err := datastore_utils.FindAndFormatRef(e.DataStore, normRef, perChainInputs.ChainSelector, datastore_utils.FullRef) - if err != nil { - return cldf.ChangesetOutput{}, fmt.Errorf("failed to resolve contract ref %s for chain %d: %w", datastore_utils.SprintRef(normRef), perChainInputs.ChainSelector, err) + if fullRef, err := datastore_utils.FindAndFormatRef(e.DataStore, addrRef, perChainInputs.ChainSelector, datastore_utils.FullRef); err == nil { + addrRef = fullRef } - perChainInputs.ContractRef[i] = fullRef + perChainInputs.ContractRef[i] = addrRef } report, err := cldf_ops.ExecuteSequence(e.OperationsBundle, adapter.SequenceAcceptOwnership(), e.BlockChains, perChainInputs) if err != nil { @@ -90,17 +88,15 @@ func transferOwnershipApply(cr *TransferOwnershipAdapterRegistry, mcmsRegistry * if err != nil { return cldf.ChangesetOutput{}, err } - // if partial refs are provided, resolve to full refs for i, contractRef := range perChainInputs.ContractRef { - normRef, err := TryNormalizeAddressRef(perChainInputs.ChainSelector, contractRef) + addrRef, err := TryNormalizeAddressRef(perChainInputs.ChainSelector, contractRef) if err != nil { return cldf.ChangesetOutput{}, fmt.Errorf("failed to normalize contract ref %s for chain %d: %w", datastore_utils.SprintRef(contractRef), perChainInputs.ChainSelector, err) } - fullRef, err := datastore_utils.FindAndFormatRef(e.DataStore, normRef, perChainInputs.ChainSelector, datastore_utils.FullRef) - if err != nil { - return cldf.ChangesetOutput{}, fmt.Errorf("failed to resolve contract ref %s for chain %d: %w", datastore_utils.SprintRef(normRef), perChainInputs.ChainSelector, err) + if fullRef, err := datastore_utils.FindAndFormatRef(e.DataStore, addrRef, perChainInputs.ChainSelector, datastore_utils.FullRef); err == nil { + addrRef = fullRef } - perChainInputs.ContractRef[i] = fullRef + perChainInputs.ContractRef[i] = addrRef } chainBatchOps, chainReports, err := transferAndAcceptOwnership(e, adapter, perChainInputs, input.MCMS) if err != nil { From 2f779d402bb55228b995208874b0acd978b7a1f0 Mon Sep 17 00:00:00 2001 From: chris-de-leon-cll <147140544+chris-de-leon-cll@users.noreply.github.com> Date: Wed, 15 Jul 2026 03:25:15 -0700 Subject: [PATCH 2/7] fix: handle empty address --- deployment/deploy/transfer_ownership.go | 28 +++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/deployment/deploy/transfer_ownership.go b/deployment/deploy/transfer_ownership.go index 0f507cac0..b8cc70a70 100644 --- a/deployment/deploy/transfer_ownership.go +++ b/deployment/deploy/transfer_ownership.go @@ -50,8 +50,18 @@ func acceptOwnershipApply(cr *TransferOwnershipAdapterRegistry, mcmsRegistry *ch if err != nil { return cldf.ChangesetOutput{}, fmt.Errorf("failed to normalize contract ref %s for chain %d: %w", datastore_utils.SprintRef(contractRef), perChainInputs.ChainSelector, err) } - if fullRef, err := datastore_utils.FindAndFormatRef(e.DataStore, addrRef, perChainInputs.ChainSelector, datastore_utils.FullRef); err == nil { - addrRef = fullRef + if addrRef.Address != "" { + if fullRef, err := datastore_utils.FindAndFormatRef(e.DataStore, addrRef, perChainInputs.ChainSelector, datastore_utils.FullRef); err != nil { + e.Logger.Warnf("failed to resolve contract ref %s for chain %d - using raw input ref instead: %v", datastore_utils.SprintRef(addrRef), perChainInputs.ChainSelector, err) + } else { + addrRef = fullRef + } + } else { + if fullRef, err := datastore_utils.FindAndFormatRef(e.DataStore, addrRef, perChainInputs.ChainSelector, datastore_utils.FullRef); err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("failed to resolve contract ref %s for chain %d: %w", datastore_utils.SprintRef(addrRef), perChainInputs.ChainSelector, err) + } else { + addrRef = fullRef + } } perChainInputs.ContractRef[i] = addrRef } @@ -93,8 +103,18 @@ func transferOwnershipApply(cr *TransferOwnershipAdapterRegistry, mcmsRegistry * if err != nil { return cldf.ChangesetOutput{}, fmt.Errorf("failed to normalize contract ref %s for chain %d: %w", datastore_utils.SprintRef(contractRef), perChainInputs.ChainSelector, err) } - if fullRef, err := datastore_utils.FindAndFormatRef(e.DataStore, addrRef, perChainInputs.ChainSelector, datastore_utils.FullRef); err == nil { - addrRef = fullRef + if addrRef.Address != "" { + if fullRef, err := datastore_utils.FindAndFormatRef(e.DataStore, addrRef, perChainInputs.ChainSelector, datastore_utils.FullRef); err != nil { + e.Logger.Warnf("failed to resolve contract ref %s for chain %d - using raw input ref instead: %v", datastore_utils.SprintRef(addrRef), perChainInputs.ChainSelector, err) + } else { + addrRef = fullRef + } + } else { + if fullRef, err := datastore_utils.FindAndFormatRef(e.DataStore, addrRef, perChainInputs.ChainSelector, datastore_utils.FullRef); err != nil { + return cldf.ChangesetOutput{}, fmt.Errorf("failed to resolve contract ref %s for chain %d: %w", datastore_utils.SprintRef(addrRef), perChainInputs.ChainSelector, err) + } else { + addrRef = fullRef + } } perChainInputs.ContractRef[i] = addrRef } From 0b0fe62762eeaecfe14adc0260966eb85f85d8e4 Mon Sep 17 00:00:00 2001 From: chris-de-leon-cll <147140544+chris-de-leon-cll@users.noreply.github.com> Date: Wed, 15 Jul 2026 03:25:31 -0700 Subject: [PATCH 3/7] test: add address-only test --- .../adapters/transfer_ownership_test.go | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/chains/evm/deployment/v1_0_0/adapters/transfer_ownership_test.go b/chains/evm/deployment/v1_0_0/adapters/transfer_ownership_test.go index 762c7659f..a92a5124e 100644 --- a/chains/evm/deployment/v1_0_0/adapters/transfer_ownership_test.go +++ b/chains/evm/deployment/v1_0_0/adapters/transfer_ownership_test.go @@ -30,6 +30,100 @@ import ( "github.com/smartcontractkit/chainlink-ccip/deployment/utils/mcms" ) +// TestTransferOwnershipAddressOnlyRef verifies that TransferOwnershipChangeset and +// AcceptOwnershipChangeset succeed when ContractRef contains only an Address with no +// Type/Version/Qualifier, and the contract is not present in the datastore. This exercises +// the best-effort DS enrichment path: DS miss is tolerated as long as Address is set. +func TestTransferOwnershipAddressOnlyRef(t *testing.T) { + t.Parallel() + selector := chainsel.TEST_90000001.Selector + env, err := environment.New(t.Context(), + environment.WithEVMSimulated(t, []uint64{selector}), + ) + require.NoError(t, err) + env.Logger = logger.Test(t) + + evmDeployer := &adapters.EVMDeployer{} + dReg := deploy.GetRegistry() + dReg.RegisterDeployer(chainsel.FamilyEVM, deploy.MCMSVersion, evmDeployer) + deployMCMS := deploy.DeployMCMS(dReg, nil) + + output, err := deployMCMS.Apply(*env, deploy.MCMSDeploymentConfig{ + AdapterVersion: semver.MustParse("1.0.0"), + MCMS: testhelpers.MCMSInputForQualifier(deploymentutils.CLLQualifier), + Chains: map[uint64]deploy.MCMSDeploymentConfigPerChain{ + selector: { + Canceller: testhelpers.SingleGroupMCMS(), + Bypasser: testhelpers.SingleGroupMCMS(), + Proposer: testhelpers.SingleGroupMCMS(), + TimelockMinDelay: big.NewInt(1), + Qualifier: deploymentutils.StringPtr(deploymentutils.CLLQualifier), + }, + }, + }) + require.NoError(t, err) + ds := output.DataStore + require.NoError(t, ds.Merge(env.DataStore)) + env.DataStore = ds.Seal() + + // Deploy a router but intentionally do NOT add it to the datastore. + evmChain := env.BlockChains.EVMChains()[selector] + deployRouterOp, err := cldf_ops.ExecuteOperation(env.OperationsBundle, routerops1_2.Deploy, evmChain, contract.DeployInput[routerops1_2.ConstructorArgs]{ + ChainSelector: evmChain.Selector, + TypeAndVersion: deployment.NewTypeAndVersion(routerops1_2.ContractType, *semver.MustParse("1.2.0")), + Args: routerops1_2.ConstructorArgs{ + WrappedNative: utils.RandomAddress(), + RMNProxy: utils.RandomAddress(), + }, + }) + require.NoError(t, err) + routerAddr := common.HexToAddress(deployRouterOp.Output.Address) + + timelockRef, err := datastore_utils.FindAndFormatRef(env.DataStore, datastore.AddressRef{ + ChainSelector: selector, + Type: datastore.ContractType(deploymentutils.RBACTimelock), + Qualifier: deploymentutils.CLLQualifier, + Version: semver.MustParse("1.0.0"), + }, selector, datastore_utils.FullRef) + require.NoError(t, err) + + cr := deploy.GetTransferOwnershipRegistry() + evmAdapter := &adapters.EVMTransferOwnershipAdapter{} + cr.RegisterAdapter(chainsel.FamilyEVM, semver.MustParse("1.0.0"), evmAdapter) + mcmsRegistry := changesets.GetRegistry() + mcmsRegistry.RegisterMCMSReader(chainsel.FamilyEVM, &adapters.EVMMCMSReader{}) + + mcmsInput := mcms.Input{ + OverridePreviousRoot: false, + ValidUntil: 3759765795, + TimelockAction: mcms_types.TimelockActionSchedule, + Qualifier: deploymentutils.CLLQualifier, + Description: "address-only ref test", + } + + // Transfer ownership using an address-only ref — router is not in the datastore. + transferOutput, err := deploy.TransferOwnershipChangeset(cr, mcmsRegistry).Apply(*env, deploy.TransferOwnershipInput{ + AdapterVersion: semver.MustParse("1.0.0"), + MCMS: mcmsInput, + ChainInputs: []deploy.TransferOwnershipPerChainInput{ + { + ChainSelector: selector, + ContractRef: []datastore.AddressRef{{Address: routerAddr.Hex()}}, + ProposedOwner: timelockRef.Address, + }, + }, + }) + require.NoError(t, err) + require.Equal(t, 1, len(transferOutput.MCMSTimelockProposals)) + testhelpers.ProcessTimelockProposals(t, *env, transferOutput.MCMSTimelockProposals, false) + + r, err := router.NewRouter(routerAddr, evmChain.Client) + require.NoError(t, err) + owner, err := r.Owner(&bind.CallOpts{Context: t.Context()}) + require.NoError(t, err) + require.Equal(t, common.HexToAddress(timelockRef.Address), owner) +} + // TestTransferOwnership tests transferring ownership of deployed contracts via MCMS timelocks. // It deploys MCMS contracts on two EVM chains, deploys routers, transfers ownership of routers from deployer key to the timelock, // then transfers ownership from the first timelock to a second timelock. From 4dbd59b384711df97a202f13c6d613eb680496ce Mon Sep 17 00:00:00 2001 From: chris-de-leon-cll <147140544+chris-de-leon-cll@users.noreply.github.com> Date: Wed, 15 Jul 2026 03:39:27 -0700 Subject: [PATCH 4/7] refactor: add helper --- deployment/deploy/transfer_ownership.go | 46 ++++++++++++------------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/deployment/deploy/transfer_ownership.go b/deployment/deploy/transfer_ownership.go index b8cc70a70..3c9f3bdbf 100644 --- a/deployment/deploy/transfer_ownership.go +++ b/deployment/deploy/transfer_ownership.go @@ -50,18 +50,9 @@ func acceptOwnershipApply(cr *TransferOwnershipAdapterRegistry, mcmsRegistry *ch if err != nil { return cldf.ChangesetOutput{}, fmt.Errorf("failed to normalize contract ref %s for chain %d: %w", datastore_utils.SprintRef(contractRef), perChainInputs.ChainSelector, err) } - if addrRef.Address != "" { - if fullRef, err := datastore_utils.FindAndFormatRef(e.DataStore, addrRef, perChainInputs.ChainSelector, datastore_utils.FullRef); err != nil { - e.Logger.Warnf("failed to resolve contract ref %s for chain %d - using raw input ref instead: %v", datastore_utils.SprintRef(addrRef), perChainInputs.ChainSelector, err) - } else { - addrRef = fullRef - } - } else { - if fullRef, err := datastore_utils.FindAndFormatRef(e.DataStore, addrRef, perChainInputs.ChainSelector, datastore_utils.FullRef); err != nil { - return cldf.ChangesetOutput{}, fmt.Errorf("failed to resolve contract ref %s for chain %d: %w", datastore_utils.SprintRef(addrRef), perChainInputs.ChainSelector, err) - } else { - addrRef = fullRef - } + addrRef, err = resolveContractRef(e, addrRef, perChainInputs.ChainSelector) + if err != nil { + return cldf.ChangesetOutput{}, err } perChainInputs.ContractRef[i] = addrRef } @@ -103,18 +94,9 @@ func transferOwnershipApply(cr *TransferOwnershipAdapterRegistry, mcmsRegistry * if err != nil { return cldf.ChangesetOutput{}, fmt.Errorf("failed to normalize contract ref %s for chain %d: %w", datastore_utils.SprintRef(contractRef), perChainInputs.ChainSelector, err) } - if addrRef.Address != "" { - if fullRef, err := datastore_utils.FindAndFormatRef(e.DataStore, addrRef, perChainInputs.ChainSelector, datastore_utils.FullRef); err != nil { - e.Logger.Warnf("failed to resolve contract ref %s for chain %d - using raw input ref instead: %v", datastore_utils.SprintRef(addrRef), perChainInputs.ChainSelector, err) - } else { - addrRef = fullRef - } - } else { - if fullRef, err := datastore_utils.FindAndFormatRef(e.DataStore, addrRef, perChainInputs.ChainSelector, datastore_utils.FullRef); err != nil { - return cldf.ChangesetOutput{}, fmt.Errorf("failed to resolve contract ref %s for chain %d: %w", datastore_utils.SprintRef(addrRef), perChainInputs.ChainSelector, err) - } else { - addrRef = fullRef - } + addrRef, err = resolveContractRef(e, addrRef, perChainInputs.ChainSelector) + if err != nil { + return cldf.ChangesetOutput{}, err } perChainInputs.ContractRef[i] = addrRef } @@ -175,6 +157,22 @@ func TransferToTimelock(chainSel uint64, e cldf.Environment, mcmsInput mcms.Inpu return transferAndAcceptOwnership(e, adapter, ownershipInput, mcmsInput) } +// resolveContractRef attempts to enrich ref with full metadata from the datastore. If ref +// already has an Address and the DS lookup fails, the original ref is returned as-is (the +// address is sufficient for the ownership sequence; the `Type` is only used for logging). +// If ref has no Address, the DS lookup is required and a failure is returned as an error. +func resolveContractRef(e cldf.Environment, ref datastore.AddressRef, chainSelector uint64) (datastore.AddressRef, error) { + fullRef, err := datastore_utils.FindAndFormatRef(e.DataStore, ref, chainSelector, datastore_utils.FullRef) + if err != nil { + if ref.Address != "" { + e.Logger.Warnf("failed to resolve contract ref %s for chain %d - using provided ref instead: %v", datastore_utils.SprintRef(ref), chainSelector, err) + return ref, nil + } + return datastore.AddressRef{}, fmt.Errorf("failed to resolve contract ref %s for chain %d: %w", datastore_utils.SprintRef(ref), chainSelector, err) + } + return fullRef, nil +} + // transferAndAcceptOwnership executes the transfer ownership sequence via MCMS and, // if needed, the accept ownership sequence for the given contracts on a single chain. func transferAndAcceptOwnership( From 78a4c8b3f95531fbe71f47b243d2ef2443f14fa5 Mon Sep 17 00:00:00 2001 From: chris-de-leon-cll <147140544+chris-de-leon-cll@users.noreply.github.com> Date: Wed, 15 Jul 2026 03:39:47 -0700 Subject: [PATCH 5/7] test: call AcceptOwnership --- .../adapters/transfer_ownership_test.go | 86 +++++++++++++++---- 1 file changed, 70 insertions(+), 16 deletions(-) diff --git a/chains/evm/deployment/v1_0_0/adapters/transfer_ownership_test.go b/chains/evm/deployment/v1_0_0/adapters/transfer_ownership_test.go index a92a5124e..10da3efed 100644 --- a/chains/evm/deployment/v1_0_0/adapters/transfer_ownership_test.go +++ b/chains/evm/deployment/v1_0_0/adapters/transfer_ownership_test.go @@ -48,6 +48,7 @@ func TestTransferOwnershipAddressOnlyRef(t *testing.T) { dReg.RegisterDeployer(chainsel.FamilyEVM, deploy.MCMSVersion, evmDeployer) deployMCMS := deploy.DeployMCMS(dReg, nil) + // Deploy CLL MCMS set (used for the initial transfer). output, err := deployMCMS.Apply(*env, deploy.MCMSDeploymentConfig{ AdapterVersion: semver.MustParse("1.0.0"), MCMS: testhelpers.MCMSInputForQualifier(deploymentutils.CLLQualifier), @@ -66,6 +67,24 @@ func TestTransferOwnershipAddressOnlyRef(t *testing.T) { require.NoError(t, ds.Merge(env.DataStore)) env.DataStore = ds.Seal() + // Deploy RMN MCMS set (used as the accept-ownership target). + output, err = deployMCMS.Apply(*env, deploy.MCMSDeploymentConfig{ + AdapterVersion: semver.MustParse("1.0.0"), + MCMS: testhelpers.MCMSInputForQualifier(deploymentutils.CLLQualifier), + Chains: map[uint64]deploy.MCMSDeploymentConfigPerChain{ + selector: { + Canceller: testhelpers.SingleGroupMCMS(), + Bypasser: testhelpers.SingleGroupMCMS(), + Proposer: testhelpers.SingleGroupMCMS(), + TimelockMinDelay: big.NewInt(1), + Qualifier: deploymentutils.StringPtr(deploymentutils.RMNTimelockQualifier), + }, + }, + }) + require.NoError(t, err) + require.NoError(t, ds.Merge(output.DataStore.Seal())) + env.DataStore = ds.Seal() + // Deploy a router but intentionally do NOT add it to the datastore. evmChain := env.BlockChains.EVMChains()[selector] deployRouterOp, err := cldf_ops.ExecuteOperation(env.OperationsBundle, routerops1_2.Deploy, evmChain, contract.DeployInput[routerops1_2.ConstructorArgs]{ @@ -79,7 +98,7 @@ func TestTransferOwnershipAddressOnlyRef(t *testing.T) { require.NoError(t, err) routerAddr := common.HexToAddress(deployRouterOp.Output.Address) - timelockRef, err := datastore_utils.FindAndFormatRef(env.DataStore, datastore.AddressRef{ + cllTimelockRef, err := datastore_utils.FindAndFormatRef(env.DataStore, datastore.AddressRef{ ChainSelector: selector, Type: datastore.ContractType(deploymentutils.RBACTimelock), Qualifier: deploymentutils.CLLQualifier, @@ -87,30 +106,31 @@ func TestTransferOwnershipAddressOnlyRef(t *testing.T) { }, selector, datastore_utils.FullRef) require.NoError(t, err) + rmnTimelockRef, err := datastore_utils.FindAndFormatRef(env.DataStore, datastore.AddressRef{ + ChainSelector: selector, + Type: datastore.ContractType(deploymentutils.RBACTimelock), + Qualifier: deploymentutils.RMNTimelockQualifier, + Version: semver.MustParse("1.0.0"), + }, selector, datastore_utils.FullRef) + require.NoError(t, err) + cr := deploy.GetTransferOwnershipRegistry() evmAdapter := &adapters.EVMTransferOwnershipAdapter{} cr.RegisterAdapter(chainsel.FamilyEVM, semver.MustParse("1.0.0"), evmAdapter) mcmsRegistry := changesets.GetRegistry() mcmsRegistry.RegisterMCMSReader(chainsel.FamilyEVM, &adapters.EVMMCMSReader{}) - mcmsInput := mcms.Input{ - OverridePreviousRoot: false, - ValidUntil: 3759765795, - TimelockAction: mcms_types.TimelockActionSchedule, - Qualifier: deploymentutils.CLLQualifier, - Description: "address-only ref test", - } + addrOnlyRef := []datastore.AddressRef{{Address: routerAddr.Hex()}} - // Transfer ownership using an address-only ref — router is not in the datastore. + // TransferOwnershipChangeset: transfer from deployer to CLL timelock using address-only ref. transferOutput, err := deploy.TransferOwnershipChangeset(cr, mcmsRegistry).Apply(*env, deploy.TransferOwnershipInput{ AdapterVersion: semver.MustParse("1.0.0"), - MCMS: mcmsInput, + MCMS: mcms.Input{ + ValidUntil: 3759765795, TimelockAction: mcms_types.TimelockActionSchedule, + Qualifier: deploymentutils.CLLQualifier, Description: "transfer to CLL timelock", + }, ChainInputs: []deploy.TransferOwnershipPerChainInput{ - { - ChainSelector: selector, - ContractRef: []datastore.AddressRef{{Address: routerAddr.Hex()}}, - ProposedOwner: timelockRef.Address, - }, + {ChainSelector: selector, ContractRef: addrOnlyRef, ProposedOwner: cllTimelockRef.Address}, }, }) require.NoError(t, err) @@ -121,7 +141,41 @@ func TestTransferOwnershipAddressOnlyRef(t *testing.T) { require.NoError(t, err) owner, err := r.Owner(&bind.CallOpts{Context: t.Context()}) require.NoError(t, err) - require.Equal(t, common.HexToAddress(timelockRef.Address), owner) + require.Equal(t, common.HexToAddress(cllTimelockRef.Address), owner) + + // Transfer ownership from CLL timelock to RMN timelock (sets up the pending transfer). + transferOutput, err = deploy.TransferOwnershipChangeset(cr, mcmsRegistry).Apply(*env, deploy.TransferOwnershipInput{ + AdapterVersion: semver.MustParse("1.0.0"), + MCMS: mcms.Input{ + ValidUntil: 3759765795, TimelockAction: mcms_types.TimelockActionSchedule, + Qualifier: deploymentutils.CLLQualifier, Description: "transfer to RMN timelock", + }, + ChainInputs: []deploy.TransferOwnershipPerChainInput{ + {ChainSelector: selector, ContractRef: addrOnlyRef, ProposedOwner: rmnTimelockRef.Address}, + }, + }) + require.NoError(t, err) + require.Equal(t, 1, len(transferOutput.MCMSTimelockProposals)) + testhelpers.ProcessTimelockProposals(t, *env, transferOutput.MCMSTimelockProposals, false) + + // AcceptOwnershipChangeset: accept from RMN timelock using address-only ref. + acceptOutput, err := deploy.AcceptOwnershipChangeset(cr, mcmsRegistry).Apply(*env, deploy.TransferOwnershipInput{ + AdapterVersion: semver.MustParse("1.0.0"), + MCMS: mcms.Input{ + ValidUntil: 3759765795, TimelockAction: mcms_types.TimelockActionSchedule, + Qualifier: deploymentutils.RMNTimelockQualifier, Description: "accept from RMN timelock", + }, + ChainInputs: []deploy.TransferOwnershipPerChainInput{ + {ChainSelector: selector, ContractRef: addrOnlyRef, ProposedOwner: rmnTimelockRef.Address}, + }, + }) + require.NoError(t, err) + require.Equal(t, 1, len(acceptOutput.MCMSTimelockProposals)) + testhelpers.ProcessTimelockProposals(t, *env, acceptOutput.MCMSTimelockProposals, false) + + owner, err = r.Owner(&bind.CallOpts{Context: t.Context()}) + require.NoError(t, err) + require.Equal(t, common.HexToAddress(rmnTimelockRef.Address), owner) } // TestTransferOwnership tests transferring ownership of deployed contracts via MCMS timelocks. From c8c70aca8226167dffcf5bc8c0c7fe32a1046f4c Mon Sep 17 00:00:00 2001 From: chris-de-leon-cll <147140544+chris-de-leon-cll@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:37:58 -0700 Subject: [PATCH 6/7] fix: handle multiple matches --- deployment/deploy/transfer_ownership.go | 29 +++++++++++++++---------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/deployment/deploy/transfer_ownership.go b/deployment/deploy/transfer_ownership.go index 3c9f3bdbf..2093176ec 100644 --- a/deployment/deploy/transfer_ownership.go +++ b/deployment/deploy/transfer_ownership.go @@ -50,7 +50,7 @@ func acceptOwnershipApply(cr *TransferOwnershipAdapterRegistry, mcmsRegistry *ch if err != nil { return cldf.ChangesetOutput{}, fmt.Errorf("failed to normalize contract ref %s for chain %d: %w", datastore_utils.SprintRef(contractRef), perChainInputs.ChainSelector, err) } - addrRef, err = resolveContractRef(e, addrRef, perChainInputs.ChainSelector) + addrRef, err = resolveContractRef(e, perChainInputs.ChainSelector, addrRef) if err != nil { return cldf.ChangesetOutput{}, err } @@ -94,7 +94,7 @@ func transferOwnershipApply(cr *TransferOwnershipAdapterRegistry, mcmsRegistry * if err != nil { return cldf.ChangesetOutput{}, fmt.Errorf("failed to normalize contract ref %s for chain %d: %w", datastore_utils.SprintRef(contractRef), perChainInputs.ChainSelector, err) } - addrRef, err = resolveContractRef(e, addrRef, perChainInputs.ChainSelector) + addrRef, err = resolveContractRef(e, perChainInputs.ChainSelector, addrRef) if err != nil { return cldf.ChangesetOutput{}, err } @@ -157,20 +157,25 @@ func TransferToTimelock(chainSel uint64, e cldf.Environment, mcmsInput mcms.Inpu return transferAndAcceptOwnership(e, adapter, ownershipInput, mcmsInput) } -// resolveContractRef attempts to enrich ref with full metadata from the datastore. If ref -// already has an Address and the DS lookup fails, the original ref is returned as-is (the -// address is sufficient for the ownership sequence; the `Type` is only used for logging). -// If ref has no Address, the DS lookup is required and a failure is returned as an error. -func resolveContractRef(e cldf.Environment, ref datastore.AddressRef, chainSelector uint64) (datastore.AddressRef, error) { - fullRef, err := datastore_utils.FindAndFormatRef(e.DataStore, ref, chainSelector, datastore_utils.FullRef) - if err != nil { +// resolveContractRef attempts to enrich ref with full metadata from the datastore. +// *) 0 matches: error if ref.Address is "", otherwise warn and return the input ref as-is (Address is sufficient for the ownership sequence; Type is used only for logging) +// *) >1 matches: always a hard error regardless of whether Address is set, since ambiguous results indicate a datastore inconsistency that should not be silently bypassed +// *) =1 match: returns the enriched ref +func resolveContractRef(e cldf.Environment, chainSelector uint64, ref datastore.AddressRef) (datastore.AddressRef, error) { + ref.ChainSelector = chainSelector + matches := e.DataStore.Addresses().Filter(datastore_utils.AddressRefToFilters(ref)...) + switch len(matches) { + case 0: if ref.Address != "" { - e.Logger.Warnf("failed to resolve contract ref %s for chain %d - using provided ref instead: %v", datastore_utils.SprintRef(ref), chainSelector, err) + e.Logger.Warnf("contract ref %s not found in datastore for chain %d - using provided ref instead", datastore_utils.SprintRef(ref), chainSelector) return ref, nil } - return datastore.AddressRef{}, fmt.Errorf("failed to resolve contract ref %s for chain %d: %w", datastore_utils.SprintRef(ref), chainSelector, err) + return datastore.AddressRef{}, fmt.Errorf("contract ref %s not found in datastore for chain %d", datastore_utils.SprintRef(ref), chainSelector) + case 1: + return matches[0], nil + default: + return datastore.AddressRef{}, fmt.Errorf("ambiguous contract ref %s for chain %d: found %d matches in datastore, expected exactly 1", datastore_utils.SprintRef(ref), chainSelector, len(matches)) } - return fullRef, nil } // transferAndAcceptOwnership executes the transfer ownership sequence via MCMS and, From 111b9b5fe5da0f3d658cab74b048d7c099094bb8 Mon Sep 17 00:00:00 2001 From: chris-de-leon-cll <147140544+chris-de-leon-cll@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:53:25 -0700 Subject: [PATCH 7/7] chore: address feedback --- .../deployment/v1_0_0/adapters/transfer_ownership_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/chains/evm/deployment/v1_0_0/adapters/transfer_ownership_test.go b/chains/evm/deployment/v1_0_0/adapters/transfer_ownership_test.go index 10da3efed..9ba443efa 100644 --- a/chains/evm/deployment/v1_0_0/adapters/transfer_ownership_test.go +++ b/chains/evm/deployment/v1_0_0/adapters/transfer_ownership_test.go @@ -63,11 +63,13 @@ func TestTransferOwnershipAddressOnlyRef(t *testing.T) { }, }) require.NoError(t, err) + testhelpers.ProcessTimelockProposals(t, *env, output.MCMSTimelockProposals, false) ds := output.DataStore require.NoError(t, ds.Merge(env.DataStore)) env.DataStore = ds.Seal() - // Deploy RMN MCMS set (used as the accept-ownership target). + // Deploy RMN MCMS set. The per-chain contracts carry the RMN qualifier, but the MCMS input + // uses CLLQualifier because the MCM multisigs are owned by the CLL timelock. output, err = deployMCMS.Apply(*env, deploy.MCMSDeploymentConfig{ AdapterVersion: semver.MustParse("1.0.0"), MCMS: testhelpers.MCMSInputForQualifier(deploymentutils.CLLQualifier), @@ -82,6 +84,7 @@ func TestTransferOwnershipAddressOnlyRef(t *testing.T) { }, }) require.NoError(t, err) + testhelpers.ProcessTimelockProposals(t, *env, output.MCMSTimelockProposals, false) require.NoError(t, ds.Merge(output.DataStore.Seal())) env.DataStore = ds.Seal()