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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 214 additions & 0 deletions deployment/output_builder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
package deployment

import (
"errors"
"fmt"

"github.com/smartcontractkit/chainlink-deployments-framework/datastore"

"github.com/smartcontractkit/mcms"
mcms_types "github.com/smartcontractkit/mcms/types"

"github.com/smartcontractkit/chainlink-deployments-framework/operations"

chain_selectors "github.com/smartcontractkit/chain-selectors"
)

var (
// ErrBatchOpsWithoutMCMSInput indicates batch operations were set without MCMS proposal input.
ErrBatchOpsWithoutMCMSInput = errors.New("batch operations require MCMS timelock proposal input")
)

// OutputBuilder builds a ChangesetOutput, including an MCMS timelock proposal when batch operations are present.
type OutputBuilder struct {
registry *MCMSReaderRegistry
environment Environment
batchOps []mcms_types.BatchOperation
changesetOutput ChangesetOutput
mcmsProposalInput *MCMSTimelockProposalInput
}

// NewOutputBuilder creates a new OutputBuilder with the given environment and data store.
func NewOutputBuilder(e Environment, newDS datastore.MutableDataStore) *OutputBuilder {
return &OutputBuilder{
environment: e,
batchOps: []mcms_types.BatchOperation{},
changesetOutput: ChangesetOutput{
DataStore: newDS,
},
}
}

// WithOperationsReports sets the reports on the ChangesetOutput.
func (b *OutputBuilder) WithOperationsReports(reports []operations.Report[any, any]) *OutputBuilder {
b.changesetOutput.Reports = reports
return b
}

// WithMCMSReaderRegistry overrides the MCMS reader registry used during Build (default: GetMCMSReaderRegistry).
// In most cases, you should not need to use this method.
func (b *OutputBuilder) WithMCMSReaderRegistry(registry *MCMSReaderRegistry) *OutputBuilder {
b.registry = registry
return b
}

type batchOpsConfig struct {
mergePerChain bool
}

// BatchOpsOption configures how WithBatchOps processes batch operations.
type BatchOpsOption func(*batchOpsConfig)

// WithoutMergeBatchOpsPerChain keeps batch operations as provided, only filtering out empty operations.
func WithoutMergeBatchOpsPerChain() BatchOpsOption {
return func(c *batchOpsConfig) {
c.mergePerChain = false
}
}

// WithBatchOps sets the batch operations on the OutputBuilder.
// Empty batch operations (no transactions) are filtered out.
// By default, multiple batch operations for the same chain are merged into one, preserving transaction order.
// Use WithoutMergeBatchOpsPerChain to keep operations separate per chain.
func (b *OutputBuilder) WithBatchOps(ops []mcms_types.BatchOperation, opts ...BatchOpsOption) *OutputBuilder {
cfg := batchOpsConfig{mergePerChain: true}
for _, opt := range opts {
opt(&cfg)
}

if cfg.mergePerChain {
b.batchOps = mergeBatchOpsPerChain(ops)
} else {
b.batchOps = filterNonEmptyBatchOps(ops)
}

return b
}

// WithMCMSTimelockProposalInput sets the input required to build an MCMS timelock proposal when batch operations are present.
func (b *OutputBuilder) WithMCMSTimelockProposalInput(input MCMSTimelockProposalInput) *OutputBuilder {
b.mcmsProposalInput = &input
return b
}

