Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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/moody-grapes-make.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"chainlink-deployments-framework": patch
---

improve error messages when proposal ctx is not correctly provided to mcms cli
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 chainMeta 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 chainMeta family %s", fam)
}

converters[chainMeta] = converter
}

return converters, nil
}
Loading