Skip to content

Commit 267e083

Browse files
fix: bind changesets and proposal operations using changeset uuids (#919)
Change how we associate proposal operations and changesets. Previously we relied on the "operation id"s, which had recently been added to the proposal's operation types. However, that change had to be reverted and the latest release of mcms no longer contains the operation id attributes. As an alternative, we now reverse the association by assigning a unique (uu)id to each changeset, and then add a tag to the operations that were generated by the changeset: ```json { ... "metadata": { "changesets": [{ "id": "c00e5d67-c275-4389-aded-7d8b151cbd5b", "name": "my_changeset", "input": { "payload": { "key": "value" } } }] }, "operations": [{ "chainSelector": 12345678901234567890, "transactions": [{ "to": "0x02", "data": "", "additionalFields": { "value": 0 }, "contractType": "", "tags": [ "changeset:c00e5d67-c275-4389-aded-7d8b151cbd5b" ] }] }] } ``` This pull-request also applies a couple of change requests from CCIP after initial tests with the proposal hooks API: 1. return a failure in fork tests and the "mcms hooks" command when a hook with failure policy set as "abort" returns an error 2. expose an new interface in the ProposalHookEnv (`ForkContext`) and a corresponding implementation for EVM (`EVMForkContext`) which allows hooks to identify that they're running inside a "forked environment" and gives them the configuration so that they can directly access it (e.g., the URL of the RPC spawned by the anvil container). --- OPT-467
1 parent 20dd834 commit 267e083

17 files changed

Lines changed: 323 additions & 186 deletions

.changeset/quick-cities-hide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"chainlink-deployments-framework": patch
3+
---
4+
5+
fix: use "changeset uuids" to associate proposal operations and changesets

engine/cld/changeset/hooks.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ type ProposalHookEnv struct {
5050
Name string
5151
Logger logger.Logger
5252
BlockChains chain.BlockChains
53+
ForkContext ForkContext
54+
// TODO: read-only JD and CRE clients
5355
}
5456

5557
// PreHookParams is passed to pre-hooks.

engine/cld/changeset/mcms.go

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,32 @@ import (
77
"time"
88

99
gethcommon "github.com/ethereum/go-ethereum/common"
10+
chainsel "github.com/smartcontractkit/chain-selectors"
1011
"github.com/smartcontractkit/mcms"
1112
mcmstypes "github.com/smartcontractkit/mcms/types"
1213

1314
fdeployment "github.com/smartcontractkit/chainlink-deployments-framework/deployment"
15+
cldfenv "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/environment"
1416
)
1517

1618
// ----- mcms timelock execution report types -----
1719

20+
type MCMSReportStatus string
21+
22+
const (
23+
StatusSuccess MCMSReportStatus = "SUCCESS"
24+
StatusNoOp MCMSReportStatus = "NOOP"
25+
StatusFailed MCMSReportStatus = "FAILED"
26+
)
27+
1828
type mcmsReport[IN, OUT any] struct {
19-
ID string `json:"id"`
20-
Type string `json:"type"`
21-
Status string `json:"status,omitempty"`
22-
Error string `json:"error,omitempty"`
23-
Timestamp time.Time `json:"timestamp,omitzero"`
24-
Input IN `json:"input,omitempty"`
25-
Output OUT `json:"output,omitempty"`
29+
ID string `json:"id"`
30+
Type string `json:"type"`
31+
Status MCMSReportStatus `json:"status,omitempty"`
32+
Error string `json:"error,omitempty"`
33+
Timestamp time.Time `json:"timestamp,omitzero"`
34+
Input IN `json:"input,omitempty"`
35+
Output OUT `json:"output,omitempty"`
2636
}
2737

2838
type MCMSTimelockExecuteReportInput struct {
@@ -40,6 +50,7 @@ type MCMSTimelockExecuteReportOutput struct {
4050
}
4151

4252
type MCMSReportChangeset struct {
53+
ID string `json:"id"`
4354
Index int `json:"index"`
4455
Name string `json:"name,omitzero"`
4556
}
@@ -48,24 +59,49 @@ type MCMSTimelockExecuteReport mcmsReport[MCMSTimelockExecuteReportInput, MCMSTi
4859

4960
const MCMSTimelockExecuteReportType = "timelock-execution"
5061

62+
// ----- fork context -----
63+
64+
// ForkContext provides information about the forked state of the environment, if applicable. It is
65+
// exposed to hooks to allow them to adjust their behavior accordingly.
66+
type ForkContext interface {
67+
ChainFamily() string
68+
}
69+
70+
type EVMForkContext struct {
71+
ChainConfig cldfenv.ChainConfig
72+
Client cldfenv.ForkedOnchainClient
73+
}
74+
75+
func (*EVMForkContext) ChainFamily() string {
76+
return chainsel.FamilyEVM
77+
}
78+
5179
// RunProposalHooks executes all post-proposal hooks for the given proposal and reports. It returns
5280
// an error if any of the hooks fail.
5381
// Execution order is:
5482
// 1. Per-changeset post-proposal-hooks
5583
// 2. Global post-proposal-hooks
5684
func (r *ChangesetsRegistry) RunProposalHooks(
57-
key string, e fdeployment.Environment, proposal *mcms.TimelockProposal, input any, reports []MCMSTimelockExecuteReport,
85+
key string, e fdeployment.Environment, proposal *mcms.TimelockProposal, input any,
86+
reports []MCMSTimelockExecuteReport, forkCtx ForkContext,
5887
) error {
5988
applySnapshot, err := r.getApplySnapshot(key)
6089
if err != nil {
6190
return err
6291
}
6392

93+
blockChains := e.BlockChains
94+
if forkCtx == nil {
95+
blockChains = blockChains.ReadOnly()
96+
}
97+
6498
params := PostProposalHookParams{
6599
Env: ProposalHookEnv{
66100
Name: e.Name,
67101
Logger: e.Logger,
68-
BlockChains: e.BlockChains.ReadOnly(),
102+
BlockChains: blockChains,
103+
ForkContext: forkCtx,
104+
// TODO: JD and CRE clients
69105
},
70106
ChangesetKey: key,
71107
Proposal: proposal,

engine/cld/changeset/mcms_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ func Test_RunProposalHooks(t *testing.T) {
148148
execLogs := []string{}
149149
registry := tt.setup(&execLogs)
150150

151-
err := registry.RunProposalHooks(tt.key, hookTestEnv(t), &mcms.TimelockProposal{}, nil, nil)
151+
err := registry.RunProposalHooks(tt.key, hookTestEnv(t), &mcms.TimelockProposal{}, nil, nil, nil)
152152

153153
if tt.wantErr == "" {
154154
require.NoError(t, err)
@@ -181,7 +181,7 @@ func Test_RunProposalHooks_HookReceivesCorrectParams(t *testing.T) {
181181
}},
182182
}
183183

184-
err := r.RunProposalHooks("test-cs", hookTestEnv(t), proposal, input, reports)
184+
err := r.RunProposalHooks("test-cs", hookTestEnv(t), proposal, input, reports, nil)
185185
require.NoError(t, err)
186186

187187
expectedParams := PostProposalHookParams{

engine/cld/changeset/registry_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -919,7 +919,7 @@ func Test_FluentAPI_HooksExtractedByAdd(t *testing.T) {
919919
require.Len(t, entry.postProposalHooks, 1, "Add should extract post-proposal-hooks via hookCarrier")
920920
require.Equal(t, "proposal-hook", entry.postProposalHooks[0].Name)
921921

922-
err := r.RunProposalHooks("test-cs", hookTestEnv(t), nil, "input", nil)
922+
err := r.RunProposalHooks("test-cs", hookTestEnv(t), nil, "input", nil, nil)
923923
require.NoError(t, err)
924924
require.Equal(t, []string{"proposal"}, hookExecutions)
925925
})
@@ -940,7 +940,7 @@ func Test_FluentAPI_HooksExtractedByAdd(t *testing.T) {
940940
require.Len(t, entry.postProposalHooks, 1)
941941
require.Equal(t, "proposal-hook", entry.postProposalHooks[0].Name)
942942

943-
err := r.RunProposalHooks("test-cs", hookTestEnv(t), nil, "input", nil)
943+
err := r.RunProposalHooks("test-cs", hookTestEnv(t), nil, "input", nil, nil)
944944
require.NoError(t, err)
945945
require.Equal(t, []string{"proposal"}, hookExecutions)
946946
})

engine/cld/commands/mcms/cmd_execute_fork.go

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030

3131
"github.com/smartcontractkit/chainlink-deployments-framework/chain"
3232
cldf_evm "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm"
33+
cldfchangeset "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/changeset"
3334
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/commands/flags"
3435
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/commands/mcms/layout"
3536
"github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/commands/text"
@@ -166,28 +167,33 @@ func executeFork(
166167
}
167168
if family != chainsel.FamilyEVM {
168169
lggr.Infof("Skipping fork execution: chain selector %d is not EVM. Family is %s", cfg.chainSelector, family)
169-
170-
return nil // don't fail, just exit cleanly
170+
return nil
171171
}
172172

173-
if len(cfg.forkedEnv.ChainConfigs[cfg.chainSelector].HTTPRPCs) == 0 {
173+
chainConfig, ok := cfg.forkedEnv.ChainConfigs[cfg.chainSelector]
174+
if !ok {
175+
return fmt.Errorf("failed to get forked env's chain config for chain %d", cfg.chainSelector)
176+
}
177+
if len(chainConfig.HTTPRPCs) == 0 {
174178
return fmt.Errorf("no rpcs loaded in forked environment for chain %d (fork tests require public RPCs)", cfg.chainSelector)
175179
}
176-
177-
// get the chain URL, chain ID and MCM contract address
178-
url := cfg.forkedEnv.ChainConfigs[cfg.chainSelector].HTTPRPCs[0].External
179-
anvilClient := rpc.New(url, nil)
180-
chainID := cfg.forkedEnv.ChainConfigs[cfg.chainSelector].ChainID
180+
forkClient, ok := cfg.forkedEnv.ForkClients[cfg.chainSelector]
181+
if !ok {
182+
return fmt.Errorf("failed to get fork client for chain %d", cfg.chainSelector)
183+
}
181184

182185
// zkSync VM chains (zkSync Era, Lens, Cronos zkEVM, etc.) require anvil-zksync,
183186
// not standard Anvil. Derive this from the loaded chain which is set by the chain
184187
// provider, so it stays in sync with new zkSync chains automatically.
185188
if evmChain, ok := cfg.blockchains.EVMChains()[cfg.chainSelector]; ok && evmChain.IsZkSyncVM {
186189
lggr.Infof("Skipping fork execution: chain selector %d is zkSync VM (chain ID %s), which requires anvil-zksync instead of standard anvil",
187-
cfg.chainSelector, chainID)
190+
cfg.chainSelector, chainConfig.ChainID)
188191

189192
return nil
190193
}
194+
195+
url := chainConfig.HTTPRPCs[0].External
196+
anvilClient := rpc.New(url, nil)
191197
mcmAddress := cfg.proposal.ChainMetadata[types.ChainSelector(cfg.chainSelector)].MCMAddress
192198
timelockAddress := common.HexToAddress(cfg.timelockProposal.TimelockAddresses[types.ChainSelector(cfg.chainSelector)])
193199

@@ -203,7 +209,7 @@ func executeFork(
203209
blockchain.DefaultAnvilPublicKey,
204210
blockchain.DefaultAnvilPublicKey,
205211
url,
206-
chainID,
212+
chainConfig.ChainID,
207213
mcmAddress,
208214
); lerr != nil {
209215
return fmt.Errorf("failed to set signer: %w", lerr)
@@ -220,7 +226,7 @@ func executeFork(
220226
return fmt.Errorf("failed to overwrite proposal signature: %w", lerr)
221227
}
222228

223-
lerr = overrideForkChainDeployerKeyWithTestSigner(cfg, chainID)
229+
lerr = overrideForkChainDeployerKeyWithTestSigner(cfg, chainConfig.ChainID)
224230
if lerr != nil {
225231
return fmt.Errorf("failed to override fork deployer key to test signer: %w", lerr)
226232
}
@@ -273,10 +279,11 @@ func executeFork(
273279
return nil
274280
}
275281

282+
forkContext := &cldfchangeset.EVMForkContext{ChainConfig: chainConfig, Client: forkClient}
276283
cfg.env.Name = cfg.envStr // ensure hooks load the correct env config for the fork
277-
err = runHooksInternal(mcmsCfg, cfg.env, cfg.timelockProposal, reports)
284+
err = runHooksInternal(mcmsCfg, cfg.env, cfg.timelockProposal, reports, forkContext)
278285
if err != nil {
279-
lggr.Warnw("Failed to run post-execution hooks", "err", err)
286+
return fmt.Errorf("failed to run post-proposal hooks: %w", err)
280287
}
281288

282289
return nil

engine/cld/commands/mcms/cmd_run_hooks.go

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
"errors"
77
"fmt"
88
"os"
9-
"slices"
109

1110
"github.com/go-viper/mapstructure/v2"
1211
"github.com/samber/lo"
@@ -46,21 +45,15 @@ type runHooksFlags struct {
4645
}
4746

4847
type proposalMetadata struct {
49-
Changesets []changesetMetadata `json:"changesets" mapstructure:"changesets"`
50-
PostExecutionHooks []proposalHooksMetadata `json:"postExecutionHooks" mapstructure:"postExecutionHooks"`
48+
Changesets []changesetMetadata `json:"changesets" mapstructure:"changesets"`
5149
}
5250

53-
type proposalHooksMetadata struct {
51+
type changesetMetadata struct {
52+
ID string `json:"id" mapstructure:"id"`
5453
Name string `json:"name" mapstructure:"name"`
5554
Input any `json:"input" mapstructure:"input"`
5655
}
5756

58-
type changesetMetadata struct {
59-
Name string `json:"name" mapstructure:"name"`
60-
OperationIDs []string `json:"operationIDs" mapstructure:"operationIDs"`
61-
Input any `json:"input" mapstructure:"input"`
62-
}
63-
6457
func newRunProposalHooksCmd(cfg Config) *cobra.Command {
6558
cmd := &cobra.Command{
6659
Use: "hooks",
@@ -120,14 +113,15 @@ func runHooks(ctx context.Context, cfg Config, hFlags runHooksFlags) error {
120113
return errors.New("expected proposal to be a TimelockProposal")
121114
}
122115

123-
return runHooksInternal(cfg, proposalCfg.Env, proposalCfg.TimelockProposal, hFlags.reports)
116+
return runHooksInternal(cfg, proposalCfg.Env, proposalCfg.TimelockProposal, hFlags.reports, nil)
124117
}
125118

126119
func runHooksInternal(
127120
cfg Config,
128121
env cldf.Environment,
129122
timelockProposal *mcms.TimelockProposal,
130123
reports []cldfchangeset.MCMSTimelockExecuteReport,
124+
forkCtx cldfchangeset.ForkContext,
131125
) error {
132126
if cfg.LoadChangesets == nil {
133127
return errors.New("LoadChangesets function is required for proposal hook execution")
@@ -146,17 +140,17 @@ func runHooksInternal(
146140

147141
for _, changeset := range metadata.Changesets {
148142
changesetReports := lo.Filter(reports, func(r cldfchangeset.MCMSTimelockExecuteReport, _ int) bool {
149-
return slices.Contains(changeset.OperationIDs, r.Input.OperationID.Hex())
143+
return changeset.ID != "" && changeset.ID == r.Input.Changeset.ID
150144
})
151145

152-
herr := changesetRegistry.RunProposalHooks(changeset.Name, env, timelockProposal, changeset.Input, changesetReports)
146+
herr := changesetRegistry.RunProposalHooks(changeset.Name, env, timelockProposal,
147+
changeset.Input, changesetReports, forkCtx)
153148
if herr != nil {
154-
cfg.Logger.Errorw("proposal hook failed", "changeset", changeset.Name, "error", herr)
155-
err = errors.Join(err, fmt.Errorf("proposal hook for changeset %q failed: %w", changeset.Name, herr))
149+
return fmt.Errorf("proposal hook for changeset %q failed: %w", changeset.Name, herr)
156150
}
157151
}
158152

159-
return err
153+
return nil
160154
}
161155

162156
func loadReport(path string) ([]cldfchangeset.MCMSTimelockExecuteReport, error) {

0 commit comments

Comments
 (0)