// Build constructs the final ChangesetOutput, including building an MCMS proposal when batch operations are present.
func (b *OutputBuilder) Build() (ChangesetOutput, error) {
if len(b.batchOps) == 0 {
return b.changesetOutput, nil
Comment thread
graham-chainlink marked this conversation as resolved.
}

if b.mcmsProposalInput == nil {
return ChangesetOutput{}, ErrBatchOpsWithoutMCMSInput
}

mcmsInput := *b.mcmsProposalInput
if err := mcmsInput.Validate(); err != nil {
return ChangesetOutput{}, fmt.Errorf("failed to validate MCMS timelock proposal input: %w", err)
}

timelockAddresses, chainMetadata, err := b.resolveMCMSPerChain(mcmsInput, b.batchOps)
if err != nil {
return ChangesetOutput{}, err
}

proposal, err := mcms.NewTimelockProposalBuilder().
SetVersion("v1").
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

just courious, why is this version hardcoded?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Because i dont think there is ever a v2. From the code

Version              string                                      `json:"version" validate:"required,oneof=v1"`

SetDescription(mcmsInput.Description).
SetOverridePreviousRoot(mcmsInput.OverridePreviousRoot).
SetValidUntil(mcmsInput.ValidUntil).
SetDelay(mcmsInput.TimelockDelay).
SetAction(mcmsInput.TimelockAction).
SetOperations(b.batchOps).
SetTimelockAddresses(timelockAddresses).
SetChainMetadata(chainMetadata).
Build()
if err != nil {
return ChangesetOutput{}, fmt.Errorf("failed to build MCMS proposal: %w", err)
}
b.changesetOutput.MCMSTimelockProposals = []mcms.TimelockProposal{*proposal}

return b.changesetOutput, nil
}

func (b *OutputBuilder) mcmsRegistry() *MCMSReaderRegistry {
if b.registry != nil {
return b.registry
}

return GetMCMSReaderRegistry()
}

func filterNonEmptyBatchOps(ops []mcms_types.BatchOperation) []mcms_types.BatchOperation {
filtered := make([]mcms_types.BatchOperation, 0, len(ops))
for _, op := range ops {
if len(op.Transactions) > 0 {
filtered = append(filtered, op)
}
}

return filtered
}

func mergeBatchOpsPerChain(ops []mcms_types.BatchOperation) []mcms_types.BatchOperation {
txPerChain := make(map[mcms_types.ChainSelector][]mcms_types.Transaction)
chainOrder := make([]mcms_types.ChainSelector, 0)
for _, op := range ops {
if len(op.Transactions) == 0 {
continue
}
if _, seen := txPerChain[op.ChainSelector]; !seen {
chainOrder = append(chainOrder, op.ChainSelector)
}
txPerChain[op.ChainSelector] = append(txPerChain[op.ChainSelector], op.Transactions...)
}

merged := make([]mcms_types.BatchOperation, 0, len(chainOrder))
for _, chainSelector := range chainOrder {
merged = append(merged, mcms_types.BatchOperation{
ChainSelector: chainSelector,
Transactions: txPerChain[chainSelector],
})
}

return merged
}

func (b *OutputBuilder) resolveMCMSPerChain(
input MCMSTimelockProposalInput,
ops []mcms_types.BatchOperation,
) (map[mcms_types.ChainSelector]string, map[mcms_types.ChainSelector]mcms_types.ChainMetadata, error) {
timelocks := make(map[mcms_types.ChainSelector]string)
metadata := make(map[mcms_types.ChainSelector]mcms_types.ChainMetadata)
seen := make(map[mcms_types.ChainSelector]struct{})

for _, op := range ops {
if _, ok := seen[op.ChainSelector]; ok {
continue
}
seen[op.ChainSelector] = struct{}{}
chainSelector := op.ChainSelector

family, err := chain_selectors.GetSelectorFamily(uint64(chainSelector))
if err != nil {
return nil, nil, fmt.Errorf("chain family for selector %d: %w", chainSelector, err)
}
reader, ok := b.mcmsRegistry().Get(family)
if !ok {
return nil, nil, fmt.Errorf("no MCMS reader registered for chain family '%s'", family)
}

timelockRef, err := reader.GetTimelockRef(b.environment, uint64(chainSelector), input)
if err != nil {
return nil, nil, fmt.Errorf("get timelock ref for chain %d: %w", chainSelector, err)
}
timelocks[chainSelector] = timelockRef.Address

chainMetadata, err := reader.GetChainMetadata(b.environment, uint64(chainSelector), input)
if err != nil {
return nil, nil, fmt.Errorf("get chain metadata for chain %d: %w", chainSelector, err)
}
metadata[chainSelector] = chainMetadata
}

return timelocks, metadata, nil
}
Loading
Loading