diff --git a/internal/testutil/datastoretest/datastoretest.go b/internal/testutil/datastoretest/datastoretest.go new file mode 100644 index 0000000..2ffd645 --- /dev/null +++ b/internal/testutil/datastoretest/datastoretest.go @@ -0,0 +1,54 @@ +// Package datastoretest provides lightweight fakes for datastore interfaces in unit tests. +package datastoretest + +import ( + cldfdatastore "github.com/smartcontractkit/chainlink-deployments-framework/datastore" +) + +// NewDataStore returns a read-only DataStore backed by the given address refs. +func NewDataStore(refs []cldfdatastore.AddressRef) cldfdatastore.DataStore { + return fakeDataStore{store: fakeAddressRefStore{refs: refs}} +} + +type fakeAddressRefStore struct { + refs []cldfdatastore.AddressRef +} + +var _ cldfdatastore.AddressRefStore = fakeAddressRefStore{} + +func (f fakeAddressRefStore) Fetch() ([]cldfdatastore.AddressRef, error) { + return f.refs, nil +} + +func (f fakeAddressRefStore) Get(key cldfdatastore.AddressRefKey) (cldfdatastore.AddressRef, error) { + for _, ref := range f.refs { + if ref.Key().Equals(key) { + return ref, nil + } + } + + return cldfdatastore.AddressRef{}, cldfdatastore.ErrAddressRefNotFound +} + +func (f fakeAddressRefStore) Filter(filters ...cldfdatastore.FilterFunc[cldfdatastore.AddressRefKey, cldfdatastore.AddressRef]) []cldfdatastore.AddressRef { + refs := f.refs + for _, filter := range filters { + refs = filter(refs) + } + + return refs +} + +type fakeDataStore struct { + store fakeAddressRefStore +} + +var _ cldfdatastore.DataStore = fakeDataStore{} + +func (f fakeDataStore) Addresses() cldfdatastore.AddressRefStore { return f.store } + +func (f fakeDataStore) ChainMetadata() cldfdatastore.ChainMetadataStore { return nil } + +func (f fakeDataStore) ContractMetadata() cldfdatastore.ContractMetadataStore { return nil } + +func (f fakeDataStore) EnvMetadata() cldfdatastore.EnvMetadataStore { return nil } diff --git a/mcms/changesets/set-config/changeset_test.go b/mcms/changesets/set-config/changeset_test.go index a74a0f8..f934f26 100644 --- a/mcms/changesets/set-config/changeset_test.go +++ b/mcms/changesets/set-config/changeset_test.go @@ -176,35 +176,6 @@ func TestChangeset_VerifyPreconditions(t *testing.T) { } } -//nolint:paralleltest // global mcm.SetProgramID state and shared Solana CTF container setup -func TestChangeset_VerifyPreconditions_Solana(t *testing.T) { - selector := chain_selectors.TEST_22222222222222222222222222222222222222222222.Selector - rt := newSolanaRuntimeWithDeploy(t, selector) - env := rt.Environment() - - validCfg := cldftesthelpers.SingleGroupMCMS(t) - validTargets := mcmsTargets(selector, validCfg, validCfg, validCfg) - - cs := setconfig.Changeset{} - for _, tt := range []struct { - name string - input setconfig.Input - }{ - { - name: "valid direct-send input", - input: setConfigInput(validTargets, nil), - }, - { - name: "valid MCMS input", - input: setConfigInput(validTargets, newMCMSInput(mcmstypes.TimelockActionSchedule, "valid solana proposal", "")), - }, - } { - t.Run(tt.name, func(t *testing.T) { - require.NoError(t, cs.VerifyPreconditions(env, tt.input)) - }) - } -} - func TestChangeset_EVM(t *testing.T) { t.Parallel() @@ -484,6 +455,17 @@ func TestChangeset_Solana(t *testing.T) { require.NoError(t, err) soltestutils.FundSignerPDAs(t, chain, mcmsState) + // Verify preconditions pass on a Solana environment before mutating state. + validCfg := cldftesthelpers.SingleGroupMCMS(t) + validTargets := mcmsTargets(selector, validCfg, validCfg, validCfg) + cs := setconfig.Changeset{} + t.Run("verify preconditions - valid direct-send input", func(t *testing.T) { //nolint:paralleltest // shared runtime state + require.NoError(t, cs.VerifyPreconditions(rt.Environment(), setConfigInput(validTargets, nil))) + }) + t.Run("verify preconditions - valid MCMS input", func(t *testing.T) { //nolint:paralleltest // shared runtime state + require.NoError(t, cs.VerifyPreconditions(rt.Environment(), setConfigInput(validTargets, newMCMSInput(mcmstypes.TimelockActionSchedule, "valid solana proposal", "")))) + }) + inspector := solana.NewInspector(chain.Client) signer1Key, signer1Addr := createSolSigner(t) _, signer2Addr := createSolSigner(t) diff --git a/mcms/evm/deploy/addresses.go b/mcms/evm/deploy/addresses.go index 58f9b26..8a37981 100644 --- a/mcms/evm/deploy/addresses.go +++ b/mcms/evm/deploy/addresses.go @@ -3,6 +3,8 @@ package evmdeploy import ( + "fmt" + "github.com/ethereum/go-ethereum/common" cldfdatastore "github.com/smartcontractkit/chainlink-deployments-framework/datastore" cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" @@ -21,9 +23,9 @@ type deployedAddresses struct { CallProxy common.Address } -func loadDeployedAddresses(ds cldfdatastore.DataStore, chainSelector uint64, qualifier string) deployedAddresses { +func loadDeployedAddresses(ds cldfdatastore.DataStore, chainSelector uint64, qualifier string) (deployedAddresses, error) { if ds == nil { - return deployedAddresses{} + return deployedAddresses{}, nil } type lookup struct { @@ -41,45 +43,46 @@ func loadDeployedAddresses(ds cldfdatastore.DataStore, chainSelector uint64, qua } for _, l := range lookups { - if addr, ok := findDeployedAddress(ds.Addresses(), chainSelector, l.contractType, qualifier); ok { + addr, ok, err := findDeployedAddress(ds.Addresses(), chainSelector, l.contractType, qualifier) + if err != nil { + return deployedAddresses{}, err + } + if ok { *l.dest = addr } } - return addrs + return addrs, nil } // findDeployedAddress returns a previously deployed contract address for this -// deploy package's version. Legacy datastore entries without a version are -// accepted as a fallback; refs with a different version are ignored so a -// version bump can redeploy. +// deploy package's version. Qualifier is always matched exactly, including "". +// Returns ok=false when no ref matches; an error when multiple refs match. func findDeployedAddress( store cldfdatastore.AddressRefStore, chainSelector uint64, contractType cldf.ContractType, qualifier string, -) (common.Address, bool) { - baseFilters := make([]cldfdatastore.FilterFunc[cldfdatastore.AddressRefKey, cldfdatastore.AddressRef], 0, 3) - baseFilters = append(baseFilters, +) (common.Address, bool, error) { + version := semvers.V1_0_0 + refs := store.Filter( cldfdatastore.AddressRefByChainSelector(chainSelector), cldfdatastore.AddressRefByType(cldfdatastore.ContractType(contractType)), cldfdatastore.AddressRefByQualifier(qualifier), + cldfdatastore.AddressRefByVersion(&version), ) - - version := semvers.V1_0_0 - refs := store.Filter(append(baseFilters, cldfdatastore.AddressRefByVersion(&version))...) - if len(refs) > 0 { - return common.HexToAddress(refs[0].Address), true + switch len(refs) { + case 0: + return common.Address{}, false, nil + case 1: + return common.HexToAddress(refs[0].Address), true, nil + default: + return common.Address{}, false, fmt.Errorf( + "%w: chain selector %d contract type %s qualifier %q version %s: found %d refs", + cldfdatastore.ErrAddressRefQueryAmbiguous, + chainSelector, contractType, qualifier, version, len(refs), + ) } - - refs = store.Filter(baseFilters...) - for _, ref := range refs { - if ref.Version == nil { - return common.HexToAddress(ref.Address), true - } - } - - return common.Address{}, false } // addressRefWithLabel attaches an optional label to a deployed contract address ref. diff --git a/mcms/evm/deploy/addresses_test.go b/mcms/evm/deploy/addresses_test.go index f40f8de..b0f5bd5 100644 --- a/mcms/evm/deploy/addresses_test.go +++ b/mcms/evm/deploy/addresses_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/smartcontractkit/cld-changesets/internal/semvers" + "github.com/smartcontractkit/cld-changesets/internal/testutil/datastoretest" ) func TestLoadDeployedAddresses(t *testing.T) { @@ -21,13 +22,14 @@ func TestLoadDeployedAddresses(t *testing.T) { v090 := semver.MustParse("0.9.0") bypasserV100 := common.HexToAddress("0x00000000000000000000000000000000000000b1") - bypasserLegacy := common.HexToAddress("0x00000000000000000000000000000000000000b2") bypasserOld := common.HexToAddress("0x00000000000000000000000000000000000000b3") proposerV100 := common.HexToAddress("0x00000000000000000000000000000000000000c1") t.Run("nil datastore", func(t *testing.T) { t.Parallel() - require.Equal(t, deployedAddresses{}, loadDeployedAddresses(nil, selector, "")) + addrs, err := loadDeployedAddresses(nil, selector, "") + require.NoError(t, err) + require.Equal(t, deployedAddresses{}, addrs) }) t.Run("matches v1.0.0", func(t *testing.T) { @@ -41,7 +43,8 @@ func TestLoadDeployedAddresses(t *testing.T) { Version: &v100, })) - addrs := loadDeployedAddresses(ds.Seal(), selector, "") + addrs, err := loadDeployedAddresses(ds.Seal(), selector, "") + require.NoError(t, err) require.Equal(t, bypasserV100, addrs.Bypasser) }) @@ -56,63 +59,50 @@ func TestLoadDeployedAddresses(t *testing.T) { Version: v090, })) - addrs := loadDeployedAddresses(ds.Seal(), selector, "") + addrs, err := loadDeployedAddresses(ds.Seal(), selector, "") + require.NoError(t, err) require.Equal(t, common.Address{}, addrs.Bypasser) }) - t.Run("falls back to legacy nil version", func(t *testing.T) { + t.Run("respects qualifier", func(t *testing.T) { t.Parallel() - store := fakeAddressRefStore{refs: []cldfdatastore.AddressRef{{ + ds := cldfdatastore.NewMemoryDataStore() + require.NoError(t, ds.Addresses().Add(cldfdatastore.AddressRef{ ChainSelector: selector, - Address: bypasserLegacy.Hex(), - Type: cldfdatastore.ContractType(mcmscontracts.BypasserManyChainMultisig), - }}} + Address: proposerV100.Hex(), + Type: cldfdatastore.ContractType(mcmscontracts.ProposerManyChainMultisig), + Version: &v100, + Qualifier: "prod", + })) + + addrs, err := loadDeployedAddresses(ds.Seal(), selector, "") + require.NoError(t, err) + require.Equal(t, common.Address{}, addrs.Proposer) - got, ok := findDeployedAddress(store, selector, mcmscontracts.BypasserManyChainMultisig, "") - require.True(t, ok) - require.Equal(t, bypasserLegacy, got) + addrs, err = loadDeployedAddresses(ds.Seal(), selector, "prod") + require.NoError(t, err) + require.Equal(t, proposerV100, addrs.Proposer) }) - t.Run("prefers v1.0.0 over legacy nil version", func(t *testing.T) { + t.Run("duplicate refs", func(t *testing.T) { t.Parallel() - store := fakeAddressRefStore{refs: []cldfdatastore.AddressRef{ + _, err := loadDeployedAddresses(datastoretest.NewDataStore([]cldfdatastore.AddressRef{ { ChainSelector: selector, - Address: bypasserLegacy.Hex(), + Address: bypasserV100.Hex(), Type: cldfdatastore.ContractType(mcmscontracts.BypasserManyChainMultisig), + Version: &v100, }, { ChainSelector: selector, - Address: bypasserV100.Hex(), + Address: bypasserOld.Hex(), Type: cldfdatastore.ContractType(mcmscontracts.BypasserManyChainMultisig), Version: &v100, }, - }} - - got, ok := findDeployedAddress(store, selector, mcmscontracts.BypasserManyChainMultisig, "") - require.True(t, ok) - require.Equal(t, bypasserV100, got) - }) - - t.Run("respects qualifier", func(t *testing.T) { - t.Parallel() - - ds := cldfdatastore.NewMemoryDataStore() - require.NoError(t, ds.Addresses().Add(cldfdatastore.AddressRef{ - ChainSelector: selector, - Address: proposerV100.Hex(), - Type: cldfdatastore.ContractType(mcmscontracts.ProposerManyChainMultisig), - Version: &v100, - Qualifier: "prod", - })) - - addrs := loadDeployedAddresses(ds.Seal(), selector, "") - require.Equal(t, common.Address{}, addrs.Proposer) - - addrs = loadDeployedAddresses(ds.Seal(), selector, "prod") - require.Equal(t, proposerV100, addrs.Proposer) + }), selector, "") + require.ErrorIs(t, err, cldfdatastore.ErrAddressRefQueryAmbiguous) }) } @@ -131,34 +121,8 @@ func TestFindDeployedAddress(t *testing.T) { Version: &v100, })) - got, ok := findDeployedAddress(ds.Addresses(), selector, mcmscontracts.RBACTimelock, "") + got, ok, err := findDeployedAddress(ds.Addresses(), selector, mcmscontracts.RBACTimelock, "") + require.NoError(t, err) require.True(t, ok) require.Equal(t, addr, got) } - -type fakeAddressRefStore struct { - refs []cldfdatastore.AddressRef -} - -func (f fakeAddressRefStore) Fetch() ([]cldfdatastore.AddressRef, error) { - return f.refs, nil -} - -func (f fakeAddressRefStore) Get(key cldfdatastore.AddressRefKey) (cldfdatastore.AddressRef, error) { - for _, ref := range f.refs { - if ref.Key().Equals(key) { - return ref, nil - } - } - - return cldfdatastore.AddressRef{}, cldfdatastore.ErrAddressRefNotFound -} - -func (f fakeAddressRefStore) Filter(filters ...cldfdatastore.FilterFunc[cldfdatastore.AddressRefKey, cldfdatastore.AddressRef]) []cldfdatastore.AddressRef { - refs := f.refs - for _, filter := range filters { - refs = filter(refs) - } - - return refs -} diff --git a/mcms/evm/deploy/sequence.go b/mcms/evm/deploy/sequence.go index 555fde4..90bbaff 100644 --- a/mcms/evm/deploy/sequence.go +++ b/mcms/evm/deploy/sequence.go @@ -54,11 +54,13 @@ func deployMCMSWithTimelock( qualifier := qualifierFromConfig(in.Config.Qualifier) - existing := loadDeployedAddresses(deps.DataStore, in.ChainSelector, qualifier) + existing, err := loadDeployedAddresses(deps.DataStore, in.ChainSelector, qualifier) + if err != nil { + return sequenceutils.OnChainOutput{}, fmt.Errorf("load deployed addresses: %w", err) + } d := &deployer{b: b, chain: chain, config: in.Config, qualifier: qualifier} - var err error if existing.Bypasser, err = d.deployMCMIfNeeded(mcmscontracts.BypasserManyChainMultisig, in.Config.Bypasser, existing.Bypasser); err != nil { return d.out, err } diff --git a/mcms/solana/deploy/addresses.go b/mcms/solana/deploy/addresses.go new file mode 100644 index 0000000..8ba7eeb --- /dev/null +++ b/mcms/solana/deploy/addresses.go @@ -0,0 +1,177 @@ +package soldeploy + +import ( + "fmt" + + solanago "github.com/gagliardetto/solana-go" + cldfdatastore "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + mcmscontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/mcms" + + "github.com/smartcontractkit/cld-changesets/internal/semvers" + legacysolana "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana" +) + +// deployedAddresses holds the on-chain state of an MCMS+timelock deployment on one +// Solana chain. Zero values mean the corresponding program or account has not yet +// been deployed/initialized. +type deployedAddresses struct { + AccessControllerProgram solanago.PublicKey + ProposerAccessControllerAccount solanago.PublicKey + ExecutorAccessControllerAccount solanago.PublicKey + CancellerAccessControllerAccount solanago.PublicKey + BypasserAccessControllerAccount solanago.PublicKey + McmProgram solanago.PublicKey + ProposerMCMSeed legacysolana.PDASeed + CancellerMCMSeed legacysolana.PDASeed + BypasserMCMSeed legacysolana.PDASeed + TimelockProgram solanago.PublicKey + TimelockSeed legacysolana.PDASeed +} + +func (d deployedAddresses) hasProposerMCM() bool { + return d.ProposerMCMSeed != (legacysolana.PDASeed{}) +} +func (d deployedAddresses) hasCancellerMCM() bool { + return d.CancellerMCMSeed != (legacysolana.PDASeed{}) +} +func (d deployedAddresses) hasBypasserMCM() bool { + return d.BypasserMCMSeed != (legacysolana.PDASeed{}) +} +func (d deployedAddresses) hasTimelock() bool { return d.TimelockSeed != (legacysolana.PDASeed{}) } + +// loadDeployedAddresses returns the current deployment state for the given chain +// and qualifier by reading address refs from the datastore. A zero value in any +// field means the corresponding program or account has not been deployed yet. +func loadDeployedAddresses(ds cldfdatastore.DataStore, chainSelector uint64, qualifier string) (deployedAddresses, error) { + if ds == nil { + return deployedAddresses{}, nil + } + + var addrs deployedAddresses + + // findRef returns the address string for a given contract type at this + // deploy package's version. Qualifier is always matched exactly, including "". + // Returns ("", nil) when no ref matches; an error when multiple refs match. + findRef := func(ct cldf.ContractType) (string, error) { + version := semvers.V1_0_0 + refs := ds.Addresses().Filter( + cldfdatastore.AddressRefByChainSelector(chainSelector), + cldfdatastore.AddressRefByType(cldfdatastore.ContractType(ct)), + cldfdatastore.AddressRefByQualifier(qualifier), + cldfdatastore.AddressRefByVersion(&version), + ) + switch len(refs) { + case 0: + return "", nil + case 1: + return refs[0].Address, nil + default: + return "", fmt.Errorf( + "%w: chain selector %d contract type %s qualifier %q version %s: found %d refs", + cldfdatastore.ErrAddressRefQueryAmbiguous, + chainSelector, ct, qualifier, version, len(refs), + ) + } + } + + loadPubkey := func(ct cldf.ContractType, dest *solanago.PublicKey) error { + addr, err := findRef(ct) + if err != nil { + return err + } + if addr == "" { + return nil + } + pk, err := solanago.PublicKeyFromBase58(addr) + if err != nil { + return fmt.Errorf("parse %s address %q: %w", ct, addr, err) + } + *dest = pk + + return nil + } + + // Plain base58 addresses (program IDs and AC accounts) + for _, entry := range []struct { + ct cldf.ContractType + dest *solanago.PublicKey + }{ + {mcmscontracts.AccessControllerProgram, &addrs.AccessControllerProgram}, + {mcmscontracts.ProposerAccessControllerAccount, &addrs.ProposerAccessControllerAccount}, + {mcmscontracts.ExecutorAccessControllerAccount, &addrs.ExecutorAccessControllerAccount}, + {mcmscontracts.CancellerAccessControllerAccount, &addrs.CancellerAccessControllerAccount}, + {mcmscontracts.BypasserAccessControllerAccount, &addrs.BypasserAccessControllerAccount}, + {mcmscontracts.ManyChainMultisigProgram, &addrs.McmProgram}, + {mcmscontracts.RBACTimelockProgram, &addrs.TimelockProgram}, + } { + if err := loadPubkey(entry.ct, entry.dest); err != nil { + return deployedAddresses{}, err + } + } + + // Seed-encoded MCM instance addresses (programID:seed) + for ct, dst := range map[cldf.ContractType]*legacysolana.PDASeed{ + mcmscontracts.ProposerManyChainMultisig: &addrs.ProposerMCMSeed, + mcmscontracts.CancellerManyChainMultisig: &addrs.CancellerMCMSeed, + mcmscontracts.BypasserManyChainMultisig: &addrs.BypasserMCMSeed, + } { + addr, err := findRef(ct) + if err != nil { + return deployedAddresses{}, err + } + if addr == "" { + continue + } + programID, seed, err := legacysolana.DecodeAddressWithSeed(addr) + if err != nil { + return deployedAddresses{}, fmt.Errorf("decode %s address %q: %w", ct, addr, err) + } + if err := reconcileInstanceProgramID( + &addrs.McmProgram, programID, ct, mcmscontracts.ManyChainMultisigProgram, + ); err != nil { + return deployedAddresses{}, err + } + *dst = seed + } + + // Seed-encoded timelock instance (programID:seed) + if addr, err := findRef(mcmscontracts.RBACTimelock); err != nil { + return deployedAddresses{}, err + } else if addr != "" { + programID, seed, err := legacysolana.DecodeAddressWithSeed(addr) + if err != nil { + return deployedAddresses{}, fmt.Errorf("decode %s address %q: %w", mcmscontracts.RBACTimelock, addr, err) + } + if err := reconcileInstanceProgramID( + &addrs.TimelockProgram, programID, mcmscontracts.RBACTimelock, mcmscontracts.RBACTimelockProgram, + ); err != nil { + return deployedAddresses{}, err + } + addrs.TimelockSeed = seed + } + + return addrs, nil +} + +// reconcileInstanceProgramID sets programLevel from embedded when unset, or errors on mismatch. +func reconcileInstanceProgramID( + programLevel *solanago.PublicKey, + embedded solanago.PublicKey, + instanceType cldf.ContractType, + programType cldf.ContractType, +) error { + if programLevel.IsZero() { + *programLevel = embedded + + return nil + } + if *programLevel != embedded { + return fmt.Errorf( + "%s instance ref embeds program %s but %s ref is %s", + instanceType, embedded, programType, *programLevel, + ) + } + + return nil +} diff --git a/mcms/solana/deploy/addresses_test.go b/mcms/solana/deploy/addresses_test.go new file mode 100644 index 0000000..7f7747c --- /dev/null +++ b/mcms/solana/deploy/addresses_test.go @@ -0,0 +1,212 @@ +package soldeploy + +import ( + "testing" + + "github.com/Masterminds/semver/v3" + solanago "github.com/gagliardetto/solana-go" + chainselectors "github.com/smartcontractkit/chain-selectors" + cldfdatastore "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + mcmscontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/mcms" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cld-changesets/internal/semvers" + "github.com/smartcontractkit/cld-changesets/internal/testutil/datastoretest" + legacysolana "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana" + solutils "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana/solutils" +) + +func TestLoadDeployedAddresses(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_22222222222222222222222222222222222222222222.Selector + v100 := semvers.V1_0_0 + v090 := semver.MustParse("0.9.0") + + acProgram := solanago.MustPublicKeyFromBase58(solutils.GetProgramID(solutils.ProgAccessController)) + mcmProgram := solanago.MustPublicKeyFromBase58(solutils.GetProgramID(solutils.ProgMCM)) + proposerAC := solanago.MustPublicKeyFromBase58("11111111111111111111111111111112") + var proposerSeed legacysolana.PDASeed + copy(proposerSeed[:], "proposer-seed-123456789012345678") // 32 bytes + proposerMCM := legacysolana.EncodeAddressWithSeed(mcmProgram, proposerSeed) + + t.Run("nil datastore", func(t *testing.T) { + t.Parallel() + addrs, err := loadDeployedAddresses(nil, selector, "") + require.NoError(t, err) + require.Equal(t, deployedAddresses{}, addrs) + }) + + t.Run("matches v1.0.0 program", func(t *testing.T) { + t.Parallel() + + ds := cldfdatastore.NewMemoryDataStore() + require.NoError(t, ds.Addresses().Add(cldfdatastore.AddressRef{ + ChainSelector: selector, + Address: acProgram.String(), + Type: cldfdatastore.ContractType(mcmscontracts.AccessControllerProgram), + Version: &v100, + })) + + addrs, err := loadDeployedAddresses(ds.Seal(), selector, "") + require.NoError(t, err) + require.Equal(t, acProgram, addrs.AccessControllerProgram) + }) + + t.Run("ignores older version", func(t *testing.T) { + t.Parallel() + + ds := cldfdatastore.NewMemoryDataStore() + require.NoError(t, ds.Addresses().Add(cldfdatastore.AddressRef{ + ChainSelector: selector, + Address: acProgram.String(), + Type: cldfdatastore.ContractType(mcmscontracts.AccessControllerProgram), + Version: v090, + })) + + addrs, err := loadDeployedAddresses(ds.Seal(), selector, "") + require.NoError(t, err) + require.True(t, addrs.AccessControllerProgram.IsZero()) + }) + + t.Run("respects qualifier", func(t *testing.T) { + t.Parallel() + + ds := cldfdatastore.NewMemoryDataStore() + require.NoError(t, ds.Addresses().Add(cldfdatastore.AddressRef{ + ChainSelector: selector, + Address: proposerAC.String(), + Type: cldfdatastore.ContractType(mcmscontracts.ProposerAccessControllerAccount), + Version: &v100, + Qualifier: "prod", + })) + + addrs, err := loadDeployedAddresses(ds.Seal(), selector, "") + require.NoError(t, err) + require.True(t, addrs.ProposerAccessControllerAccount.IsZero()) + + addrs, err = loadDeployedAddresses(ds.Seal(), selector, "prod") + require.NoError(t, err) + require.Equal(t, proposerAC, addrs.ProposerAccessControllerAccount) + }) + + t.Run("loads MCM instance seed and program from encoded address", func(t *testing.T) { + t.Parallel() + + ds := cldfdatastore.NewMemoryDataStore() + require.NoError(t, ds.Addresses().Add(cldfdatastore.AddressRef{ + ChainSelector: selector, + Address: proposerMCM, + Type: cldfdatastore.ContractType(mcmscontracts.ProposerManyChainMultisig), + Version: &v100, + })) + + addrs, err := loadDeployedAddresses(ds.Seal(), selector, "") + require.NoError(t, err) + require.Equal(t, mcmProgram, addrs.McmProgram) + require.Equal(t, proposerSeed, addrs.ProposerMCMSeed) + require.True(t, addrs.hasProposerMCM()) + }) + + t.Run("invalid base58 address", func(t *testing.T) { + t.Parallel() + + ds := cldfdatastore.NewMemoryDataStore() + require.NoError(t, ds.Addresses().Add(cldfdatastore.AddressRef{ + ChainSelector: selector, + Address: "not-a-valid-address", + Type: cldfdatastore.ContractType(mcmscontracts.AccessControllerProgram), + Version: &v100, + })) + + _, err := loadDeployedAddresses(ds.Seal(), selector, "") + require.Error(t, err) + }) + + t.Run("invalid encoded MCM address", func(t *testing.T) { + t.Parallel() + + ds := cldfdatastore.NewMemoryDataStore() + require.NoError(t, ds.Addresses().Add(cldfdatastore.AddressRef{ + ChainSelector: selector, + Address: "not-a-valid-encoded-address", + Type: cldfdatastore.ContractType(mcmscontracts.ProposerManyChainMultisig), + Version: &v100, + })) + + _, err := loadDeployedAddresses(ds.Seal(), selector, "") + require.Error(t, err) + }) + + t.Run("duplicate refs", func(t *testing.T) { + t.Parallel() + + _, err := loadDeployedAddresses(datastoretest.NewDataStore([]cldfdatastore.AddressRef{ + { + ChainSelector: selector, + Address: acProgram.String(), + Type: cldfdatastore.ContractType(mcmscontracts.AccessControllerProgram), + Version: &v100, + }, + { + ChainSelector: selector, + Address: mcmProgram.String(), + Type: cldfdatastore.ContractType(mcmscontracts.AccessControllerProgram), + Version: &v100, + }, + }), selector, "") + require.ErrorIs(t, err, cldfdatastore.ErrAddressRefQueryAmbiguous) + }) + + t.Run("MCM instance program mismatch", func(t *testing.T) { + t.Parallel() + + timelockProgram := solanago.MustPublicKeyFromBase58(solutils.GetProgramID(solutils.ProgTimelock)) + staleProposerMCM := legacysolana.EncodeAddressWithSeed(timelockProgram, proposerSeed) + + ds := cldfdatastore.NewMemoryDataStore() + require.NoError(t, ds.Addresses().Add(cldfdatastore.AddressRef{ + ChainSelector: selector, + Address: mcmProgram.String(), + Type: cldfdatastore.ContractType(mcmscontracts.ManyChainMultisigProgram), + Version: &v100, + })) + require.NoError(t, ds.Addresses().Add(cldfdatastore.AddressRef{ + ChainSelector: selector, + Address: staleProposerMCM, + Type: cldfdatastore.ContractType(mcmscontracts.ProposerManyChainMultisig), + Version: &v100, + })) + + _, err := loadDeployedAddresses(ds.Seal(), selector, "") + require.ErrorContains(t, err, "embeds program") + require.ErrorContains(t, err, string(mcmscontracts.ProposerManyChainMultisig)) + }) + + t.Run("timelock instance program mismatch", func(t *testing.T) { + t.Parallel() + + timelockProgram := solanago.MustPublicKeyFromBase58(solutils.GetProgramID(solutils.ProgTimelock)) + var timelockSeed legacysolana.PDASeed + copy(timelockSeed[:], "timelock-seed-123456789012345678") + staleTimelock := legacysolana.EncodeAddressWithSeed(mcmProgram, timelockSeed) + + ds := cldfdatastore.NewMemoryDataStore() + require.NoError(t, ds.Addresses().Add(cldfdatastore.AddressRef{ + ChainSelector: selector, + Address: timelockProgram.String(), + Type: cldfdatastore.ContractType(mcmscontracts.RBACTimelockProgram), + Version: &v100, + })) + require.NoError(t, ds.Addresses().Add(cldfdatastore.AddressRef{ + ChainSelector: selector, + Address: staleTimelock, + Type: cldfdatastore.ContractType(mcmscontracts.RBACTimelock), + Version: &v100, + })) + + _, err := loadDeployedAddresses(ds.Seal(), selector, "") + require.ErrorContains(t, err, "embeds program") + require.ErrorContains(t, err, string(mcmscontracts.RBACTimelock)) + }) +} diff --git a/mcms/solana/deploy/changeset_test.go b/mcms/solana/deploy/changeset_test.go new file mode 100644 index 0000000..f43e09c --- /dev/null +++ b/mcms/solana/deploy/changeset_test.go @@ -0,0 +1,257 @@ +package soldeploy_test + +import ( + "testing" + + chainselectors "github.com/smartcontractkit/chain-selectors" + cldfsol "github.com/smartcontractkit/chainlink-deployments-framework/chain/solana" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + mcmscontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/mcms" + cldfproposalutils "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils" + cldftesthelpers "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils/testhelpers" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/environment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/test/runtime" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" + mcmssolana "github.com/smartcontractkit/mcms/sdk/solana" + mcmstypes "github.com/smartcontractkit/mcms/types" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cld-changesets/internal/semvers" + legacysolana "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana" + solutils "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana/solutils" + soltestutils "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana/testutils" + "github.com/smartcontractkit/cld-changesets/mcms/changesets/deploy" + solreaders "github.com/smartcontractkit/cld-changesets/mcms/solana/readers" + familysolana "github.com/smartcontractkit/cld-changesets/pkg/family/solana" + + _ "github.com/smartcontractkit/cld-changesets/mcms/solana/deploy" +) + +// All Solana integration tests share global mcm/timelock/ac SetProgramID state; +// they must run sequentially to avoid races. + +type solanaMCMSTimelockRefs struct { + Bypasser string + Canceller string + Proposer string + Timelock string +} + +// datastoreWithMCMSPrograms seeds the datastore with canonical MCMS program IDs. +// The test validator preloads the same programs via WithSolanaContainer; program +// deploy is skipped because artifacts lack -keypair.json files required by +// solana program deploy for fixed program IDs. +func datastoreWithMCMSPrograms(t *testing.T, selector uint64) datastore.DataStore { + t.Helper() + + v := semvers.V1_0_0 + ds := datastore.NewMemoryDataStore() + for _, entry := range []struct { + addr string + ct datastore.ContractType + }{ + {solutils.GetProgramID(solutils.ProgAccessController), datastore.ContractType(mcmscontracts.AccessControllerProgram)}, + {solutils.GetProgramID(solutils.ProgMCM), datastore.ContractType(mcmscontracts.ManyChainMultisigProgram)}, + {solutils.GetProgramID(solutils.ProgTimelock), datastore.ContractType(mcmscontracts.RBACTimelockProgram)}, + } { + require.NoError(t, ds.Addresses().Add(datastore.AddressRef{ + ChainSelector: selector, + Address: entry.addr, + Type: entry.ct, + Version: &v, + })) + } + + return ds.Seal() +} + +func newSolanaDeployRuntime(t *testing.T, selector uint64, ds datastore.DataStore) *runtime.Runtime { + t.Helper() + + programsPath, programIDs := soltestutils.LoadMCMSPrograms(t, t.TempDir()) + rt, err := runtime.New(t.Context(), runtime.WithEnvOpts( + environment.WithSolanaContainer(t, []uint64{selector}, programsPath, programIDs), + environment.WithDatastore(ds), + environment.WithLogger(logger.Test(t)), + )) + require.NoError(t, err) + + return rt +} + +func execSolanaDeployChangeset( + t *testing.T, + rt *runtime.Runtime, + selector uint64, + cfg cldfproposalutils.MCMSWithTimelockConfig, +) { + t.Helper() + + err := rt.Exec(runtime.ChangesetTask(deploy.Changeset{}, deploy.Input{ + ConfigByChain: map[uint64]cldfproposalutils.MCMSWithTimelockConfig{ + selector: cfg, + }, + })) + require.NoError(t, err) +} + +func loadSolanaMCMSTimelockRefs(t *testing.T, env cldf.Environment, selector uint64) solanaMCMSTimelockRefs { + t.Helper() + + reader := solreaders.Reader{} + + timelockRef, err := reader.GetTimelockRef(env, selector, cldf.MCMSTimelockProposalInput{}) + require.NoError(t, err) + + proposerRef, err := reader.GetMCMSRef(env, selector, cldf.MCMSTimelockProposalInput{ + TimelockAction: mcmstypes.TimelockActionSchedule, + }) + require.NoError(t, err) + + cancellerRef, err := reader.GetMCMSRef(env, selector, cldf.MCMSTimelockProposalInput{ + TimelockAction: mcmstypes.TimelockActionCancel, + }) + require.NoError(t, err) + + bypasserRef, err := reader.GetMCMSRef(env, selector, cldf.MCMSTimelockProposalInput{ + TimelockAction: mcmstypes.TimelockActionBypass, + }) + require.NoError(t, err) + + return solanaMCMSTimelockRefs{ + Bypasser: bypasserRef.Address, + Canceller: cancellerRef.Address, + Proposer: proposerRef.Address, + Timelock: timelockRef.Address, + } +} + +func assertSolanaDeployDatastoreRefs(t *testing.T, rt *runtime.Runtime, selector uint64) map[datastore.ContractType]datastore.AddressRef { + t.Helper() + + refs, err := rt.State().DataStore.Addresses().Fetch() + require.NoError(t, err) + require.Len(t, refs, 11, "expected 11 MCMS contract address refs") + + byType := make(map[datastore.ContractType]datastore.AddressRef, 11) + for _, ref := range refs { + require.Equal(t, selector, ref.ChainSelector) + require.True(t, semvers.V1_0_0.Equal(ref.Version)) + byType[ref.Type] = ref + } + + for _, ct := range []datastore.ContractType{ + datastore.ContractType(mcmscontracts.AccessControllerProgram), + datastore.ContractType(mcmscontracts.ProposerAccessControllerAccount), + datastore.ContractType(mcmscontracts.ExecutorAccessControllerAccount), + datastore.ContractType(mcmscontracts.CancellerAccessControllerAccount), + datastore.ContractType(mcmscontracts.BypasserAccessControllerAccount), + datastore.ContractType(mcmscontracts.ManyChainMultisigProgram), + datastore.ContractType(mcmscontracts.ProposerManyChainMultisig), + datastore.ContractType(mcmscontracts.CancellerManyChainMultisig), + datastore.ContractType(mcmscontracts.BypasserManyChainMultisig), + datastore.ContractType(mcmscontracts.RBACTimelockProgram), + datastore.ContractType(mcmscontracts.RBACTimelock), + } { + require.Contains(t, byType, ct) + } + + return byType +} + +func assertSolanaMCMConfig(t *testing.T, chain cldfsol.Chain, address string, want mcmstypes.Config) { + t.Helper() + + got, err := mcmssolana.NewInspector(chain.Client).GetConfig(t.Context(), address) + require.NoError(t, err) + require.ElementsMatch(t, want.Signers, got.Signers) + require.Equal(t, want.Quorum, got.Quorum) +} + +func assertSolanaTimelockRoles(t *testing.T, chain cldfsol.Chain, timelockAddr string, refs solanaMCMSTimelockRefs) { + t.Helper() + + mcmProgram, proposerSeed, err := mcmssolana.ParseContractAddress(refs.Proposer) + require.NoError(t, err) + _, cancellerSeed, err := mcmssolana.ParseContractAddress(refs.Canceller) + require.NoError(t, err) + _, bypasserSeed, err := mcmssolana.ParseContractAddress(refs.Bypasser) + require.NoError(t, err) + + proposerPDA := familysolana.GetMCMSignerPDA(mcmProgram, legacysolana.PDASeed(proposerSeed)) + cancellerPDA := familysolana.GetMCMSignerPDA(mcmProgram, legacysolana.PDASeed(cancellerSeed)) + bypasserPDA := familysolana.GetMCMSignerPDA(mcmProgram, legacysolana.PDASeed(bypasserSeed)) + deployer := chain.DeployerKey.PublicKey().String() + + inspector := mcmssolana.NewTimelockInspector(chain.Client) + + proposers, err := inspector.GetProposers(t.Context(), timelockAddr) + require.NoError(t, err) + require.Equal(t, []string{proposerPDA.String()}, proposers) + + executors, err := inspector.GetExecutors(t.Context(), timelockAddr) + require.NoError(t, err) + require.Equal(t, []string{deployer}, executors) + + cancellers, err := inspector.GetCancellers(t.Context(), timelockAddr) + require.NoError(t, err) + require.ElementsMatch(t, []string{ + cancellerPDA.String(), + proposerPDA.String(), + bypasserPDA.String(), + }, cancellers) + + bypassers, err := inspector.GetBypassers(t.Context(), timelockAddr) + require.NoError(t, err) + require.Equal(t, []string{bypasserPDA.String()}, bypassers) +} + +func assertSolanaDeployOnChain( + t *testing.T, + rt *runtime.Runtime, + selector uint64, + cfg cldfproposalutils.MCMSWithTimelockConfig, +) { + t.Helper() + + env := rt.Environment() + chain := env.BlockChains.SolanaChains()[selector] + refs := loadSolanaMCMSTimelockRefs(t, env, selector) + + assertSolanaTimelockRoles(t, chain, refs.Timelock, refs) + assertSolanaMCMConfig(t, chain, refs.Proposer, cfg.Proposer) + assertSolanaMCMConfig(t, chain, refs.Canceller, cfg.Canceller) + assertSolanaMCMConfig(t, chain, refs.Bypasser, cfg.Bypasser) + + minDelay, err := mcmssolana.NewTimelockInspector(chain.Client).GetMinDelay(t.Context(), refs.Timelock) + require.NoError(t, err) + require.Equal(t, uint64(0), minDelay) +} + +//nolint:paralleltest // global SetProgramID binding state is shared in-process +func TestDeployMCMSWithTimelock_Solana(t *testing.T) { + selector := chainselectors.TEST_22222222222222222222222222222222222222222222.Selector + cfg := cldftesthelpers.SingleGroupTimelockConfig(t) + + rt := newSolanaDeployRuntime(t, selector, datastoreWithMCMSPrograms(t, selector)) + execSolanaDeployChangeset(t, rt, selector, cfg) + + afterFirst := assertSolanaDeployDatastoreRefs(t, rt, selector) + assertSolanaDeployOnChain(t, rt, selector, cfg) + + // Pre-existing programs must retain their canonical IDs (not be re-deployed). + require.Equal(t, solutils.GetProgramID(solutils.ProgAccessController), + afterFirst[datastore.ContractType(mcmscontracts.AccessControllerProgram)].Address) + require.Equal(t, solutils.GetProgramID(solutils.ProgMCM), + afterFirst[datastore.ContractType(mcmscontracts.ManyChainMultisigProgram)].Address) + require.Equal(t, solutils.GetProgramID(solutils.ProgTimelock), + afterFirst[datastore.ContractType(mcmscontracts.RBACTimelockProgram)].Address) + + // Re-running the changeset must be idempotent: no addresses should change. + execSolanaDeployChangeset(t, rt, selector, cfg) + afterSecond := assertSolanaDeployDatastoreRefs(t, rt, selector) + for ct, ref := range afterFirst { + require.Equal(t, ref.Address, afterSecond[ct].Address, "address changed for %s on re-run", ct) + } +} diff --git a/mcms/solana/deploy/idempotency.go b/mcms/solana/deploy/idempotency.go new file mode 100644 index 0000000..c0e62fa --- /dev/null +++ b/mcms/solana/deploy/idempotency.go @@ -0,0 +1,16 @@ +package soldeploy + +import ( + "strconv" + + cldfsol "github.com/smartcontractkit/chainlink-deployments-framework/chain/solana" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" +) + +// chainIdempotencyKey scopes an operation report to a single chain. Different +// calls on the same chain are distinguished by the operation's input fields +// (program name, contract type, MCM config, etc.) which are hashed together +// with the operation definition and this key to form the final cache key. +func chainIdempotencyKey[IN, DEP any](chain cldfsol.Chain) operations.ExecuteOption[IN, DEP] { + return operations.WithIdempotencyKey[IN, DEP](strconv.FormatUint(chain.Selector, 10)) +} diff --git a/mcms/solana/deploy/operations.go b/mcms/solana/deploy/operations.go new file mode 100644 index 0000000..b672549 --- /dev/null +++ b/mcms/solana/deploy/operations.go @@ -0,0 +1,478 @@ +package soldeploy + +import ( + "context" + "crypto/rand" + "fmt" + "math/big" + "time" + + binary "github.com/gagliardetto/binary" + solanago "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/programs/system" + "github.com/gagliardetto/solana-go/rpc" + + "github.com/Masterminds/semver/v3" + accessControllerBindings "github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings/v0_1_1/access_controller" + mcmBindings "github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings/v0_1_1/mcm" + timelockBindings "github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings/v0_1_1/timelock" + solanaUtils "github.com/smartcontractkit/chainlink-ccip/chains/solana/utils/common" + cldfsol "github.com/smartcontractkit/chainlink-deployments-framework/chain/solana" + cldfdatastore "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + mcmscontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/mcms" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + mcmssolanasdk "github.com/smartcontractkit/mcms/sdk/solana" + mcmstypes "github.com/smartcontractkit/mcms/types" + + "github.com/smartcontractkit/cld-changesets/internal/semvers" + legacysolana "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana" + solutils "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana/solutils" + familysolana "github.com/smartcontractkit/cld-changesets/pkg/family/solana" +) + +const programReadinessTimeout = 30 * time.Second + +// ─── Input / output types ───────────────────────────────────────────────────── + +type deployProgramInput struct { + ProgramName string `json:"programName"` + ContractType cldf.ContractType `json:"contractType"` + Qualifier string `json:"qualifier"` + Label string `json:"label"` +} + +type initACAccountInput struct { + ProgramID solanago.PublicKey `json:"programID"` + ContractType cldf.ContractType `json:"contractType"` + Qualifier string `json:"qualifier"` + Label string `json:"label"` +} + +type mcmInstanceOutput struct { + Ref cldfdatastore.AddressRef `json:"ref"` + Seed legacysolana.PDASeed `json:"seed"` +} + +type initMCMInstanceInput struct { + McmProgram solanago.PublicKey `json:"mcmProgram"` + ContractType cldf.ContractType `json:"contractType"` + MCMConfig mcmstypes.Config `json:"mcmConfig"` + Qualifier string `json:"qualifier"` + Label string `json:"label"` +} + +type timelockInstanceOutput struct { + Ref cldfdatastore.AddressRef `json:"ref"` + Seed legacysolana.PDASeed `json:"seed"` +} + +type initTimelockInstanceInput struct { + TimelockProgram solanago.PublicKey `json:"timelockProgram"` + AccessControllerProgram solanago.PublicKey `json:"accessControllerProgram"` + ProposerAC solanago.PublicKey `json:"proposerAC"` + ExecutorAC solanago.PublicKey `json:"executorAC"` + CancellerAC solanago.PublicKey `json:"cancellerAC"` + BypasserAC solanago.PublicKey `json:"bypasserAC"` + MinDelay *big.Int `json:"minDelay"` + Qualifier string `json:"qualifier"` + Label string `json:"label"` +} + +type setupTimelockRolesInput struct { + McmProgram solanago.PublicKey `json:"mcmProgram"` + ProposerMCMSeed legacysolana.PDASeed `json:"proposerMCMSeed"` + CancellerMCMSeed legacysolana.PDASeed `json:"cancellerMCMSeed"` + BypasserMCMSeed legacysolana.PDASeed `json:"bypasserMCMSeed"` + TimelockProgram solanago.PublicKey `json:"timelockProgram"` + TimelockSeed legacysolana.PDASeed `json:"timelockSeed"` + AccessControllerProgram solanago.PublicKey `json:"accessControllerProgram"` + ProposerAC solanago.PublicKey `json:"proposerAC"` + ExecutorAC solanago.PublicKey `json:"executorAC"` + CancellerAC solanago.PublicKey `json:"cancellerAC"` + BypasserAC solanago.PublicKey `json:"bypasserAC"` +} + +// ─── Operations ─────────────────────────────────────────────────────────────── + +// opDeployProgram deploys a Solana program binary and returns its address ref. +var opDeployProgram = operations.NewOperation( + "sol-deploy-program", + semver.MustParse("1.0.0"), + "Deploy a Solana program binary and return its program ID as an address ref", + func(b operations.Bundle, chain cldfsol.Chain, in deployProgramInput) (cldfdatastore.AddressRef, error) { + size := solutils.GetProgramBufferBytes(in.ProgramName) + + programIDStr, err := chain.DeployProgram(b.Logger, cldfsol.ProgramInfo{ + Name: in.ProgramName, + Bytes: size, + }, false, true) + if err != nil { + return cldfdatastore.AddressRef{}, fmt.Errorf("deploy program %q: %w", in.ProgramName, err) + } + + programID, err := solanago.PublicKeyFromBase58(programIDStr) + if err != nil { + return cldfdatastore.AddressRef{}, fmt.Errorf("parse program ID %q: %w", programIDStr, err) + } + + if err = waitForProgramReady(b.GetContext(), chain.Client, programID); err != nil { + return cldfdatastore.AddressRef{}, fmt.Errorf("program %q not ready: %w", in.ProgramName, err) + } + + return addressRef(chain.Selector, in.ContractType, programIDStr, in.Qualifier, in.Label), nil + }, +) + +// opInitAccessControllerAccount creates and initializes one access controller account. +var opInitAccessControllerAccount = operations.NewOperation( + "sol-init-ac-account", + semver.MustParse("1.0.0"), + "Create and initialize a Solana access controller account", + func(b operations.Bundle, chain cldfsol.Chain, in initACAccountInput) (cldfdatastore.AddressRef, error) { + accessControllerBindings.SetProgramID(in.ProgramID) + + rentExemption, err := chain.Client.GetMinimumBalanceForRentExemption( + b.GetContext(), accessControllerAccountSize, rpc.CommitmentConfirmed, + ) + if err != nil { + return cldfdatastore.AddressRef{}, fmt.Errorf("get rent exemption: %w", err) + } + + account, err := solanago.NewRandomPrivateKey() + if err != nil { + return cldfdatastore.AddressRef{}, fmt.Errorf("generate keypair: %w", err) + } + + instructions, err := buildAccessControllerInitInstructions( + in.ProgramID, + chain.DeployerKey.PublicKey(), + account, + rentExemption, + ) + if err != nil { + return cldfdatastore.AddressRef{}, err + } + + if err = chain.Confirm(instructions, solanaUtils.AddSigners(account)); err != nil { + return cldfdatastore.AddressRef{}, fmt.Errorf("confirm access controller init: %w", err) + } + + return addressRef(chain.Selector, in.ContractType, account.PublicKey().String(), in.Qualifier, in.Label), nil + }, +) + +// opInitMCMInstance initializes one MCM instance on an already-deployed MCM program, +// sets its signer config, and returns the encoded address ref and seed. +var opInitMCMInstance = operations.NewOperation( + "sol-init-mcm-instance", + semver.MustParse("1.0.0"), + "Initialize a Solana MCM instance, set its config, and return the encoded address ref", + func(b operations.Bundle, chain cldfsol.Chain, in initMCMInstanceInput) (mcmInstanceOutput, error) { + mcmBindings.SetProgramID(in.McmProgram) + + seed, err := randomSeed() + if err != nil { + return mcmInstanceOutput{}, fmt.Errorf("generate seed: %w", err) + } + + var programData struct { + DataType uint32 + Address solanago.PublicKey + } + + data, err := chain.Client.GetAccountInfoWithOpts(b.GetContext(), in.McmProgram, &rpc.GetAccountInfoOpts{Commitment: rpc.CommitmentConfirmed}) + if err != nil { + return mcmInstanceOutput{}, fmt.Errorf("get mcm program account info: %w", err) + } + if err = binary.UnmarshalBorsh(&programData, data.Bytes()); err != nil { + return mcmInstanceOutput{}, fmt.Errorf("unmarshal mcm program data: %w", err) + } + + initIx, err := mcmBindings.NewInitializeInstruction( + chain.Selector, + seed, + familysolana.GetMCMConfigPDA(in.McmProgram, seed), + chain.DeployerKey.PublicKey(), + solanago.SystemProgramID, + in.McmProgram, + programData.Address, + familysolana.GetMCMRootMetadataPDA(in.McmProgram, seed), + familysolana.GetMCMExpiringRootAndOpCountPDA(in.McmProgram, seed), + ).ValidateAndBuild() + if err != nil { + return mcmInstanceOutput{}, fmt.Errorf("build mcm Initialize: %w", err) + } + if err = chain.Confirm([]solanago.Instruction{initIx}); err != nil { + return mcmInstanceOutput{}, fmt.Errorf("confirm mcm Initialize: %w", err) + } + + encodedAddr := legacysolana.EncodeAddressWithSeed(in.McmProgram, seed) + + configurer := mcmssolanasdk.NewConfigurer(chain.Client, *chain.DeployerKey, mcmstypes.ChainSelector(chain.Selector)) + if _, err = configurer.SetConfig(b.GetContext(), encodedAddr, &in.MCMConfig, false); err != nil { + return mcmInstanceOutput{}, fmt.Errorf("set config: %w", err) + } + + return mcmInstanceOutput{ + Ref: addressRef(chain.Selector, in.ContractType, encodedAddr, in.Qualifier, in.Label), + Seed: seed, + }, nil + }, +) + +// opInitTimelockInstance initializes one timelock instance and returns its encoded +// address ref and seed. +var opInitTimelockInstance = operations.NewOperation( + "sol-init-timelock-instance", + semver.MustParse("1.0.0"), + "Initialize a Solana timelock instance and return the encoded address ref", + func(b operations.Bundle, chain cldfsol.Chain, in initTimelockInstanceInput) (timelockInstanceOutput, error) { + timelockBindings.SetProgramID(in.TimelockProgram) + + seed, err := randomSeed() + if err != nil { + return timelockInstanceOutput{}, fmt.Errorf("generate seed: %w", err) + } + + var programData struct { + DataType uint32 + Address solanago.PublicKey + } + + data, err := chain.Client.GetAccountInfoWithOpts(b.GetContext(), in.TimelockProgram, &rpc.GetAccountInfoOpts{Commitment: rpc.CommitmentConfirmed}) + if err != nil { + return timelockInstanceOutput{}, fmt.Errorf("get timelock program account info: %w", err) + } + if err = binary.UnmarshalBorsh(&programData, data.Bytes()); err != nil { + return timelockInstanceOutput{}, fmt.Errorf("unmarshal timelock program data: %w", err) + } + + minDelay, err := timelockMinDelayUint64(in.MinDelay) + if err != nil { + return timelockInstanceOutput{}, err + } + + initIx, err := timelockBindings.NewInitializeInstruction( + seed, + minDelay, + familysolana.GetTimelockConfigPDA(in.TimelockProgram, seed), + chain.DeployerKey.PublicKey(), + solanago.SystemProgramID, + in.TimelockProgram, + programData.Address, + in.AccessControllerProgram, + in.ProposerAC, + in.ExecutorAC, + in.CancellerAC, + in.BypasserAC, + ).ValidateAndBuild() + if err != nil { + return timelockInstanceOutput{}, fmt.Errorf("build timelock Initialize: %w", err) + } + if err = chain.Confirm([]solanago.Instruction{initIx}); err != nil { + return timelockInstanceOutput{}, fmt.Errorf("confirm timelock Initialize: %w", err) + } + + encodedAddr := legacysolana.EncodeAddressWithSeed(in.TimelockProgram, seed) + + return timelockInstanceOutput{ + Ref: addressRef(chain.Selector, mcmscontracts.RBACTimelock, encodedAddr, in.Qualifier, in.Label), + Seed: seed, + }, nil + }, +) + +// opSetupTimelockRoles grants proposer/executor/canceller/bypasser roles on the timelock. +var opSetupTimelockRoles = operations.NewOperation( + "sol-setup-timelock-roles", + semver.MustParse("1.0.0"), + "Grant MCMS signer PDAs their roles on the Solana timelock", + func(b operations.Bundle, chain cldfsol.Chain, in setupTimelockRolesInput) (struct{}, error) { + for _, g := range timelockRoleGrants(in, chain.DeployerKey.PublicKey()) { + ix, err := buildTimelockBatchAddAccessInstruction(in, g, chain.DeployerKey.PublicKey()) + if err != nil { + return struct{}{}, fmt.Errorf("build BatchAddAccess for role %v: %w", g.role, err) + } + if err = chain.Confirm([]solanago.Instruction{ix}); err != nil { + return struct{}{}, fmt.Errorf("confirm BatchAddAccess for role %v: %w", g.role, err) + } + } + + return struct{}{}, nil + }, +) + +type timelockRoleGrant struct { + role timelockBindings.Role + accounts []solanago.PublicKey + acAccount solanago.PublicKey +} + +func timelockRoleGrants(in setupTimelockRolesInput, deployer solanago.PublicKey) []timelockRoleGrant { + proposerPDA := familysolana.GetMCMSignerPDA(in.McmProgram, in.ProposerMCMSeed) + cancellerPDA := familysolana.GetMCMSignerPDA(in.McmProgram, in.CancellerMCMSeed) + bypasserPDA := familysolana.GetMCMSignerPDA(in.McmProgram, in.BypasserMCMSeed) + + return []timelockRoleGrant{ + {timelockBindings.Proposer_Role, []solanago.PublicKey{proposerPDA}, in.ProposerAC}, + {timelockBindings.Executor_Role, []solanago.PublicKey{deployer}, in.ExecutorAC}, + {timelockBindings.Canceller_Role, []solanago.PublicKey{cancellerPDA, proposerPDA, bypasserPDA}, in.CancellerAC}, + {timelockBindings.Bypasser_Role, []solanago.PublicKey{bypasserPDA}, in.BypasserAC}, + } +} + +func buildTimelockBatchAddAccessInstruction( + in setupTimelockRolesInput, + g timelockRoleGrant, + admin solanago.PublicKey, +) (solanago.Instruction, error) { + timelockBindings.SetProgramID(in.TimelockProgram) + + timelockConfigPDA := familysolana.GetTimelockConfigPDA(in.TimelockProgram, in.TimelockSeed) + + ib := timelockBindings.NewBatchAddAccessInstruction( + [32]byte(in.TimelockSeed), + g.role, + timelockConfigPDA, + in.AccessControllerProgram, + g.acAccount, + admin, + ) + for _, acc := range g.accounts { + ib.Append(solanago.Meta(acc)) + } + + return ib.ValidateAndBuild() +} + +func buildAccessControllerInitInstructions( + programID solanago.PublicKey, + payer solanago.PublicKey, + account solanago.PrivateKey, + rentExemption uint64, +) ([]solanago.Instruction, error) { + accessControllerBindings.SetProgramID(programID) + + createIx, err := system.NewCreateAccountInstruction( + rentExemption, accessControllerAccountSize, + programID, payer, account.PublicKey(), + ).ValidateAndBuild() + if err != nil { + return nil, fmt.Errorf("build CreateAccount: %w", err) + } + + initIx, err := accessControllerBindings.NewInitializeInstruction( + account.PublicKey(), payer, + ).ValidateAndBuild() + if err != nil { + return nil, fmt.Errorf("build Initialize: %w", err) + } + + return []solanago.Instruction{createIx, initIx}, nil +} + +// ─── Helpers shared across operations ──────────────────────────────────────── + +// timelockMinDelayUint64 converts a config min delay to uint64 for on-chain init. +func timelockMinDelayUint64(minDelay *big.Int) (uint64, error) { + if minDelay == nil { + return 0, nil + } + if minDelay.Sign() < 0 { + return 0, fmt.Errorf("timelock min delay must be non-negative, got %s", minDelay) + } + if minDelay.BitLen() > 64 { + return 0, fmt.Errorf("timelock min delay overflows uint64, got %s", minDelay) + } + + return minDelay.Uint64(), nil +} + +// accessControllerAccountSize is the on-chain byte size for an AccessController account. +// discriminator(8) + owner(32) + proposed_owner(32) + access_list(64 entries × 32 + length(8)) +const accessControllerAccountSize = uint64(8 + 32 + 32 + ((32 * 64) + 8)) + +func randomSeed() (legacysolana.PDASeed, error) { + const alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + + var seed legacysolana.PDASeed + for i := range seed { + n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet)))) + if err != nil { + return legacysolana.PDASeed{}, fmt.Errorf("random byte: %w", err) + } + seed[i] = alphabet[n.Int64()] + } + + return seed, nil +} + +func semverPtr(v semver.Version) *semver.Version { return &v } + +func addressRef(chainSelector uint64, contractType cldf.ContractType, address, qualifier, label string) cldfdatastore.AddressRef { + ref := cldfdatastore.AddressRef{ + Address: address, + ChainSelector: chainSelector, + Type: cldfdatastore.ContractType(contractType), + Version: semverPtr(semvers.V1_0_0), + Qualifier: qualifier, + } + if label != "" { + ref.Labels = cldfdatastore.NewLabelSet(label) + } + + return ref +} + +type getAccountInfoFunc func(ctx context.Context, programID solanago.PublicKey) (*rpc.GetAccountInfoResult, error) + +// waitForProgramReady polls until the program account is executable or the +// timeout is reached. This is required because Solana validators can process +// the program deploy transaction before the program account becomes queryable +// by downstream RPC calls, causing init instructions to fail. +func waitForProgramReady(ctx context.Context, client *rpc.Client, programID solanago.PublicKey) error { + return waitForProgramReadyWith( + ctx, + programID, + client.GetAccountInfo, + 500*time.Millisecond, + programReadinessTimeout, + ) +} + +func waitForProgramReadyWith( + ctx context.Context, + programID solanago.PublicKey, + getAccountInfo getAccountInfoFunc, + pollInterval time.Duration, + timeout time.Duration, +) error { + deadline := time.Now().Add(timeout) + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + + var lastErr error + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + if time.Now().After(deadline) { + if lastErr != nil { + return fmt.Errorf("timed out waiting for program %s to be executable: %w", programID, lastErr) + } + + return fmt.Errorf("timed out waiting for program %s to be executable", programID) + } + resp, err := getAccountInfo(ctx, programID) + if err != nil { + lastErr = err + continue + } + if resp != nil && resp.Value != nil && resp.Value.Executable { + return nil + } + } + } +} diff --git a/mcms/solana/deploy/operations_test.go b/mcms/solana/deploy/operations_test.go new file mode 100644 index 0000000..1784bcb --- /dev/null +++ b/mcms/solana/deploy/operations_test.go @@ -0,0 +1,275 @@ +package soldeploy + +import ( + "context" + "errors" + "fmt" + "math/big" + "testing" + "time" + + solanago "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/programs/system" + "github.com/gagliardetto/solana-go/rpc" + chainselectors "github.com/smartcontractkit/chain-selectors" + timelockBindings "github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings/v0_1_1/timelock" + cldfdatastore "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + mcmscontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/mcms" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/cld-changesets/internal/semvers" + legacysolana "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana" + solutils "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana/solutils" + familysolana "github.com/smartcontractkit/cld-changesets/pkg/family/solana" +) + +func testPubkey(n byte) solanago.PublicKey { + var b [32]byte + b[31] = n + + return solanago.PublicKeyFromBytes(b[:]) +} + +func testSeed(label string) legacysolana.PDASeed { + var seed legacysolana.PDASeed + copy(seed[:], label) + + return seed +} + +func TestTimelockMinDelayUint64(t *testing.T) { + t.Parallel() + + t.Run("nil is zero", func(t *testing.T) { + t.Parallel() + got, err := timelockMinDelayUint64(nil) + require.NoError(t, err) + require.Equal(t, uint64(0), got) + }) + + t.Run("valid delay", func(t *testing.T) { + t.Parallel() + got, err := timelockMinDelayUint64(big.NewInt(42)) + require.NoError(t, err) + require.Equal(t, uint64(42), got) + }) + + t.Run("negative rejected", func(t *testing.T) { + t.Parallel() + _, err := timelockMinDelayUint64(big.NewInt(-1)) + require.Error(t, err) + }) + + t.Run("overflow rejected", func(t *testing.T) { + t.Parallel() + _, err := timelockMinDelayUint64(new(big.Int).Lsh(big.NewInt(1), 65)) + require.Error(t, err) + }) +} + +func TestAddressRef(t *testing.T) { + t.Parallel() + + selector := chainselectors.TEST_22222222222222222222222222222222222222222222.Selector + ref := addressRef(selector, mcmscontracts.RBACTimelock, "addr", "prod", "label") + + require.Equal(t, "addr", ref.Address) + require.Equal(t, selector, ref.ChainSelector) + require.Equal(t, cldfdatastore.ContractType(mcmscontracts.RBACTimelock), ref.Type) + require.True(t, semvers.V1_0_0.Equal(ref.Version)) + require.Equal(t, "prod", ref.Qualifier) + require.Equal(t, []string{"label"}, ref.Labels.List()) +} + +func TestTimelockRoleGrants(t *testing.T) { + t.Parallel() + + mcmProgram := solanago.MustPublicKeyFromBase58(solutils.GetProgramID(solutils.ProgMCM)) + timelockProgram := solanago.MustPublicKeyFromBase58(solutils.GetProgramID(solutils.ProgTimelock)) + acProgram := solanago.MustPublicKeyFromBase58(solutils.GetProgramID(solutils.ProgAccessController)) + deployer := solanago.MustPublicKeyFromBase58("11111111111111111111111111111112") + + proposerSeed := testSeed("proposer-seed-123456789012345678") + cancellerSeed := testSeed("canceller-seed-12345678901234567") + bypasserSeed := testSeed("bypasser-seed-123456789012345678") + + proposerAC := testPubkey(1) + executorAC := testPubkey(2) + cancellerAC := testPubkey(3) + bypasserAC := testPubkey(4) + + in := setupTimelockRolesInput{ + McmProgram: mcmProgram, + ProposerMCMSeed: proposerSeed, + CancellerMCMSeed: cancellerSeed, + BypasserMCMSeed: bypasserSeed, + TimelockProgram: timelockProgram, + TimelockSeed: proposerSeed, + AccessControllerProgram: acProgram, + ProposerAC: proposerAC, + ExecutorAC: executorAC, + CancellerAC: cancellerAC, + BypasserAC: bypasserAC, + } + + grants := timelockRoleGrants(in, deployer) + require.Len(t, grants, 4) + + proposerPDA := familysolana.GetMCMSignerPDA(mcmProgram, proposerSeed) + cancellerPDA := familysolana.GetMCMSignerPDA(mcmProgram, cancellerSeed) + bypasserPDA := familysolana.GetMCMSignerPDA(mcmProgram, bypasserSeed) + + require.Equal(t, timelockBindings.Proposer_Role, grants[0].role) + require.Equal(t, []solanago.PublicKey{proposerPDA}, grants[0].accounts) + require.Equal(t, proposerAC, grants[0].acAccount) + + require.Equal(t, timelockBindings.Executor_Role, grants[1].role) + require.Equal(t, []solanago.PublicKey{deployer}, grants[1].accounts) + require.Equal(t, executorAC, grants[1].acAccount) + + require.Equal(t, timelockBindings.Canceller_Role, grants[2].role) + require.Equal(t, []solanago.PublicKey{cancellerPDA, proposerPDA, bypasserPDA}, grants[2].accounts) + require.Equal(t, cancellerAC, grants[2].acAccount) + + require.Equal(t, timelockBindings.Bypasser_Role, grants[3].role) + require.Equal(t, []solanago.PublicKey{bypasserPDA}, grants[3].accounts) + require.Equal(t, bypasserAC, grants[3].acAccount) +} + +//nolint:paralleltest // timelockBindings.SetProgramID is global in-process +func TestBuildTimelockBatchAddAccessInstruction(t *testing.T) { + mcmProgram := solanago.MustPublicKeyFromBase58(solutils.GetProgramID(solutils.ProgMCM)) + timelockProgram := solanago.MustPublicKeyFromBase58(solutils.GetProgramID(solutils.ProgTimelock)) + acProgram := solanago.MustPublicKeyFromBase58(solutils.GetProgramID(solutils.ProgAccessController)) + admin := solanago.MustPublicKeyFromBase58("11111111111111111111111111111112") + proposerAC := testPubkey(1) + + timelockSeed := testSeed("timelock-seed-123456789012345678") + proposerSeed := testSeed("proposer-seed-123456789012345678") + + in := setupTimelockRolesInput{ + McmProgram: mcmProgram, + ProposerMCMSeed: proposerSeed, + TimelockProgram: timelockProgram, + TimelockSeed: timelockSeed, + AccessControllerProgram: acProgram, + ProposerAC: proposerAC, + } + + grant := timelockRoleGrants(in, admin)[0] + ix, err := buildTimelockBatchAddAccessInstruction(in, grant, admin) + require.NoError(t, err) + require.Equal(t, timelockProgram, ix.ProgramID()) + + accounts := ix.Accounts() + require.GreaterOrEqual(t, len(accounts), 5) + require.Equal(t, familysolana.GetTimelockConfigPDA(timelockProgram, timelockSeed), accounts[0].PublicKey) + require.Equal(t, acProgram, accounts[1].PublicKey) + require.Equal(t, proposerAC, accounts[2].PublicKey) + require.Equal(t, admin, accounts[3].PublicKey) + require.Equal(t, familysolana.GetMCMSignerPDA(mcmProgram, proposerSeed), accounts[4].PublicKey) +} + +//nolint:paralleltest // accessControllerBindings.SetProgramID is global in-process +func TestBuildAccessControllerInitInstructions(t *testing.T) { + programID := solanago.MustPublicKeyFromBase58(solutils.GetProgramID(solutils.ProgAccessController)) + payer := solanago.MustPublicKeyFromBase58("11111111111111111111111111111112") + account, err := solanago.NewRandomPrivateKey() + require.NoError(t, err) + + instructions, err := buildAccessControllerInitInstructions(programID, payer, account, 1_000_000) + require.NoError(t, err) + require.Len(t, instructions, 2) + + createIx := instructions[0] + require.Equal(t, system.ProgramID, createIx.ProgramID()) + require.Equal(t, payer, createIx.Accounts()[0].PublicKey) + require.Equal(t, account.PublicKey(), createIx.Accounts()[1].PublicKey) + require.True(t, createIx.Accounts()[1].IsSigner) + + initIx := instructions[1] + require.Equal(t, programID, initIx.ProgramID()) + require.Equal(t, account.PublicKey(), initIx.Accounts()[0].PublicKey) + require.Equal(t, payer, initIx.Accounts()[1].PublicKey) + require.True(t, initIx.Accounts()[1].IsSigner) +} + +func TestWaitForProgramReadyWith(t *testing.T) { + t.Parallel() + + programID := testPubkey(9) + const ( + testPollInterval = 10 * time.Millisecond + testTimeout = 50 * time.Millisecond + ) + + t.Run("success when executable", func(t *testing.T) { + t.Parallel() + + err := waitForProgramReadyWith(t.Context(), programID, func(context.Context, solanago.PublicKey) (*rpc.GetAccountInfoResult, error) { + return &rpc.GetAccountInfoResult{Value: &rpc.Account{Executable: true}}, nil + }, testPollInterval, testTimeout) + require.NoError(t, err) + }) + + t.Run("success after transient RPC errors", func(t *testing.T) { + t.Parallel() + + calls := 0 + err := waitForProgramReadyWith(t.Context(), programID, func(context.Context, solanago.PublicKey) (*rpc.GetAccountInfoResult, error) { + calls++ + if calls < 3 { + return nil, errors.New("rpc unavailable") + } + + return &rpc.GetAccountInfoResult{Value: &rpc.Account{Executable: true}}, nil + }, testPollInterval, 200*time.Millisecond) + require.NoError(t, err) + require.GreaterOrEqual(t, calls, 3) + }) + + t.Run("timeout wraps last RPC error", func(t *testing.T) { + t.Parallel() + + rpcErr := errors.New("rpc unavailable") + err := waitForProgramReadyWith(t.Context(), programID, func(context.Context, solanago.PublicKey) (*rpc.GetAccountInfoResult, error) { + return nil, rpcErr + }, testPollInterval, testTimeout) + require.Error(t, err) + require.ErrorContains(t, err, "timed out waiting for program") + require.ErrorIs(t, err, rpcErr) + }) + + t.Run("timeout when account never becomes executable", func(t *testing.T) { + t.Parallel() + + err := waitForProgramReadyWith(t.Context(), programID, func(context.Context, solanago.PublicKey) (*rpc.GetAccountInfoResult, error) { + return &rpc.GetAccountInfoResult{Value: &rpc.Account{Executable: false}}, nil + }, testPollInterval, testTimeout) + require.Error(t, err) + require.EqualError(t, err, fmt.Sprintf("timed out waiting for program %s to be executable", programID)) + }) + + t.Run("respects context cancellation", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(t.Context()) + t.Cleanup(cancel) + + errCh := make(chan error, 1) + go func() { + errCh <- waitForProgramReadyWith(ctx, programID, func(context.Context, solanago.PublicKey) (*rpc.GetAccountInfoResult, error) { + return &rpc.GetAccountInfoResult{Value: &rpc.Account{Executable: false}}, nil + }, testPollInterval, time.Second) + }() + + cancel() + + select { + case err := <-errCh: + require.ErrorIs(t, err, context.Canceled) + case <-time.After(time.Second): + t.Fatal("timed out waiting for waitForProgramReadyWith to return") + } + }) +} diff --git a/mcms/solana/deploy/register.go b/mcms/solana/deploy/register.go new file mode 100644 index 0000000..d1c91e1 --- /dev/null +++ b/mcms/solana/deploy/register.go @@ -0,0 +1,38 @@ +// Package soldeploy provides the Solana chain-family implementation for the +// MCMS deploy changeset (mcms/changesets/deploy). +package soldeploy + +import ( + "fmt" + + chainselectors "github.com/smartcontractkit/chain-selectors" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + + "github.com/smartcontractkit/cld-changesets/mcms/changesets/deploy" +) + +// init auto-registers the Solana family when this package is imported. +func init() { + deploy.Registry.Register(Registration()) +} + +// Registration returns the Solana chain-family deploy registration for MCMS with +// timelock. Importing this package registers Solana automatically via init; use +// Registration() only in tests that call [deploy.Registry.Register] manually. +func Registration() deploy.Registration { + return deploy.Registration{ + Family: chainselectors.FamilySolana, + Sequence: seqDeployMCMSWithTimelock, + Verify: verifySolanaChains, + } +} + +func verifySolanaChains(env cldf.Environment, chains []deploy.ChainInput) error { + for _, c := range chains { + if _, ok := env.BlockChains.SolanaChains()[c.ChainSelector]; !ok { + return fmt.Errorf("solana chain %d not found in environment", c.ChainSelector) + } + } + + return nil +} diff --git a/mcms/solana/deploy/sequence.go b/mcms/solana/deploy/sequence.go new file mode 100644 index 0000000..d95bb53 --- /dev/null +++ b/mcms/solana/deploy/sequence.go @@ -0,0 +1,371 @@ +package soldeploy + +import ( + "fmt" + "math/big" + + "github.com/Masterminds/semver/v3" + solanago "github.com/gagliardetto/solana-go" + cldfsol "github.com/smartcontractkit/chainlink-deployments-framework/chain/solana" + "github.com/smartcontractkit/chainlink-deployments-framework/changeset/sequenceutils" + cldfdatastore "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + mcmscontracts "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/contracts/mcms" + cldfproposalutils "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils" + "github.com/smartcontractkit/chainlink-deployments-framework/operations" + mcmstypes "github.com/smartcontractkit/mcms/types" + + legacysolana "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana" + solutils "github.com/smartcontractkit/cld-changesets/legacy/pkg/family/solana/solutils" + "github.com/smartcontractkit/cld-changesets/mcms/changesets/deploy" +) + +var seqDeployMCMSWithTimelock = operations.NewSequence( + "seq-solana-mcms-deploy-with-timelock", + semver.MustParse("1.0.0"), + "Deploy MCMS and timelock programs on a Solana chain", + deployMCMSWithTimelock, +) + +// deployer accumulates per-chain deployment state within a single sequence run. +type deployer struct { + b operations.Bundle + chain cldfsol.Chain + config cldfproposalutils.MCMSWithTimelockConfig + qualifier string + label string + existing deployedAddresses + out sequenceutils.OnChainOutput +} + +func deployMCMSWithTimelock( + b operations.Bundle, + deps deploy.Deps, + in deploy.ChainInput, +) (sequenceutils.OnChainOutput, error) { + chain, ok := deps.BlockChains.SolanaChains()[in.ChainSelector] + if !ok { + return sequenceutils.OnChainOutput{}, fmt.Errorf("solana chain %d not found in environment", in.ChainSelector) + } + + d := &deployer{ + b: b, + chain: chain, + config: in.Config, + qualifier: stringFromPtr(in.Config.Qualifier), + label: stringFromPtr(in.Config.Label), + } + + var err error + d.existing, err = loadDeployedAddresses(deps.DataStore, in.ChainSelector, d.qualifier) + if err != nil { + return sequenceutils.OnChainOutput{}, fmt.Errorf("load deployed addresses: %w", err) + } + + // 1. Access controller program + accounts + if err := d.deployAccessControllerProgramIfNeeded(); err != nil { + return d.out, err + } + for _, role := range []cldf.ContractType{ + mcmscontracts.ProposerAccessControllerAccount, + mcmscontracts.ExecutorAccessControllerAccount, + mcmscontracts.CancellerAccessControllerAccount, + mcmscontracts.BypasserAccessControllerAccount, + } { + if err := d.initAccessControllerAccountIfNeeded(role); err != nil { + return d.out, err + } + } + + // 2. MCM program + instances + if err := d.deployMCMProgramIfNeeded(); err != nil { + return d.out, err + } + for _, r := range []struct { + contractType cldf.ContractType + mcmConfig mcmstypes.Config + hasFn func() bool + seedDst *legacysolana.PDASeed + }{ + {mcmscontracts.BypasserManyChainMultisig, in.Config.Bypasser, d.existing.hasBypasserMCM, &d.existing.BypasserMCMSeed}, + {mcmscontracts.CancellerManyChainMultisig, in.Config.Canceller, d.existing.hasCancellerMCM, &d.existing.CancellerMCMSeed}, + {mcmscontracts.ProposerManyChainMultisig, in.Config.Proposer, d.existing.hasProposerMCM, &d.existing.ProposerMCMSeed}, + } { + if r.hasFn() { + continue + } + seed, err := d.initMCMInstance(r.contractType, r.mcmConfig) + if err != nil { + return d.out, err + } + *r.seedDst = seed + } + + // 3. Timelock program + instance + if err := d.deployTimelockProgramIfNeeded(); err != nil { + return d.out, err + } + if !d.existing.hasTimelock() { + if err := d.initTimelock(); err != nil { + return d.out, err + } + } + + // 4. Role grants + if err := d.setupTimelockRoles(); err != nil { + return d.out, err + } + + return d.out, nil +} + +// ─── Access controller ──────────────────────────────────────────────────────── + +func (d *deployer) deployAccessControllerProgramIfNeeded() error { + if !d.existing.AccessControllerProgram.IsZero() { + return nil + } + + report, err := operations.ExecuteOperation( + d.b, + opDeployProgram, + d.chain, + deployProgramInput{ + ProgramName: solutils.ProgAccessController, + ContractType: mcmscontracts.AccessControllerProgram, + Qualifier: d.qualifier, + Label: d.label, + }, + chainIdempotencyKey[deployProgramInput, cldfsol.Chain](d.chain), + ) + if err != nil { + return fmt.Errorf("deploy access controller program: %w", err) + } + + ref := report.Output + pk, err := solanaPubkeyFromRef(ref) + if err != nil { + return fmt.Errorf("parse access controller program address: %w", err) + } + d.existing.AccessControllerProgram = pk + d.out.Metadata.Addresses = append(d.out.Metadata.Addresses, ref) + + return nil +} + +func (d *deployer) initAccessControllerAccountIfNeeded(contractType cldf.ContractType) error { + dest := d.accessControllerAccountPtr(contractType) + if dest == nil || !dest.IsZero() { + return nil + } + + report, err := operations.ExecuteOperation( + d.b, + opInitAccessControllerAccount, + d.chain, + initACAccountInput{ + ProgramID: d.existing.AccessControllerProgram, + ContractType: contractType, + Qualifier: d.qualifier, + Label: d.label, + }, + chainIdempotencyKey[initACAccountInput, cldfsol.Chain](d.chain), + ) + if err != nil { + return fmt.Errorf("init %s: %w", contractType, err) + } + + ref := report.Output + pk, err := solanaPubkeyFromRef(ref) + if err != nil { + return fmt.Errorf("parse %s address: %w", contractType, err) + } + *dest = pk + d.out.Metadata.Addresses = append(d.out.Metadata.Addresses, ref) + + return nil +} + +func (d *deployer) accessControllerAccountPtr(contractType cldf.ContractType) *solanago.PublicKey { + switch contractType { + case mcmscontracts.ProposerAccessControllerAccount: + return &d.existing.ProposerAccessControllerAccount + case mcmscontracts.ExecutorAccessControllerAccount: + return &d.existing.ExecutorAccessControllerAccount + case mcmscontracts.CancellerAccessControllerAccount: + return &d.existing.CancellerAccessControllerAccount + case mcmscontracts.BypasserAccessControllerAccount: + return &d.existing.BypasserAccessControllerAccount + default: + return nil + } +} + +// ─── MCM program + instances ────────────────────────────────────────────────── + +func (d *deployer) deployMCMProgramIfNeeded() error { + if !d.existing.McmProgram.IsZero() { + return nil + } + + report, err := operations.ExecuteOperation( + d.b, + opDeployProgram, + d.chain, + deployProgramInput{ + ProgramName: solutils.ProgMCM, + ContractType: mcmscontracts.ManyChainMultisigProgram, + Qualifier: d.qualifier, + Label: d.label, + }, + chainIdempotencyKey[deployProgramInput, cldfsol.Chain](d.chain), + ) + if err != nil { + return fmt.Errorf("deploy mcm program: %w", err) + } + + ref := report.Output + pk, err := solanaPubkeyFromRef(ref) + if err != nil { + return fmt.Errorf("parse mcm program address: %w", err) + } + d.existing.McmProgram = pk + d.out.Metadata.Addresses = append(d.out.Metadata.Addresses, ref) + + return nil +} + +func (d *deployer) initMCMInstance(contractType cldf.ContractType, mcmConfig mcmstypes.Config) (legacysolana.PDASeed, error) { + report, err := operations.ExecuteOperation( + d.b, + opInitMCMInstance, + d.chain, + initMCMInstanceInput{ + McmProgram: d.existing.McmProgram, + ContractType: contractType, + MCMConfig: mcmConfig, + Qualifier: d.qualifier, + Label: d.label, + }, + chainIdempotencyKey[initMCMInstanceInput, cldfsol.Chain](d.chain), + ) + if err != nil { + return legacysolana.PDASeed{}, fmt.Errorf("initialize %s: %w", contractType, err) + } + + d.out.Metadata.Addresses = append(d.out.Metadata.Addresses, report.Output.Ref) + + return report.Output.Seed, nil +} + +// ─── Timelock program + instance ───────────────────────────────────────────── + +func (d *deployer) deployTimelockProgramIfNeeded() error { + if !d.existing.TimelockProgram.IsZero() { + return nil + } + + report, err := operations.ExecuteOperation( + d.b, + opDeployProgram, + d.chain, + deployProgramInput{ + ProgramName: solutils.ProgTimelock, + ContractType: mcmscontracts.RBACTimelockProgram, + Qualifier: d.qualifier, + Label: d.label, + }, + chainIdempotencyKey[deployProgramInput, cldfsol.Chain](d.chain), + ) + if err != nil { + return fmt.Errorf("deploy timelock program: %w", err) + } + + ref := report.Output + pk, err := solanaPubkeyFromRef(ref) + if err != nil { + return fmt.Errorf("parse timelock program address: %w", err) + } + d.existing.TimelockProgram = pk + d.out.Metadata.Addresses = append(d.out.Metadata.Addresses, ref) + + return nil +} + +func (d *deployer) initTimelock() error { + minDelay := d.config.TimelockMinDelay + if minDelay == nil { + minDelay = big.NewInt(0) + } + + report, err := operations.ExecuteOperation( + d.b, + opInitTimelockInstance, + d.chain, + initTimelockInstanceInput{ + TimelockProgram: d.existing.TimelockProgram, + AccessControllerProgram: d.existing.AccessControllerProgram, + ProposerAC: d.existing.ProposerAccessControllerAccount, + ExecutorAC: d.existing.ExecutorAccessControllerAccount, + CancellerAC: d.existing.CancellerAccessControllerAccount, + BypasserAC: d.existing.BypasserAccessControllerAccount, + MinDelay: minDelay, + Qualifier: d.qualifier, + Label: d.label, + }, + chainIdempotencyKey[initTimelockInstanceInput, cldfsol.Chain](d.chain), + ) + if err != nil { + return fmt.Errorf("initialize timelock: %w", err) + } + + out := report.Output + d.existing.TimelockSeed = out.Seed + d.out.Metadata.Addresses = append(d.out.Metadata.Addresses, out.Ref) + + return nil +} + +// ─── Role grants ────────────────────────────────────────────────────────────── + +func (d *deployer) setupTimelockRoles() error { + _, err := operations.ExecuteOperation( + d.b, + opSetupTimelockRoles, + d.chain, + setupTimelockRolesInput{ + McmProgram: d.existing.McmProgram, + ProposerMCMSeed: d.existing.ProposerMCMSeed, + CancellerMCMSeed: d.existing.CancellerMCMSeed, + BypasserMCMSeed: d.existing.BypasserMCMSeed, + TimelockProgram: d.existing.TimelockProgram, + TimelockSeed: d.existing.TimelockSeed, + AccessControllerProgram: d.existing.AccessControllerProgram, + ProposerAC: d.existing.ProposerAccessControllerAccount, + ExecutorAC: d.existing.ExecutorAccessControllerAccount, + CancellerAC: d.existing.CancellerAccessControllerAccount, + BypasserAC: d.existing.BypasserAccessControllerAccount, + }, + chainIdempotencyKey[setupTimelockRolesInput, cldfsol.Chain](d.chain), + ) + if err != nil { + return fmt.Errorf("setup timelock roles: %w", err) + } + + return nil +} + +// solanaPubkeyFromRef parses the address field of an AddressRef as a Solana +// public key. Used after ExecuteOperation returns a ref built inside an +// Operation func. +func solanaPubkeyFromRef(ref cldfdatastore.AddressRef) (solanago.PublicKey, error) { + return solanago.PublicKeyFromBase58(ref.Address) +} + +func stringFromPtr(s *string) string { + if s == nil { + return "" + } + + return *s +}