diff --git a/go.mod b/go.mod index 956b653..96ef8fd 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.7 require ( github.com/Masterminds/semver/v3 v3.4.0 github.com/aptos-labs/aptos-go-sdk v1.12.0 + github.com/deckarep/golang-set/v2 v2.6.0 github.com/ethereum/go-ethereum v1.17.1 github.com/gagliardetto/solana-go v1.13.0 github.com/smartcontractkit/ccip-owner-contracts v0.1.0 @@ -12,7 +13,7 @@ require ( github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260121163256-85accaf3d28d github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20250912190424-fd2e35d7deb5 github.com/smartcontractkit/chainlink-common v0.10.1-0.20260217160002-b56cb5356cc7 - github.com/smartcontractkit/chainlink-deployments-framework v0.98.0 + github.com/smartcontractkit/chainlink-deployments-framework v0.99.0 github.com/smartcontractkit/chainlink-evm v0.3.3 github.com/smartcontractkit/chainlink-evm/gethwrappers v0.0.0-20260421142741-9c7fbaf7c828 github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0 @@ -73,7 +74,6 @@ require ( github.com/creachadair/mds v0.13.4 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dchest/siphash v1.2.3 // indirect - github.com/deckarep/golang-set/v2 v2.6.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/digital-asset/dazl-client/v8 v8.9.0 // indirect github.com/distribution/reference v0.6.0 // indirect diff --git a/go.sum b/go.sum index 4cc5e17..79a869b 100644 --- a/go.sum +++ b/go.sum @@ -744,8 +744,8 @@ github.com/smartcontractkit/chainlink-common v0.10.1-0.20260217160002-b56cb5356c github.com/smartcontractkit/chainlink-common v0.10.1-0.20260217160002-b56cb5356cc7/go.mod h1:HXgSKzmZ/bhSx8nHU7hHW6dR+BHSXkdcpFv2T8qJcS8= github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.10 h1:FJAFgXS9oqASnkS03RE1HQwYQQxrO4l46O5JSzxqLgg= github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.10/go.mod h1:oiDa54M0FwxevWwyAX773lwdWvFYYlYHHQV1LQ5HpWY= -github.com/smartcontractkit/chainlink-deployments-framework v0.98.0 h1:Ov/KOEtubOHXX8oa9UtARhHmkQNCOIjWNt+Zi0AuzHM= -github.com/smartcontractkit/chainlink-deployments-framework v0.98.0/go.mod h1:24dwRW1PYolrlxSth///ddG3auGqR+50xaJiXfUHhkg= +github.com/smartcontractkit/chainlink-deployments-framework v0.99.0 h1:UmFIN63m3+qXB5sP3ZtNzoMS8iIPDxeDVzYnhFB/U2k= +github.com/smartcontractkit/chainlink-deployments-framework v0.99.0/go.mod h1:h2R69nbkSMGUSYHrf1lbrchml1CdR1jP4t9HsBb0xdY= github.com/smartcontractkit/chainlink-evm v0.3.3 h1:JqwyJEtnNEUaoQQPoOBTT4sn2lpdIZHtf0Hr0M60YDw= github.com/smartcontractkit/chainlink-evm v0.3.3/go.mod h1:q0ZBvaoisNaqC8NcMYWNPTjee88nQktDEeJMQHq3hVI= github.com/smartcontractkit/chainlink-evm/gethwrappers v0.0.0-20260421142741-9c7fbaf7c828 h1:BmsFk/TSHL6dPPR86GTqgSrUXLSINNFC6cfpFRrQX+4= diff --git a/pkg/contract/mcms/propose.go b/pkg/contract/mcms/propose.go new file mode 100644 index 0000000..bcf8074 --- /dev/null +++ b/pkg/contract/mcms/propose.go @@ -0,0 +1,227 @@ +package mcms + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + mapset "github.com/deckarep/golang-set/v2" + chain_selectors "github.com/smartcontractkit/chain-selectors" + mcmslib "github.com/smartcontractkit/mcms" + mcmschainwrappers "github.com/smartcontractkit/mcms/chainwrappers" + mcmssdk "github.com/smartcontractkit/mcms/sdk" + mcmssolanasdk "github.com/smartcontractkit/mcms/sdk/solana" + "github.com/smartcontractkit/mcms/types" + + "github.com/smartcontractkit/cld-changesets/pkg/family/solana" + + cldf_adapters "github.com/smartcontractkit/chainlink-deployments-framework/chain/mcms/adapters" + cldf "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + cldfproposalutils "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalutils" +) + +const ( + DefaultValidUntil = 72 * time.Hour +) + +type ChainMetadata map[uint64]map[string]any + +func (c *ChainMetadata) Set(chainSelector uint64, key string, value any) *ChainMetadata { + _, exists := (*c)[chainSelector] + if !exists { + (*c)[chainSelector] = make(map[string]any) + } + + (*c)[chainSelector][key] = value + + return c +} + +type BuildProposalOption func(*buildProposalOptions) + +type buildProposalOptions struct { + chainMetadata ChainMetadata +} + +func WithChainMetadata(chainMetadata ChainMetadata) BuildProposalOption { + return func(opts *buildProposalOptions) { + opts.chainMetadata = chainMetadata + } +} + +// BuildProposalFromBatchesV2 uses the new MCMS library which replaces the implementation in BuildProposalFromBatches. +func BuildProposalFromBatchesV2( + e cldf.Environment, + timelockAddressPerChain map[uint64]string, + mcmsAddressPerChain map[uint64]string, + inspectorPerChain map[uint64]mcmssdk.Inspector, // optional + batches []types.BatchOperation, + description string, + mcmsCfg cldfproposalutils.TimelockConfig, + opts ...BuildProposalOption, +) (*mcmslib.TimelockProposal, error) { + buildOptions := buildProposalOptions{} + for _, opt := range opts { + opt(&buildOptions) + } + + // default to schedule if not set, this is to be consistent with the old implementation + // and to avoid breaking changes + if mcmsCfg.MCMSAction == "" { + mcmsCfg.MCMSAction = types.TimelockActionSchedule + } + if len(batches) == 0 { + return nil, errors.New("no operations in batch") + } + + chains := mapset.NewSet[uint64]() + for _, op := range batches { + chains.Add(uint64(op.ChainSelector)) + } + tlsPerChainID := make(map[types.ChainSelector]string) + for chainID, tl := range timelockAddressPerChain { + tlsPerChainID[types.ChainSelector(chainID)] = tl + } + mcmsMd, err := buildProposalMetadataV2(e, chains.ToSlice(), inspectorPerChain, mcmsAddressPerChain, + mcmsCfg.MCMSAction, buildOptions.chainMetadata) + if err != nil { + return nil, err + } + + proposalDuration := DefaultValidUntil + if mcmsCfg.ValidDuration != nil { + proposalDuration = mcmsCfg.ValidDuration.Duration + } + validUntil := time.Now().Add(proposalDuration).Unix() + + builder := mcmslib.NewTimelockProposalBuilder() + builder. + SetVersion("v1"). + SetAction(mcmsCfg.MCMSAction). + //nolint:gosec // G115 + SetValidUntil(uint32(validUntil)). + SetDescription(description). + SetDelay(types.NewDuration(mcmsCfg.MinDelay)). + SetOverridePreviousRoot(mcmsCfg.OverrideRoot). + SetChainMetadata(mcmsMd). + SetTimelockAddresses(tlsPerChainID). + SetOperations(batches) + + build, err := builder.Build() + if err != nil { + return nil, err + } + + return build, nil +} + +func buildProposalMetadataV2( + env cldf.Environment, + chainSelectors []uint64, + inspectorPerChain map[uint64]mcmssdk.Inspector, // optional + mcmAddresses map[uint64]string, // can be proposer, canceller or bypasser + mcmsAction types.TimelockAction, + additionalChainMetadata ChainMetadata, +) (map[types.ChainSelector]types.ChainMetadata, error) { + proposalChainMetadata := make(map[types.ChainSelector]types.ChainMetadata) + + if len(additionalChainMetadata) == 0 { + additionalChainMetadata = make(ChainMetadata) + } + + for _, selector := range chainSelectors { + mcmAddress, ok := mcmAddresses[selector] + if !ok { + return nil, fmt.Errorf("missing mcm address for chain %d", selector) + } + + chainID := types.ChainSelector(selector) + family, err := chain_selectors.GetSelectorFamily(selector) + if err != nil { + return nil, fmt.Errorf("failed to get family for chain %d: %w", selector, err) + } + + switch family { + case chain_selectors.FamilySolana: + solanaState, err := solana.GetState(env, selector) + if err != nil { + return nil, err + } + + var instanceSeed mcmssolanasdk.PDASeed + switch mcmsAction { + case types.TimelockActionSchedule: + instanceSeed = mcmssolanasdk.PDASeed(solanaState.ProposerMcmSeed) + case types.TimelockActionCancel: + instanceSeed = mcmssolanasdk.PDASeed(solanaState.CancellerMcmSeed) + case types.TimelockActionBypass: + instanceSeed = mcmssolanasdk.PDASeed(solanaState.BypasserMcmSeed) + default: + return nil, fmt.Errorf("invalid MCMS action %s", mcmsAction) + } + + proposalChainMetadata[chainID], err = mcmssolanasdk.NewChainMetadata( + 0, // opCount is set later + solanaState.McmProgram, + instanceSeed, + solanaState.ProposerAccessControllerAccount, + solanaState.CancellerAccessControllerAccount, + solanaState.BypasserAccessControllerAccount) + if err != nil { + return nil, fmt.Errorf("failed to create chain metadata: %w", err) + } + + case chain_selectors.FamilyAptos: + role, err := cldfproposalutils.GetAptosRoleFromAction(mcmsAction) + if err != nil { + return nil, fmt.Errorf("failed to get role from action: %w", err) + } + additionalChainMetadata.Set(selector, "role", role) + + proposalChainMetadata[chainID] = types.ChainMetadata{MCMAddress: mcmAddress} + + default: + proposalChainMetadata[chainID] = types.ChainMetadata{MCMAddress: mcmAddress} + } + } + + if len(inspectorPerChain) == 0 { + mcmsChains := cldf_adapters.Wrap(env.BlockChains) + inspectors, err := mcmschainwrappers.BuildInspectors(&mcmsChains, proposalChainMetadata, mcmsAction) + if err != nil { + return nil, fmt.Errorf("failed to build inspectors: %w", err) + } + + inspectorPerChain = make(map[uint64]mcmssdk.Inspector) + for selector, inspector := range inspectors { + inspectorPerChain[uint64(selector)] = inspector + } + } + + for selector, metadata := range proposalChainMetadata { + inspector, ok := inspectorPerChain[uint64(selector)] + if !ok { + return nil, fmt.Errorf("failed to get inspector for chain %d", selector) + } + + opCount, err := inspector.GetOpCount(env.GetContext(), metadata.MCMAddress) + if err != nil { + return nil, fmt.Errorf("failed to get op count for chain %d: %w", selector, err) + } + metadata.StartingOpCount = opCount + + additionalMetadata, exists := additionalChainMetadata[uint64(selector)] + if exists { + marshalledAdditionalMetadata, err := json.Marshal(additionalMetadata) + if err != nil { + return nil, fmt.Errorf("failed to marshal extra chain metadata for chain %d: %w", selector, err) + } + metadata.AdditionalFields = marshalledAdditionalMetadata + } + + proposalChainMetadata[selector] = metadata + } + + return proposalChainMetadata, nil +}