Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tall-wolves-open.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"chainlink-deployments-framework": minor
---

Add inspectors and converters helpers for mcms chain client loaders
8 changes: 8 additions & 0 deletions .mockery.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,14 @@ packages:
structname: "{{.Mock}}{{.InterfaceName}}"
interfaces:
ProposalContext:
github.com/smartcontractkit/chainlink-deployments-framework/experimental/proposalutils:
config:
all: false
dir: "{{.InterfaceDir}}/mocks"
filename: "mock_{{.InterfaceName | snakecase}}.go"
structname: "{{.Mock}}{{.InterfaceName}}"
interfaces:
InspectorFetcher:
github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/legacy/cli/mcmsv2:
interfaces:
commandRunnerI:
35 changes: 35 additions & 0 deletions experimental/proposalutils/chainsmetadata/aptos_helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package chainsmetadata

import (
"errors"

"github.com/smartcontractkit/mcms"
"github.com/smartcontractkit/mcms/sdk/aptos"
"github.com/smartcontractkit/mcms/types"
)

func AptosRoleFromAction(action types.TimelockAction) (aptos.TimelockRole, error) {
switch action {
case types.TimelockActionBypass:
return aptos.TimelockRoleBypasser, nil
case types.TimelockActionSchedule:
return aptos.TimelockRoleProposer, nil
case types.TimelockActionCancel:
return aptos.TimelockRoleCanceller, nil
default:
return 0, errors.New("unknown timelock action")
}
}

func AptosRoleFromProposal(proposal *mcms.TimelockProposal) (aptos.TimelockRole, error) {
if proposal == nil {
return 0, errors.New("aptos timelock proposal is needed")
}

role, err := AptosRoleFromAction(proposal.Action)
if err != nil {
return 0, err
}

return role, nil
}
135 changes: 135 additions & 0 deletions experimental/proposalutils/chainsmetadata/aptos_helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package chainsmetadata

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/smartcontractkit/mcms"
"github.com/smartcontractkit/mcms/sdk/aptos"
mcmsTypes "github.com/smartcontractkit/mcms/types"
)

func TestAptosRoleFromAction(t *testing.T) {
t.Parallel()

tests := []struct {
name string
action mcmsTypes.TimelockAction
expectedRole aptos.TimelockRole
expectError bool
}{
{
name: "bypass action returns bypasser role",
action: mcmsTypes.TimelockActionBypass,
expectedRole: aptos.TimelockRoleBypasser,
expectError: false,
},
{
name: "schedule action returns proposer role",
action: mcmsTypes.TimelockActionSchedule,
expectedRole: aptos.TimelockRoleProposer,
expectError: false,
},
{
name: "cancel action returns canceller role",
action: mcmsTypes.TimelockActionCancel,
expectedRole: aptos.TimelockRoleCanceller,
expectError: false,
},
{
name: "unknown action returns error",
action: mcmsTypes.TimelockAction("unknown"),
expectedRole: 0,
expectError: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

role, err := AptosRoleFromAction(tt.action)

if tt.expectError {
require.Error(t, err)
assert.Equal(t, "unknown timelock action", err.Error())
assert.Equal(t, tt.expectedRole, role)
} else {
require.NoError(t, err)
assert.Equal(t, tt.expectedRole, role)
}
})
}
}

func TestAptosRoleFromProposal(t *testing.T) {
t.Parallel()

tests := []struct {
name string
proposal *mcms.TimelockProposal
expectedRole aptos.TimelockRole
expectError bool
errorMsg string
}{
{
name: "nil proposal returns error",
proposal: nil,
expectedRole: 0,
expectError: true,
errorMsg: "aptos timelock proposal is needed",
},
{
name: "proposal with bypass action returns bypasser role",
proposal: &mcms.TimelockProposal{
Action: mcmsTypes.TimelockActionBypass,
},
expectedRole: aptos.TimelockRoleBypasser,
expectError: false,
},
{
name: "proposal with schedule action returns proposer role",
proposal: &mcms.TimelockProposal{
Action: mcmsTypes.TimelockActionSchedule,
},
expectedRole: aptos.TimelockRoleProposer,
expectError: false,
},
{
name: "proposal with cancel action returns canceller role",
proposal: &mcms.TimelockProposal{
Action: mcmsTypes.TimelockActionCancel,
},
expectedRole: aptos.TimelockRoleCanceller,
expectError: false,
},
{
name: "proposal with unknown action returns error",
proposal: &mcms.TimelockProposal{
Action: mcmsTypes.TimelockAction("unknown"),
},
expectedRole: 0,
expectError: true,
errorMsg: "unknown timelock action",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

role, err := AptosRoleFromProposal(tt.proposal)

if tt.expectError {
require.Error(t, err)
assert.Equal(t, tt.errorMsg, err.Error())
assert.Equal(t, aptos.TimelockRole(0), role)
} else {
require.NoError(t, err)
assert.Equal(t, tt.expectedRole, role)
}
})
}
}
30 changes: 30 additions & 0 deletions experimental/proposalutils/chainsmetadata/sui_helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package chainsmetadata

import (
"encoding/json"
"errors"
"fmt"

"github.com/smartcontractkit/mcms"
"github.com/smartcontractkit/mcms/sdk/sui"
"github.com/smartcontractkit/mcms/types"
)

func SuiMetadataFromProposal(selector types.ChainSelector, proposal *mcms.TimelockProposal) (sui.AdditionalFieldsMetadata, error) {
if proposal == nil {
return sui.AdditionalFieldsMetadata{}, errors.New("sui timelock proposal is needed")
}

var metadata sui.AdditionalFieldsMetadata
err := json.Unmarshal([]byte(proposal.ChainMetadata[selector].AdditionalFields), &metadata)
if err != nil {
return sui.AdditionalFieldsMetadata{}, fmt.Errorf("error unmarshaling sui chain metadata: %w", err)
}

err = metadata.Validate()
if err != nil {
return sui.AdditionalFieldsMetadata{}, fmt.Errorf("error validating sui chain metadata: %w", err)
}

return metadata, nil
}
121 changes: 121 additions & 0 deletions experimental/proposalutils/chainsmetadata/sui_helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package chainsmetadata

