Skip to content

Commit 5d4c0ad

Browse files
feat(mcms): add Solana deploy changeset for MCMS with timelock [CLD-2719]
Register Solana in the MCMS deploy registry with an operations-based sequence that deploys programs, initializes accounts/instances, and grants timelock roles, matching the EVM deploy pattern.
1 parent 2da6931 commit 5d4c0ad

6 files changed

Lines changed: 1099 additions & 0 deletions

File tree

mcms/solana/deploy/addresses.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package soldeploy
2+
3+
import (
4+
solanago "github.com/gagliardetto/solana-go"
5+
cldfdatastore "github.com/smartcontractkit/chainlink-deployments-framework/datastore"
6+
cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment"
7+
mcmscontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/mcms"
8+
9+
"github.com/smartcontractkit/cld-changesets/internal/semvers"
10+
legacysolana "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana"
11+
)
12+
13+
// deployedAddresses holds the on-chain state of an MCMS+timelock deployment on one
14+
// Solana chain. Zero values mean the corresponding program or account has not yet
15+
// been deployed/initialized.
16+
type deployedAddresses struct {
17+
AccessControllerProgram solanago.PublicKey
18+
ProposerAccessControllerAccount solanago.PublicKey
19+
ExecutorAccessControllerAccount solanago.PublicKey
20+
CancellerAccessControllerAccount solanago.PublicKey
21+
BypasserAccessControllerAccount solanago.PublicKey
22+
McmProgram solanago.PublicKey
23+
ProposerMCMSeed legacysolana.PDASeed
24+
CancellerMCMSeed legacysolana.PDASeed
25+
BypasserMCMSeed legacysolana.PDASeed
26+
TimelockProgram solanago.PublicKey
27+
TimelockSeed legacysolana.PDASeed
28+
}
29+
30+
func (d deployedAddresses) hasProposerMCM() bool {
31+
return d.ProposerMCMSeed != (legacysolana.PDASeed{})
32+
}
33+
func (d deployedAddresses) hasCancellerMCM() bool {
34+
return d.CancellerMCMSeed != (legacysolana.PDASeed{})
35+
}
36+
func (d deployedAddresses) hasBypasserMCM() bool {
37+
return d.BypasserMCMSeed != (legacysolana.PDASeed{})
38+
}
39+
func (d deployedAddresses) hasTimelock() bool { return d.TimelockSeed != (legacysolana.PDASeed{}) }
40+
41+
// loadDeployedAddresses returns the current deployment state for the given chain
42+
// and qualifier by reading address refs from the datastore. A zero value in any
43+
// field means the corresponding program or account has not been deployed yet.
44+
//
45+
// Version filtering matches EVM behaviour: prefer v1.0.0 refs; fall back to
46+
// unversioned refs; ignore refs with a different explicit version so a version
47+
// bump can trigger a re-deploy.
48+
func loadDeployedAddresses(ds cldfdatastore.DataStore, chainSelector uint64, qualifier string) deployedAddresses {
49+
if ds == nil {
50+
return deployedAddresses{}
51+
}
52+
53+
var addrs deployedAddresses
54+
55+
// findRef returns the address string for a given contract type, applying
56+
// qualifier and version filtering. Returns ("", false) if not found.
57+
findRef := func(ct cldf.ContractType) (string, bool) {
58+
base := make([]cldfdatastore.FilterFunc[cldfdatastore.AddressRefKey, cldfdatastore.AddressRef], 0, 4)
59+
base = append(base,
60+
cldfdatastore.AddressRefByChainSelector(chainSelector),
61+
cldfdatastore.AddressRefByType(cldfdatastore.ContractType(ct)),
62+
cldfdatastore.AddressRefByQualifier(qualifier),
63+
)
64+
65+
v := semvers.V1_0_0
66+
if refs := ds.Addresses().Filter(append(base, cldfdatastore.AddressRefByVersion(&v))...); len(refs) > 0 {
67+
return refs[0].Address, true
68+
}
69+
70+
for _, ref := range ds.Addresses().Filter(base...) {
71+
if ref.Version == nil {
72+
return ref.Address, true
73+
}
74+
}
75+
76+
return "", false
77+
}
78+
79+
// Plain base58 addresses (program IDs and AC accounts)
80+
if addr, ok := findRef(mcmscontracts.AccessControllerProgram); ok {
81+
addrs.AccessControllerProgram, _ = solanago.PublicKeyFromBase58(addr)
82+
}
83+
if addr, ok := findRef(mcmscontracts.ProposerAccessControllerAccount); ok {
84+
addrs.ProposerAccessControllerAccount, _ = solanago.PublicKeyFromBase58(addr)
85+
}
86+
if addr, ok := findRef(mcmscontracts.ExecutorAccessControllerAccount); ok {
87+
addrs.ExecutorAccessControllerAccount, _ = solanago.PublicKeyFromBase58(addr)
88+
}
89+
if addr, ok := findRef(mcmscontracts.CancellerAccessControllerAccount); ok {
90+
addrs.CancellerAccessControllerAccount, _ = solanago.PublicKeyFromBase58(addr)
91+
}
92+
if addr, ok := findRef(mcmscontracts.BypasserAccessControllerAccount); ok {
93+
addrs.BypasserAccessControllerAccount, _ = solanago.PublicKeyFromBase58(addr)
94+
}
95+
96+
// MCM program — prefer ManyChainMultisigProgram; fall back to reading
97+
// from encoded instance refs if only instances were saved.
98+
if addr, ok := findRef(mcmscontracts.ManyChainMultisigProgram); ok {
99+
addrs.McmProgram, _ = solanago.PublicKeyFromBase58(addr)
100+
}
101+
102+
// Seed-encoded MCM instance addresses (programID:seed)
103+
for ct, dst := range map[cldf.ContractType]*legacysolana.PDASeed{
104+
mcmscontracts.ProposerManyChainMultisig: &addrs.ProposerMCMSeed,
105+
mcmscontracts.CancellerManyChainMultisig: &addrs.CancellerMCMSeed,
106+
mcmscontracts.BypasserManyChainMultisig: &addrs.BypasserMCMSeed,
107+
} {
108+
addr, ok := findRef(ct)
109+
if !ok {
110+
continue
111+
}
112+
programID, seed, err := legacysolana.DecodeAddressWithSeed(addr)
113+
if err != nil {
114+
continue
115+
}
116+
// The program ID embedded in instance refs is authoritative when the
117+
// program-level ref is absent.
118+
if addrs.McmProgram.IsZero() {
119+
addrs.McmProgram = programID
120+
}
121+
*dst = seed
122+
}
123+
124+
// Timelock program
125+
if addr, ok := findRef(mcmscontracts.RBACTimelockProgram); ok {
126+
addrs.TimelockProgram, _ = solanago.PublicKeyFromBase58(addr)
127+
}
128+
129+
// Seed-encoded timelock instance (programID:seed)
130+
if addr, ok := findRef(mcmscontracts.RBACTimelock); ok {
131+
programID, seed, err := legacysolana.DecodeAddressWithSeed(addr)
132+
if err == nil {
133+
if addrs.TimelockProgram.IsZero() {
134+
addrs.TimelockProgram = programID
135+
}
136+
addrs.TimelockSeed = seed
137+
}
138+
}
139+
140+
return addrs
141+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package soldeploy_test
2+
3+
import (
4+
"testing"
5+
6+
chainselectors "github.com/smartcontractkit/chain-selectors"
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/smartcontractkit/chainlink-deployments-framework/datastore"
10+
mcmscontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/mcms"
11+
cldfproposalutils "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils"
12+
cldftesthelpers "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils/testhelpers"
13+
"github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment"
14+
"github.com/smartcontractkit/chainlink-deployments-framework/engine/test/runtime"
15+
"github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger"
16+
17+
"github.com/smartcontractkit/cld-changesets/internal/semvers"
18+
solutils "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana/solutils"
19+
soltestutils "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana/testutils"
20+
"github.com/smartcontractkit/cld-changesets/mcms/changesets/deploy"
21+
22+
_ "github.com/smartcontractkit/cld-changesets/mcms/solana/deploy"
23+
)
24+
25+
// All Solana integration tests share global mcm/timelock/ac SetProgramID state;
26+
// they must run sequentially to avoid races.
27+
28+
// datastoreWithMCMSPrograms seeds the datastore with canonical MCMS program IDs.
29+
// The test validator preloads the same programs via WithSolanaContainer; program
30+
// deploy is skipped because artifacts lack -keypair.json files required by
31+
// solana program deploy for fixed program IDs.
32+
func datastoreWithMCMSPrograms(t *testing.T, selector uint64) datastore.DataStore {
33+
t.Helper()
34+
35+
v := semvers.V1_0_0
36+
ds := datastore.NewMemoryDataStore()
37+
for _, entry := range []struct {
38+
addr string
39+
ct datastore.ContractType
40+
}{
41+
{solutils.GetProgramID(solutils.ProgAccessController), datastore.ContractType(mcmscontracts.AccessControllerProgram)},
42+
{solutils.GetProgramID(solutils.ProgMCM), datastore.ContractType(mcmscontracts.ManyChainMultisigProgram)},
43+
{solutils.GetProgramID(solutils.ProgTimelock), datastore.ContractType(mcmscontracts.RBACTimelockProgram)},
44+
} {
45+
require.NoError(t, ds.Addresses().Add(datastore.AddressRef{
46+
ChainSelector: selector,
47+
Address: entry.addr,
48+
Type: entry.ct,
49+
Version: &v,
50+
}))
51+
}
52+
53+
return ds.Seal()
54+
}
55+
56+
//nolint:paralleltest // global SetProgramID state; serialized via soltestutils.PreloadMCMS lock
57+
func TestDeployMCMSWithTimelock_Solana_FreshDeploy(t *testing.T) {
58+
selector := chainselectors.TEST_22222222222222222222222222222222222222222222.Selector
59+
60+
programsPath, programIDs := soltestutils.LoadMCMSPrograms(t, t.TempDir())
61+
rt, err := runtime.New(t.Context(), runtime.WithEnvOpts(
62+
environment.WithSolanaContainer(t, []uint64{selector}, programsPath, programIDs),
63+
environment.WithDatastore(datastoreWithMCMSPrograms(t, selector)),
64+
environment.WithLogger(logger.Test(t)),
65+
))
66+
require.NoError(t, err)
67+
68+
err = rt.Exec(runtime.ChangesetTask(deploy.Changeset{}, deploy.Input{
69+
ConfigByChain: map[uint64]cldfproposalutils.MCMSWithTimelockConfig{
70+
selector: cldftesthelpers.SingleGroupTimelockConfig(t),
71+
},
72+
}))
73+
require.NoError(t, err)
74+
75+
// 3 programs + 4 AC accounts + 3 MCM instances + 1 timelock = 11
76+
refs, err := rt.State().DataStore.Addresses().Fetch()
77+
require.NoError(t, err)
78+
require.Len(t, refs, 11, "expected 11 MCMS contract address refs")
79+
80+
contractTypes := make(map[datastore.ContractType]struct{}, 11)
81+
for _, ref := range refs {
82+
require.Equal(t, selector, ref.ChainSelector)
83+
require.True(t, semvers.V1_0_0.Equal(ref.Version))
84+
contractTypes[ref.Type] = struct{}{}
85+
}
86+
87+
for _, ct := range []datastore.ContractType{
88+
datastore.ContractType(mcmscontracts.AccessControllerProgram),
89+
datastore.ContractType(mcmscontracts.ProposerAccessControllerAccount),
90+
datastore.ContractType(mcmscontracts.ExecutorAccessControllerAccount),
91+
datastore.ContractType(mcmscontracts.CancellerAccessControllerAccount),
92+
datastore.ContractType(mcmscontracts.BypasserAccessControllerAccount),
93+
datastore.ContractType(mcmscontracts.ManyChainMultisigProgram),
94+
datastore.ContractType(mcmscontracts.ProposerManyChainMultisig),
95+
datastore.ContractType(mcmscontracts.CancellerManyChainMultisig),
96+
datastore.ContractType(mcmscontracts.BypasserManyChainMultisig),
97+
datastore.ContractType(mcmscontracts.RBACTimelockProgram),
98+
datastore.ContractType(mcmscontracts.RBACTimelock),
99+
} {
100+
require.Contains(t, contractTypes, ct)
101+
}
102+
103+
// Verify operation reports were emitted.
104+
var reportsLen int
105+
for _, out := range rt.State().Outputs {
106+
reportsLen += len(out.Reports)
107+
}
108+
require.NotZero(t, reportsLen, "expected operation reports in changeset output")
109+
}
110+
111+
//nolint:paralleltest // global SetProgramID state
112+
func TestDeployMCMSWithTimelock_Solana_PartialDeploy(t *testing.T) {
113+
selector := chainselectors.TEST_22222222222222222222222222222222222222222222.Selector
114+
115+
programsPath, programIDs := soltestutils.LoadMCMSPrograms(t, t.TempDir())
116+
rt, err := runtime.New(t.Context(), runtime.WithEnvOpts(
117+
environment.WithSolanaContainer(t, []uint64{selector}, programsPath, programIDs),
118+
environment.WithDatastore(datastoreWithMCMSPrograms(t, selector)),
119+
environment.WithLogger(logger.Test(t)),
120+
))
121+
require.NoError(t, err)
122+
123+
err = rt.Exec(runtime.ChangesetTask(deploy.Changeset{}, deploy.Input{
124+
ConfigByChain: map[uint64]cldfproposalutils.MCMSWithTimelockConfig{
125+
selector: cldftesthelpers.SingleGroupTimelockConfig(t),
126+
},
127+
}))
128+
require.NoError(t, err)
129+
130+
// 3 pre-existing programs + 4 AC accounts + 3 MCM instances + 1 timelock = 11 total
131+
refs, err := rt.State().DataStore.Addresses().Fetch()
132+
require.NoError(t, err)
133+
require.Len(t, refs, 11, "expected 11 total refs after partial deploy")
134+
135+
byType := make(map[datastore.ContractType]datastore.AddressRef, 11)
136+
for _, ref := range refs {
137+
byType[ref.Type] = ref
138+
}
139+
140+
// Pre-existing programs must retain their canonical IDs (not be re-deployed).
141+
require.Equal(t, solutils.GetProgramID(solutils.ProgAccessController),
142+
byType[datastore.ContractType(mcmscontracts.AccessControllerProgram)].Address)
143+
require.Equal(t, solutils.GetProgramID(solutils.ProgMCM),
144+
byType[datastore.ContractType(mcmscontracts.ManyChainMultisigProgram)].Address)
145+
require.Equal(t, solutils.GetProgramID(solutils.ProgTimelock),
146+
byType[datastore.ContractType(mcmscontracts.RBACTimelockProgram)].Address)
147+
}

mcms/solana/deploy/idempotency.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package soldeploy
2+
3+
import (
4+
"strconv"
5+
6+
cldfsol "github.com/smartcontractkit/chainlink-deployments-framework/chain/solana"
7+
"github.com/smartcontractkit/chainlink-deployments-framework/operations"
8+
)
9+
10+
// chainIdempotencyKey scopes an operation report to a single chain. Different
11+
// calls on the same chain are distinguished by the operation's input fields
12+
// (program name, contract type, MCM config, etc.) which are hashed together
13+
// with the operation definition and this key to form the final cache key.
14+
func chainIdempotencyKey[IN, DEP any](chain cldfsol.Chain) operations.ExecuteOption[IN, DEP] {
15+
return operations.WithIdempotencyKey[IN, DEP](strconv.FormatUint(chain.Selector, 10))
16+
}

0 commit comments

Comments
 (0)