import (
"encoding/json"
"testing"

chainsel "github.com/smartcontractkit/chain-selectors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/smartcontractkit/mcms"
"github.com/smartcontractkit/mcms/sdk/sui"
"github.com/smartcontractkit/mcms/types"
)

func buildTestProposalWithMetadata(chainSelector types.ChainSelector, additionalFields string) *mcms.TimelockProposal {
builder := mcms.NewTimelockProposalBuilder()
builder.SetVersion("v1").
SetAction(types.TimelockActionSchedule).
SetValidUntil(4000000000).
AddTimelockAddress(chainSelector, "0x1234567890123456789012345678901234567890").
AddChainMetadata(chainSelector, types.ChainMetadata{
AdditionalFields: []byte(additionalFields),
}).
AddOperation(types.BatchOperation{
ChainSelector: chainSelector,
Transactions: []types.Transaction{
{
To: "0x1234567890123456789012345678901234567890",
Data: []byte{0x01},
AdditionalFields: json.RawMessage(`{}`),
},
},
})
proposal, err := builder.Build()
if err != nil {
panic(err)
}

return proposal
}

func TestSuiMetadataFromProposal(t *testing.T) {
t.Parallel()

chainSelector := types.ChainSelector(chainsel.GETH_TESTNET.Selector)
validMetadataJSON := `{"mcms_package_id":"0x1","role":1,"account_obj":"0x2","registry_obj":"0x3","timelock_obj":"0x4","deployer_state_obj":"0x5"}`
invalidJSON := `{"mcms_package_id":"0x1","role":1`
missingFieldsJSON := `{"role":1}`

tests := []struct {
name string
selector types.ChainSelector
proposal *mcms.TimelockProposal
expectError bool
errorMsg string
}{
{
name: "nil proposal returns error",
selector: chainSelector,
proposal: nil,
expectError: true,
errorMsg: "sui timelock proposal is needed",
},
{
name: "valid metadata returns success",
selector: chainSelector,
proposal: buildTestProposalWithMetadata(chainSelector, validMetadataJSON),
expectError: false,
},
{
name: "invalid JSON returns error",
selector: chainSelector,
proposal: buildTestProposalWithMetadata(chainSelector, invalidJSON),
expectError: true,
errorMsg: "error unmarshaling sui chain metadata",
},
{
name: "missing required fields returns validation error",
selector: chainSelector,
proposal: buildTestProposalWithMetadata(chainSelector, missingFieldsJSON),
expectError: true,
errorMsg: "error validating sui chain metadata",
},
{
name: "empty additional fields returns unmarshaling error",
selector: chainSelector,
proposal: buildTestProposalWithMetadata(chainSelector, ""),
expectError: true,
errorMsg: "error unmarshaling sui chain metadata",
},
{
name: "missing chain selector in metadata",
selector: types.ChainSelector(999),
proposal: buildTestProposalWithMetadata(chainSelector, validMetadataJSON),
expectError: true,
errorMsg: "error unmarshaling sui chain metadata",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

metadata, err := SuiMetadataFromProposal(tt.selector, tt.proposal)

if tt.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.errorMsg)
assert.Equal(t, sui.AdditionalFieldsMetadata{}, metadata)
} else {
require.NoError(t, err)
assert.Equal(t, "0x1", metadata.McmsPackageID)
assert.Equal(t, sui.TimelockRole(1), metadata.Role)
assert.Equal(t, "0x2", metadata.AccountObj)
assert.Equal(t, "0x3", metadata.RegistryObj)
assert.Equal(t, "0x4", metadata.TimelockObj)
}
})
}
}
40 changes: 40 additions & 0 deletions experimental/proposalutils/converters.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package proposalutils

import (
"fmt"

chainsel "github.com/smartcontractkit/chain-selectors"
"github.com/smartcontractkit/mcms"
"github.com/smartcontractkit/mcms/sdk"
"github.com/smartcontractkit/mcms/sdk/aptos"
"github.com/smartcontractkit/mcms/sdk/evm"
"github.com/smartcontractkit/mcms/sdk/solana"
"github.com/smartcontractkit/mcms/types"
)

// BuildConvertersForTimelockProposal constructs a map of chain selectors to their respective timelock converters based on the provided timelock proposal.
func BuildConvertersForTimelockProposal(proposal mcms.TimelockProposal) (map[types.ChainSelector]sdk.TimelockConverter, error) {
converters := make(map[types.ChainSelector]sdk.TimelockConverter)
for chainMeta := range proposal.ChainMetadata {
fam, err := types.GetChainSelectorFamily(chainMeta)
if err != nil {
return nil, fmt.Errorf("error getting chain family: %w", err)
}

var converter sdk.TimelockConverter
switch fam {
case chainsel.FamilyEVM:
converter = &evm.TimelockConverter{}
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent converter instantiation: EVM uses pointer while Solana (line 29) uses value type. Consider making the pattern consistent across all chain families.

Copilot uses AI. Check for mistakes.
case chainsel.FamilySolana:
converter = solana.TimelockConverter{}
case chainsel.FamilyAptos:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aren't we missing a few chains here? Like Sui?

converter = aptos.NewTimelockConverter()
default:
return nil, fmt.Errorf("unsupported chain family %s", fam)
}

converters[chainMeta] = converter
}

return converters, nil
}
Loading