@0xsequence/catapult 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/README.md +27 -0
  2. package/dist/lib/__tests__/network-loader.spec.js.map +1 -1
  3. package/dist/lib/core/__tests__/resolver.spec.js +22 -0
  4. package/dist/lib/core/__tests__/resolver.spec.js.map +1 -1
  5. package/dist/lib/core/__tests__/sign-actions.spec.d.ts +2 -0
  6. package/dist/lib/core/__tests__/sign-actions.spec.d.ts.map +1 -0
  7. package/dist/lib/core/__tests__/sign-actions.spec.js +128 -0
  8. package/dist/lib/core/__tests__/sign-actions.spec.js.map +1 -0
  9. package/dist/lib/core/__tests__/signer.spec.d.ts +2 -0
  10. package/dist/lib/core/__tests__/signer.spec.d.ts.map +1 -0
  11. package/dist/lib/core/__tests__/signer.spec.js +40 -0
  12. package/dist/lib/core/__tests__/signer.spec.js.map +1 -0
  13. package/dist/lib/core/context.d.ts +3 -2
  14. package/dist/lib/core/context.d.ts.map +1 -1
  15. package/dist/lib/core/context.js +3 -2
  16. package/dist/lib/core/context.js.map +1 -1
  17. package/dist/lib/core/engine.d.ts +4 -0
  18. package/dist/lib/core/engine.d.ts.map +1 -1
  19. package/dist/lib/core/engine.js +173 -0
  20. package/dist/lib/core/engine.js.map +1 -1
  21. package/dist/lib/core/signer.d.ts +7 -0
  22. package/dist/lib/core/signer.d.ts.map +1 -0
  23. package/dist/lib/core/signer.js +60 -0
  24. package/dist/lib/core/signer.js.map +1 -0
  25. package/dist/lib/parsers/__tests__/source.spec.js +37 -0
  26. package/dist/lib/parsers/__tests__/source.spec.js.map +1 -1
  27. package/dist/lib/parsers/source.js +1 -1
  28. package/dist/lib/parsers/source.js.map +1 -1
  29. package/dist/lib/provenance.js +51 -2
  30. package/dist/lib/provenance.js.map +1 -1
  31. package/dist/lib/types/actions.d.ts +26 -2
  32. package/dist/lib/types/actions.d.ts.map +1 -1
  33. package/dist/lib/types/actions.js +3 -0
  34. package/dist/lib/types/actions.js.map +1 -1
  35. package/dist/lib/types/source.d.ts +2 -0
  36. package/dist/lib/types/source.d.ts.map +1 -1
  37. package/package.json +4 -1
  38. package/.eslintrc.json +0 -29
  39. package/.github/workflows/ci.yml +0 -181
  40. package/CONCEPT.md +0 -24
  41. package/contracts/checked-call.huff +0 -65
  42. package/eslint.config.js +0 -48
  43. package/examples/jobs/guards-v1.yaml +0 -17
  44. package/examples/jobs/sequence-seq-0001-patch.yaml +0 -59
  45. package/examples/jobs/sequence-v1.yaml +0 -59
  46. package/examples/templates/sequence-factory-v1.yaml +0 -56
  47. package/jest.config.js +0 -25
  48. package/src/cli.ts +0 -18
  49. package/src/commands/common.ts +0 -61
  50. package/src/commands/dry.ts +0 -209
  51. package/src/commands/etherscan.ts +0 -360
  52. package/src/commands/index.ts +0 -6
  53. package/src/commands/list.ts +0 -262
  54. package/src/commands/provenance.ts +0 -120
  55. package/src/commands/run.ts +0 -146
  56. package/src/commands/utils.ts +0 -215
  57. package/src/index.ts +0 -67
  58. package/src/lib/__tests__/deployer-events.spec.ts +0 -338
  59. package/src/lib/__tests__/deployer.spec.ts +0 -2269
  60. package/src/lib/__tests__/network-loader.spec.ts +0 -150
  61. package/src/lib/__tests__/network-selection.spec.ts +0 -41
  62. package/src/lib/__tests__/network-utils.spec.ts +0 -230
  63. package/src/lib/__tests__/provenance.spec.ts +0 -208
  64. package/src/lib/artifacts/__tests__/fixtures/contract1.json +0 -19
  65. package/src/lib/artifacts/__tests__/fixtures/contract2.json +0 -19
  66. package/src/lib/artifacts/__tests__/fixtures/duplicate-name.json +0 -19
  67. package/src/lib/artifacts/__tests__/fixtures/nested/nested-contract.json +0 -18
  68. package/src/lib/artifacts/__tests__/fixtures/not-an-artifact.json +0 -8
  69. package/src/lib/artifacts/__tests__/fixtures/readme.txt +0 -2
  70. package/src/lib/contracts/__tests__/repository.spec.ts +0 -612
  71. package/src/lib/contracts/repository.ts +0 -411
  72. package/src/lib/core/__tests__/assert-action.spec.ts +0 -474
  73. package/src/lib/core/__tests__/context.spec.ts +0 -37
  74. package/src/lib/core/__tests__/engine.spec.ts +0 -2005
  75. package/src/lib/core/__tests__/graph.spec.ts +0 -125
  76. package/src/lib/core/__tests__/json-integration.spec.ts +0 -425
  77. package/src/lib/core/__tests__/loader.spec.ts +0 -367
  78. package/src/lib/core/__tests__/multi-platform-verification.spec.ts +0 -406
  79. package/src/lib/core/__tests__/resolver.spec.ts +0 -2496
  80. package/src/lib/core/__tests__/static-action.spec.ts +0 -172
  81. package/src/lib/core/context.ts +0 -127
  82. package/src/lib/core/engine.ts +0 -1834
  83. package/src/lib/core/graph.ts +0 -252
  84. package/src/lib/core/loader.ts +0 -253
  85. package/src/lib/core/resolver.ts +0 -873
  86. package/src/lib/deployer.ts +0 -1005
  87. package/src/lib/events/__tests__/event-system.spec.ts +0 -392
  88. package/src/lib/events/cli-adapter.ts +0 -369
  89. package/src/lib/events/emitter.ts +0 -62
  90. package/src/lib/events/index.ts +0 -3
  91. package/src/lib/events/types.ts +0 -520
  92. package/src/lib/index.ts +0 -17
  93. package/src/lib/network-loader.ts +0 -90
  94. package/src/lib/network-selection.ts +0 -73
  95. package/src/lib/network-utils.ts +0 -64
  96. package/src/lib/parsers/__tests__/buildinfo.spec.ts +0 -122
  97. package/src/lib/parsers/__tests__/fixtures/buildinfo/invalid-bytecode-buildinfo.json +0 -62
  98. package/src/lib/parsers/__tests__/fixtures/buildinfo/invalid-json.txt +0 -2
  99. package/src/lib/parsers/__tests__/fixtures/buildinfo/multi-contract-buildinfo.json +0 -89
  100. package/src/lib/parsers/__tests__/fixtures/buildinfo/no-contracts-buildinfo.json +0 -17
  101. package/src/lib/parsers/__tests__/fixtures/buildinfo/simple-buildinfo.json +0 -63
  102. package/src/lib/parsers/__tests__/fixtures/buildinfo/wrong-format.json +0 -4
  103. package/src/lib/parsers/__tests__/job.spec.ts +0 -439
  104. package/src/lib/parsers/__tests__/source.spec.ts +0 -134
  105. package/src/lib/parsers/__tests__/template.spec.ts +0 -111
  106. package/src/lib/parsers/artifact/__tests__/artifact.spec.ts +0 -117
  107. package/src/lib/parsers/artifact/__tests__/fixtures/empty-bytecode.json +0 -5
  108. package/src/lib/parsers/artifact/__tests__/fixtures/hardhat-artifact.json +0 -67
  109. package/src/lib/parsers/artifact/__tests__/fixtures/invalid-bytecode.json +0 -5
  110. package/src/lib/parsers/artifact/__tests__/fixtures/invalid-json.txt +0 -11
  111. package/src/lib/parsers/artifact/__tests__/fixtures/minimal-artifact.json +0 -5
  112. package/src/lib/parsers/artifact/__tests__/fixtures/missing-abi.json +0 -4
  113. package/src/lib/parsers/artifact/__tests__/fixtures/missing-bytecode.json +0 -11
  114. package/src/lib/parsers/artifact/__tests__/fixtures/missing-contract-name.json +0 -11
  115. package/src/lib/parsers/artifact/__tests__/fixtures/simple-artifact.json +0 -40
  116. package/src/lib/parsers/artifact/__tests__/fixtures/wrong-types.json +0 -7
  117. package/src/lib/parsers/artifact/foundry-1.2.ts +0 -72
  118. package/src/lib/parsers/artifact/index.ts +0 -27
  119. package/src/lib/parsers/artifact/types.ts +0 -9
  120. package/src/lib/parsers/buildinfo.ts +0 -127
  121. package/src/lib/parsers/constants.ts +0 -56
  122. package/src/lib/parsers/index.ts +0 -6
  123. package/src/lib/parsers/job.ts +0 -160
  124. package/src/lib/parsers/source.ts +0 -129
  125. package/src/lib/parsers/template.ts +0 -135
  126. package/src/lib/provenance.ts +0 -785
  127. package/src/lib/std/templates/arachnid-deterministic-deployment-proxy.yaml +0 -68
  128. package/src/lib/std/templates/assured-deployment.yaml +0 -46
  129. package/src/lib/std/templates/era-evm-predeploy.yaml +0 -35
  130. package/src/lib/std/templates/erc-2470.yaml +0 -70
  131. package/src/lib/std/templates/min-balance.yaml +0 -35
  132. package/src/lib/std/templates/nano-universal-deployer.yaml +0 -61
  133. package/src/lib/std/templates/raw-erc-2470.yaml +0 -62
  134. package/src/lib/std/templates/raw-nano-universal-deployer.yaml +0 -54
  135. package/src/lib/std/templates/raw-sequence-universal-deployer-2.yaml +0 -52
  136. package/src/lib/std/templates/sequence-universal-deployer-2.yaml +0 -61
  137. package/src/lib/types/__tests__/json-request-action.spec.ts +0 -243
  138. package/src/lib/types/__tests__/read-json-value.spec.ts +0 -278
  139. package/src/lib/types/__tests__/resolve-json-value.spec.ts +0 -769
  140. package/src/lib/types/actions.ts +0 -148
  141. package/src/lib/types/artifacts.ts +0 -21
  142. package/src/lib/types/buildinfo.ts +0 -116
  143. package/src/lib/types/conditions.ts +0 -50
  144. package/src/lib/types/contracts.ts +0 -26
  145. package/src/lib/types/definitions.ts +0 -77
  146. package/src/lib/types/index.ts +0 -9
  147. package/src/lib/types/network.ts +0 -33
  148. package/src/lib/types/project.ts +0 -9
  149. package/src/lib/types/source.ts +0 -26
  150. package/src/lib/types/task.ts +0 -9
  151. package/src/lib/types/values.ts +0 -221
  152. package/src/lib/utils/assertion.ts +0 -24
  153. package/src/lib/utils/validation.ts +0 -116
  154. package/src/lib/validation/contract-references.ts +0 -210
  155. package/src/lib/validation/index.ts +0 -1
  156. package/src/lib/verification/__tests__/etherscan.spec.ts +0 -710
  157. package/src/lib/verification/__tests__/sourcify.spec.ts +0 -288
  158. package/src/lib/verification/etherscan.ts +0 -547
  159. package/src/lib/verification/sourcify.ts +0 -248
  160. package/test_validation/artifacts/TestContract.json +0 -9
  161. package/test_validation/jobs/test-missing.yaml +0 -16
  162. package/test_validation/networks.yaml +0 -3
  163. package/tsconfig.json +0 -36
@@ -1,1005 +0,0 @@
1
- import * as fs from 'fs/promises'
2
- import * as path from 'path'
3
-
4
- import { ProjectLoader, ProjectLoaderOptions } from './core/loader'
5
- import { DependencyGraph } from './core/graph'
6
- import { ExecutionEngine } from './core/engine'
7
- import { createDefaultVerificationRegistry } from './verification/etherscan'
8
- import { ExecutionContext } from './core/context'
9
- import { Network, Job } from './types'
10
- import { DeploymentEventEmitter, deploymentEvents } from './events'
11
- import type { RunSummaryEvent } from './events'
12
-
13
- /**
14
- * Options for configuring a Deployer instance.
15
- */
16
- export interface DeployerOptions {
17
- /** The root directory of the deployment project. */
18
- projectRoot: string
19
-
20
- /** The private key of the EOA to be used as the signer/relayer. Optional if an implicit sender from RPC is desired. */
21
- privateKey?: string
22
-
23
- /** An array of network configurations to use for deployment. */
24
- networks: Network[]
25
-
26
- /** Optional: An array of job names to execute. If not provided, all jobs are considered. */
27
- runJobs?: string[]
28
-
29
- /** Optional: An array of chain IDs to run on. If not provided, all configured networks are used. */
30
- runOnNetworks?: number[]
31
-
32
- /** Optional: Custom event emitter instance. If not provided, uses the global singleton. */
33
- eventEmitter?: DeploymentEventEmitter
34
-
35
- /** Optional: Project loader options (e.g., whether to load standard templates). */
36
- loaderOptions?: ProjectLoaderOptions
37
-
38
- /** Optional Etherscan API key for contract verification. */
39
- etherscanApiKey?: string
40
-
41
- /** Optional: Stop execution as soon as any job fails. Defaults to false. */
42
- failEarly?: boolean
43
-
44
- /** Optional: Skip post-execution check of skip conditions. Defaults to false (post-check enabled). */
45
- noPostCheckConditions?: boolean
46
-
47
- /** Optional: When true, write outputs in a flat directory instead of mirroring the jobs dir structure. */
48
- flatOutput?: boolean
49
-
50
- /** Optional: Allow running jobs marked as deprecated when true. */
51
- runDeprecated?: boolean
52
-
53
- /** Optional: Show end-of-run summary (default: true). */
54
- showSummary?: boolean
55
-
56
- /** Optional: Convert verification errors to warnings instead of failing (default: false). */
57
- ignoreVerifyErrors?: boolean
58
- }
59
-
60
- /**
61
- * The Deployer is the top-level orchestrator for the entire deployment process.
62
- * It loads a project, builds the dependency graph, and executes jobs across
63
- * specified networks in the correct order.
64
- */
65
- export class Deployer {
66
- private readonly options: DeployerOptions
67
- public readonly events: DeploymentEventEmitter
68
- private readonly loader: ProjectLoader
69
- private readonly noPostCheckConditions: boolean
70
- private readonly showSummary: boolean
71
-
72
- // Store both successful and failed execution results
73
- private readonly results = new Map<string, {
74
- job: Job;
75
- outputs: Map<number, { status: 'success' | 'error' | 'skipped'; data: Map<string, unknown> | string }>
76
- }>()
77
- private graph?: DependencyGraph
78
-
79
-
80
- constructor(options: DeployerOptions) {
81
- this.options = options
82
- this.events = options.eventEmitter || deploymentEvents
83
- this.loader = new ProjectLoader(options.projectRoot, options.loaderOptions)
84
- this.noPostCheckConditions = options.noPostCheckConditions ?? false
85
- this.showSummary = options.showSummary !== false
86
- }
87
-
88
-
89
- /**
90
- * Runs the entire deployment process from loading to execution and outputting results.
91
- */
92
- public async run(): Promise<void> {
93
- this.events.emitEvent({
94
- type: 'deployment_started',
95
- level: 'info',
96
- data: {
97
- projectRoot: this.options.projectRoot
98
- }
99
- })
100
-
101
- try {
102
- // 1. Load all project artifacts, templates, and jobs.
103
- this.events.emitEvent({
104
- type: 'project_loading_started',
105
- level: 'info',
106
- data: {
107
- projectRoot: this.options.projectRoot
108
- }
109
- })
110
-
111
- await this.loader.load()
112
-
113
- this.events.emitEvent({
114
- type: 'project_loaded',
115
- level: 'info',
116
- data: {
117
- jobCount: this.loader.jobs.size,
118
- templateCount: this.loader.templates.size
119
- }
120
- })
121
-
122
- // 2. Build the dependency graph and determine execution order.
123
- const graph = new DependencyGraph(this.loader.jobs, this.loader.templates)
124
- this.graph = graph
125
- const jobOrder = graph.getExecutionOrder()
126
-
127
- // 3. Filter jobs and networks based on user options.
128
- const jobsToRun = this.getJobExecutionPlan(jobOrder)
129
-
130
- // Inform about skipped deprecated jobs (when applicable)
131
- if (!this.options.runDeprecated) {
132
- const skippedDeprecated = jobOrder.filter(name => {
133
- const j = this.loader.jobs.get(name) as { deprecated?: boolean } | undefined
134
- return !jobsToRun.includes(name) && j?.deprecated === true
135
- })
136
- if (skippedDeprecated.length > 0) {
137
- this.events.emitEvent({
138
- type: 'deprecated_jobs_skipped',
139
- level: 'warn',
140
- data: { jobs: skippedDeprecated }
141
- })
142
- }
143
- }
144
- const targetNetworks = this.getTargetNetworks()
145
-
146
- this.events.emitEvent({
147
- type: 'execution_plan',
148
- level: 'info',
149
- data: {
150
- targetNetworks: targetNetworks.map(n => ({
151
- name: n.name,
152
- chainId: n.chainId
153
- })),
154
- jobExecutionOrder: jobsToRun
155
- }
156
- })
157
-
158
- // 4. Execute the plan.
159
- const verificationRegistry = createDefaultVerificationRegistry(this.options.etherscanApiKey)
160
- const engine = new ExecutionEngine(this.loader.templates, {
161
- eventEmitter: this.events,
162
- verificationRegistry,
163
- noPostCheckConditions: this.noPostCheckConditions,
164
- ignoreVerifyErrors: this.options.ignoreVerifyErrors ?? false
165
- })
166
-
167
- // Track if any jobs have failed
168
- let hasFailures = false
169
- // Emit signer info once per network (chainId)
170
- const signerInfoPrintedForChain = new Set<number>()
171
-
172
- for (const network of targetNetworks) {
173
- this.events.emitEvent({
174
- type: 'network_started',
175
- level: 'info',
176
- data: {
177
- networkName: network.name,
178
- chainId: network.chainId
179
- }
180
- })
181
- for (const jobName of jobsToRun) {
182
- const job = this.loader.jobs.get(jobName)!
183
-
184
- // Initialize results storage for this job if not exists
185
- if (!this.results.has(job.name)) {
186
- this.results.set(job.name, { job, outputs: new Map() })
187
- }
188
-
189
- if (this.shouldSkipJobOnNetwork(job, network)) {
190
- this.events.emitEvent({
191
- type: 'job_skipped',
192
- level: 'warn',
193
- data: {
194
- jobName,
195
- networkName: network.name,
196
- reason: 'configuration'
197
- }
198
- })
199
-
200
- // Store skipped result
201
- this.results.get(job.name)!.outputs.set(network.chainId, {
202
- status: 'skipped',
203
- data: 'Job skipped due to network configuration'
204
- })
205
-
206
- continue
207
- }
208
-
209
- let context: ExecutionContext | undefined
210
- try {
211
- context = new ExecutionContext(
212
- network,
213
- this.options.privateKey,
214
- this.loader.contractRepository,
215
- this.options.etherscanApiKey,
216
- this.loader.constants
217
- )
218
- // Set job-level constants if present (guard for mocked contexts in tests)
219
- if (typeof (context as unknown as { setJobConstants?: (constants: unknown) => void }).setJobConstants === 'function') {
220
- (context as unknown as { setJobConstants: (constants: unknown) => void }).setJobConstants(job.constants)
221
- }
222
-
223
- // Emit signer info once per network using the first job's context
224
- if (!signerInfoPrintedForChain.has(network.chainId)) {
225
- try {
226
- const getSignerFn = (context as unknown as {
227
- getResolvedSigner?: () => Promise<{ getAddress: () => Promise<string> }>
228
- signer?: { getAddress: () => Promise<string> }
229
- }).getResolvedSigner
230
- const signer = getSignerFn
231
- ? await getSignerFn.call(context)
232
- : (context as unknown as { signer?: { getAddress: () => Promise<string> } }).signer
233
- if (signer && typeof signer.getAddress === 'function') {
234
- const address = await signer.getAddress()
235
- // provider may not exist on mocked contexts; guard for it
236
- const provider = (context as unknown as {
237
- provider?: { getBalance: (addr: string) => Promise<bigint | number | { toString: () => string }> }
238
- }).provider
239
- if (provider && typeof provider.getBalance === 'function') {
240
- const balanceBn = await provider.getBalance(address)
241
- const balanceWei = balanceBn.toString()
242
- const balanceEth = (Number(balanceBn) / 1e18).toString()
243
- this.events.emitEvent({
244
- type: 'network_signer_info',
245
- level: 'info',
246
- data: {
247
- networkName: network.name,
248
- chainId: network.chainId,
249
- address,
250
- balanceWei,
251
- balance: balanceEth
252
- }
253
- })
254
- }
255
- }
256
- } catch {
257
- // ignore non-fatal signer info errors
258
- } finally {
259
- signerInfoPrintedForChain.add(network.chainId)
260
- }
261
- }
262
-
263
- // Check job-level skip conditions before execution
264
- // skip_if is a pure gate (no post-check), skip_condition has both pre-skip and post-check
265
- const skipIfConditions = job.skip_if
266
- const skipConditions = job.skip_condition
267
-
268
- if (skipIfConditions || skipConditions) {
269
- // Evaluate skip_if first (pure gate)
270
- let shouldSkip = false
271
- let skipReason: string = 'skip_condition'
272
-
273
- if (skipIfConditions) {
274
- const skipIfResult = await engine.evaluateSkipConditions(skipIfConditions, context, new Map())
275
- if (skipIfResult) {
276
- shouldSkip = true
277
- skipReason = 'skip_if'
278
- }
279
- }
280
-
281
- // If not skipped by skip_if, check skip_condition
282
- if (!shouldSkip && skipConditions) {
283
- const skipConditionResult = await engine.evaluateSkipConditions(skipConditions, context, new Map())
284
- if (skipConditionResult) {
285
- shouldSkip = true
286
- skipReason = 'skip_condition'
287
- }
288
- }
289
-
290
- if (shouldSkip) {
291
- // Store skipped result
292
- this.results.get(job.name)!.outputs.set(network.chainId, {
293
- status: 'skipped',
294
- data: `Job "${job.name}" skipped due to ${skipReason}`
295
- })
296
-
297
- this.events.emitEvent({
298
- type: 'job_skipped',
299
- level: 'warn',
300
- data: {
301
- jobName: job.name,
302
- networkName: network.name,
303
- reason: skipReason
304
- }
305
- })
306
-
307
- continue // Skip to next job
308
- }
309
- }
310
-
311
- // Populate context with outputs from previously executed dependent jobs
312
- this.populateContextWithDependentJobOutputs(job, context, network)
313
-
314
- await engine.executeJob(job, context)
315
-
316
- // Store successful results
317
- this.results.get(job.name)!.outputs.set(network.chainId, {
318
- status: 'success',
319
- data: (context as { getOutputs(): Map<string, unknown> }).getOutputs()
320
- })
321
- } catch (error) {
322
- // Store error results
323
- const errorMessage = error instanceof Error ? error.message : String(error)
324
- this.results.get(job.name)!.outputs.set(network.chainId, {
325
- status: 'error',
326
- data: errorMessage
327
- })
328
-
329
- this.events.emitEvent({
330
- type: 'job_execution_failed',
331
- level: 'error',
332
- data: {
333
- jobName: job.name,
334
- networkName: network.name,
335
- chainId: network.chainId,
336
- error: errorMessage
337
- }
338
- })
339
-
340
- // Mark that we have failures
341
- hasFailures = true
342
-
343
- // If fail-early is enabled, throw the error immediately
344
- if (this.options.failEarly) {
345
- throw error
346
- }
347
-
348
- // Otherwise, continue to next job/network
349
- } finally {
350
- // Clean up the context to prevent hanging connections
351
- if (context) {
352
- try {
353
- await context.dispose()
354
- } catch (disposeError) {
355
- // Log disposal errors but don't let them interrupt the flow
356
- this.events.emitEvent({
357
- type: 'context_disposal_warning',
358
- level: 'warn',
359
- data: {
360
- jobName: job.name,
361
- networkName: network.name,
362
- error: disposeError instanceof Error ? disposeError.message : String(disposeError)
363
- }
364
- })
365
- }
366
- }
367
- }
368
- }
369
- }
370
-
371
- // 5. Write results to output files.
372
- await this.writeOutputFiles()
373
-
374
- // Show verification warnings report if ignoreVerifyErrors is enabled
375
- if (this.options.ignoreVerifyErrors) {
376
- this.emitVerificationWarningsReport(engine)
377
- }
378
-
379
- // Emit end-of-run summary before final status
380
- if (this.showSummary) {
381
- this.emitRunSummary(hasFailures)
382
- }
383
-
384
- // Check if any jobs failed and exit with error if so
385
- if (hasFailures) {
386
- const error = new Error('One or more jobs failed during execution')
387
-
388
- // Build a flat list of failed jobs with network context and error messages
389
- const failedJobs: Array<{ jobName: string; networkName: string; chainId: number; error: string }> = []
390
- for (const [, result] of this.results) {
391
- const job = result.job
392
- for (const [chainId, netResult] of result.outputs) {
393
- if (netResult.status === 'error') {
394
- // Resolve network name from configured networks (fallback to chainId if missing)
395
- const network = this.options.networks.find(n => n.chainId === chainId)
396
- failedJobs.push({
397
- jobName: job.name,
398
- networkName: network?.name || `chain-${chainId}`,
399
- chainId,
400
- error: String(netResult.data)
401
- })
402
- }
403
- }
404
- }
405
-
406
- this.events.emitEvent({
407
- type: 'deployment_failed',
408
- level: 'error',
409
- data: {
410
- error: error.message,
411
- stack: error.stack,
412
- failedJobs
413
- }
414
- })
415
- throw error
416
- }
417
-
418
- this.events.emitEvent({
419
- type: 'deployment_completed',
420
- level: 'info'
421
- })
422
- } catch (error) {
423
- this.events.emitEvent({
424
- type: 'deployment_failed',
425
- level: 'error',
426
- data: {
427
- error: error instanceof Error ? error.message : String(error),
428
- stack: error instanceof Error ? error.stack : undefined
429
- }
430
- })
431
- // Re-throw to allow CLI to exit with a non-zero code
432
- throw error
433
- }
434
- }
435
-
436
- /**
437
- * Emit a concise run summary event for the CLI to render at the end.
438
- */
439
- private emitRunSummary(hasFailures: boolean): void {
440
- // Compute counts
441
- const jobCount = this.results.size
442
- let successCount = 0
443
- let failedCount = 0
444
- let skippedCount = 0
445
-
446
- for (const [, result] of this.results) {
447
- for (const [, netResult] of result.outputs) {
448
- if (netResult.status === 'success') successCount++
449
- else if (netResult.status === 'skipped') skippedCount++
450
- else if (netResult.status === 'error') failedCount++
451
- }
452
- }
453
-
454
- // Collect key contract addresses from outputs for quick visibility
455
- const keyContracts: Array<{ job: string; action: string; address: string }> = []
456
- for (const [, result] of this.results) {
457
- for (const [, netResult] of result.outputs) {
458
- if (netResult.status !== 'success') continue
459
- const outputs = netResult.data as Map<string, unknown>
460
- for (const [k, v] of outputs) {
461
- if (k.endsWith('.address') && typeof v === 'string') {
462
- const action = k.split('.')[0]
463
- keyContracts.push({ job: result.job.name, action, address: v })
464
- }
465
- }
466
- }
467
- }
468
-
469
- const summaryEvent = {
470
- type: 'run_summary',
471
- level: (hasFailures ? 'warn' : 'info') as 'info' | 'warn',
472
- data: {
473
- networkCount: this.options.networks.length,
474
- jobCount,
475
- successCount,
476
- failedCount,
477
- skippedCount,
478
- keyContracts: keyContracts.slice(0, 10)
479
- }
480
- } satisfies Omit<RunSummaryEvent, 'timestamp'>
481
-
482
- this.events.emitEvent(summaryEvent)
483
- }
484
-
485
- /**
486
- * Emit verification warnings report when ignoreVerifyErrors is enabled
487
- */
488
- private emitVerificationWarningsReport(engine: ExecutionEngine): void {
489
- const warnings = engine.getVerificationWarnings()
490
-
491
- if (warnings.length > 0) {
492
- this.events.emitEvent({
493
- type: 'verification_warnings_report',
494
- level: 'warn',
495
- data: {
496
- totalWarnings: warnings.length,
497
- warnings: warnings
498
- }
499
- })
500
- }
501
- }
502
-
503
- /**
504
- * Determines the final, ordered list of jobs to execute based on user input.
505
- * If a user requests specific jobs, this ensures all their dependencies are also included.
506
- */
507
- private getJobExecutionPlan(fullOrder: string[]): string[] {
508
- // Expand provided runJobs to concrete job names by supporting simple glob patterns
509
- const expandRunJobs = (patterns: string[]): string[] => {
510
- const allJobNames = Array.from(this.loader.jobs.keys())
511
-
512
- const isPattern = (s: string): boolean => /[*?]/.test(s)
513
- const escapeRegex = (s: string): string => s.replace(/[-\\^$+?.()|[\]{}*?]/g, '\\$&')
514
- const patternToRegex = (pattern: string): RegExp => {
515
- // Escape regex metacharacters, then translate wildcard tokens
516
- const escaped = escapeRegex(pattern)
517
- .replace(/\\\*/g, '.*') // escaped '*' -> '.*'
518
- .replace(/\\\?/g, '.') // escaped '?' -> '.'
519
- return new RegExp(`^${escaped}$`)
520
- }
521
-
522
- const expanded: string[] = []
523
- const seen = new Set<string>()
524
-
525
- for (const p of patterns) {
526
- if (!isPattern(p)) {
527
- // Exact name; validate exists
528
- if (!this.loader.jobs.has(p)) {
529
- throw new Error(`Specified job "${p}" not found in project.`)
530
- }
531
- if (!seen.has(p)) {
532
- seen.add(p)
533
- expanded.push(p)
534
- }
535
- continue
536
- }
537
-
538
- const re = patternToRegex(p)
539
- const matches = allJobNames.filter(name => re.test(name))
540
- if (matches.length === 0) {
541
- throw new Error(`Job pattern "${p}" did not match any jobs in project.`)
542
- }
543
- for (const m of matches) {
544
- if (!seen.has(m)) {
545
- seen.add(m)
546
- expanded.push(m)
547
- }
548
- }
549
- }
550
-
551
- return expanded
552
- }
553
-
554
- // Helper to decide if a job is deprecated
555
- const isDeprecated = (jobName: string): boolean => {
556
- const j = this.loader.jobs.get(jobName)
557
- return !!(j && (j as { deprecated?: boolean }).deprecated === true)
558
- }
559
-
560
- // If user didn't specify jobs explicitly, include all non-deprecated jobs.
561
- // Additionally, ALWAYS include deprecated jobs when they are dependencies of any non-deprecated job.
562
- if (!this.options.runJobs || this.options.runJobs.length === 0) {
563
- if (this.options.runDeprecated) {
564
- return fullOrder
565
- }
566
-
567
- const nonDeprecatedJobs = new Set(fullOrder.filter(name => !isDeprecated(name)))
568
-
569
- // Collect deprecated jobs that are required by any non-deprecated job
570
- const requiredDeprecated = new Set<string>()
571
- for (const jobName of nonDeprecatedJobs) {
572
- const deps = this.graph?.getDependencies(jobName) || new Set<string>()
573
- for (const dep of deps) {
574
- if (isDeprecated(dep)) {
575
- requiredDeprecated.add(dep)
576
- }
577
- }
578
- }
579
-
580
- const allowed = new Set<string>([...nonDeprecatedJobs, ...requiredDeprecated])
581
- return fullOrder.filter(name => allowed.has(name))
582
- }
583
-
584
- // Expand patterns to concrete names
585
- const expandedRunJobs = expandRunJobs(this.options.runJobs)
586
- const explicitlyRequested = new Set<string>(expandedRunJobs)
587
-
588
- const jobsToRun = new Set<string>()
589
- for (const jobName of expandedRunJobs) {
590
- jobsToRun.add(jobName)
591
- const dependencies = this.graph?.getDependencies(jobName) || new Set()
592
- dependencies.forEach((dep: string) => jobsToRun.add(dep))
593
- }
594
-
595
- // Deprecated dependencies must be kept even when --run-deprecated is not set.
596
- // Only drop deprecated jobs that are neither explicitly requested nor required as a dependency.
597
- const depsOfRequested = new Set<string>()
598
- for (const jobName of expandedRunJobs) {
599
- const deps = this.graph?.getDependencies(jobName) || new Set<string>()
600
- deps.forEach(d => depsOfRequested.add(d))
601
- }
602
-
603
- const filtered = Array.from(jobsToRun).filter(name => {
604
- if (!isDeprecated(name)) return true
605
- if (explicitlyRequested.has(name)) return true
606
- if (depsOfRequested.has(name)) return true // keep deprecated dependency
607
- return this.options.runDeprecated === true
608
- })
609
- const allowedSet = new Set(filtered)
610
-
611
- // Filter the original execution order to only include the required jobs, preserving the correct sequence.
612
- return fullOrder.filter(jobName => allowedSet.has(jobName))
613
- }
614
-
615
- /**
616
- * Determines the final list of networks to run on based on user input.
617
- */
618
- private getTargetNetworks(): Network[] {
619
- if (!this.options.runOnNetworks || this.options.runOnNetworks.length === 0) {
620
- return this.options.networks // Run on all configured networks
621
- }
622
-
623
- const targetChainIds = new Set(this.options.runOnNetworks)
624
- const filteredNetworks = this.options.networks.filter(n => targetChainIds.has(n.chainId))
625
-
626
- if (filteredNetworks.length !== this.options.runOnNetworks.length) {
627
- const foundIds = new Set(filteredNetworks.map(n => n.chainId))
628
- const missingIds = this.options.runOnNetworks.filter(id => !foundIds.has(id))
629
- this.events.emitEvent({
630
- type: 'missing_network_config_warning',
631
- level: 'warn',
632
- data: {
633
- missingChainIds: missingIds
634
- }
635
- })
636
- }
637
-
638
- return filteredNetworks
639
- }
640
-
641
- /**
642
- * Checks a job's `only_networks` and `skip_networks` fields to see if it should run on the given network.
643
- */
644
- private shouldSkipJobOnNetwork(job: Job, network: Network): boolean {
645
- // Note: This relies on `only_networks` and `skip_networks` being present on the Job type.
646
- const jobWithNetworkFilters = job as Job & { only_networks?: number[]; skip_networks?: number[]; min_evm_version?: string }
647
-
648
- // Check only_networks: if present, the job only runs on these networks.
649
- // If the network is NOT in only_networks, skip immediately. If it IS included, continue to min_evm_version checks.
650
- const hasOnly = !!(jobWithNetworkFilters.only_networks && jobWithNetworkFilters.only_networks.length > 0)
651
- if (hasOnly) {
652
- if (!jobWithNetworkFilters.only_networks!.includes(network.chainId)) {
653
- return true
654
- }
655
- // When only_networks is present and the network is allowed, skip_networks is ignored by design.
656
- } else {
657
- // Only consider skip_networks when only_networks is not set.
658
- if (jobWithNetworkFilters.skip_networks && jobWithNetworkFilters.skip_networks.length > 0) {
659
- if (jobWithNetworkFilters.skip_networks.includes(network.chainId)) {
660
- return true
661
- }
662
- }
663
- }
664
-
665
- // Check minimal EVM hardfork requirement if present on job and network declares an EVM version
666
- if (jobWithNetworkFilters.min_evm_version) {
667
- const jobMin = this.normalizeEvmVersion(jobWithNetworkFilters.min_evm_version)
668
- const chainEvm = network.evmVersion ? this.normalizeEvmVersion(network.evmVersion) : undefined
669
- if (jobMin && chainEvm) {
670
- // Skip when chain's EVM is older than job's minimal requirement
671
- return this.compareEvmVersions(chainEvm, jobMin) < 0
672
- }
673
- // If network has no evmVersion declared, do not skip (assume compatible)
674
- }
675
-
676
- return false // Run by default
677
- }
678
-
679
- /**
680
- * Normalizes common EVM hardfork identifiers to a canonical lowercase token.
681
- */
682
- private normalizeEvmVersion(identifier: string | undefined): string | undefined {
683
- if (!identifier) return undefined
684
- const id = String(identifier).trim().toLowerCase()
685
- const aliasMap: Record<string, string> = {
686
- frontier: 'frontier',
687
- homestead: 'homestead',
688
- 'tangerine whistle': 'tangerine',
689
- tangerine: 'tangerine',
690
- 'spurious dragon': 'spuriousdragon',
691
- spuriousdragon: 'spuriousdragon',
692
- byzantium: 'byzantium',
693
- constantinople: 'constantinople',
694
- petersburg: 'petersburg',
695
- istanbul: 'istanbul',
696
- berlin: 'berlin',
697
- london: 'london',
698
- // The Merge hardfork is referred to as Paris in solidity's evmVersion naming
699
- merge: 'paris',
700
- paris: 'paris',
701
- shanghai: 'shanghai',
702
- // a.k.a. Cancun + Deneb (Dencun)
703
- cancun: 'cancun',
704
- dencun: 'cancun',
705
- prague: 'prague',
706
- }
707
- return aliasMap[id] || undefined
708
- }
709
-
710
- /**
711
- * Compares canonical EVM hardfork tokens. Returns -1 if a < b, 0 if equal, 1 if a > b.
712
- * Unknown tokens are treated as incomparable; caller should guard for undefined.
713
- */
714
- private compareEvmVersions(a: string, b: string): number {
715
- const order = [
716
- 'frontier',
717
- 'homestead',
718
- 'tangerine',
719
- 'spuriousdragon',
720
- 'byzantium',
721
- 'constantinople',
722
- 'petersburg',
723
- 'istanbul',
724
- 'berlin',
725
- 'london',
726
- 'paris',
727
- 'shanghai',
728
- 'cancun',
729
- 'prague'
730
- ]
731
- const ia = order.indexOf(a)
732
- const ib = order.indexOf(b)
733
- if (ia === -1 || ib === -1) return 0
734
- if (ia < ib) return -1
735
- if (ia > ib) return 1
736
- return 0
737
- }
738
-
739
- /**
740
- * Populates the execution context with outputs from previously executed dependent jobs.
741
- * Throws an error if any dependency failed.
742
- */
743
- private populateContextWithDependentJobOutputs(job: Job, context: ExecutionContext, network: Network): void {
744
- if (!job.depends_on) return
745
-
746
- for (const dependentJobName of job.depends_on) {
747
- const dependentJobResults = this.results.get(dependentJobName)
748
- if (!dependentJobResults) {
749
- throw new Error(`Job "${job.name}" depends on "${dependentJobName}", but "${dependentJobName}" has not been executed yet.`)
750
- }
751
-
752
- const networkResult = dependentJobResults.outputs.get(network.chainId)
753
- if (!networkResult) {
754
- throw new Error(`Job "${job.name}" depends on "${dependentJobName}", but "${dependentJobName}" has not been executed on network ${network.name} (chainId: ${network.chainId}).`)
755
- }
756
-
757
- // Skip jobs don't provide outputs, but they don't prevent dependent jobs from running
758
- if (networkResult.status === 'skipped') {
759
- continue
760
- }
761
-
762
- if (networkResult.status !== 'success') {
763
- const errorMessage = typeof networkResult.data === 'string' ? networkResult.data : 'Unknown error'
764
- throw new Error(`Job "${job.name}" depends on "${dependentJobName}", but "${dependentJobName}" failed: ${errorMessage}`)
765
- }
766
-
767
- // Add outputs with job name prefixes for cross-job access
768
- const outputs = networkResult.data as Map<string, unknown>
769
- for (const [key, value] of outputs.entries()) {
770
- const prefixedKey = `${dependentJobName}.${key}`
771
- context.setOutput(prefixedKey, value)
772
- }
773
- }
774
- }
775
-
776
-
777
-
778
- /**
779
- * Writes the collected deployment results to JSON files in the output directory.
780
- * By default, mirrors the jobs directory structure under output/. When flatOutput
781
- * is true, writes all job JSONs directly under output/.
782
- */
783
- private async writeOutputFiles(): Promise<void> {
784
- if (this.results.size === 0) {
785
- this.events.emitEvent({
786
- type: 'no_outputs',
787
- level: 'warn'
788
- })
789
- return
790
- }
791
-
792
- const outputRoot = path.join(this.options.projectRoot, 'output')
793
- await fs.mkdir(outputRoot, { recursive: true })
794
-
795
- this.events.emitEvent({
796
- type: 'output_writing_started',
797
- level: 'info'
798
- })
799
-
800
- for (const [jobName, resultData] of this.results.entries()) {
801
- // Determine relative subpath for this job based on its source path under jobs/
802
- let relativeJobSubpath = `${jobName}.json`
803
- if (!this.options.flatOutput && resultData.job._path) {
804
- // Find jobs directory within project
805
- const jobsDir = path.join(this.options.projectRoot, 'jobs')
806
- const normalizedJobPath = path.normalize(resultData.job._path)
807
- const normalizedJobsDir = path.normalize(jobsDir)
808
- if (normalizedJobPath.startsWith(normalizedJobsDir)) {
809
- // Compute relative path from jobs dir to the yaml file, and replace extension with .json
810
- const relFromJobs = path.relative(normalizedJobsDir, normalizedJobPath)
811
- const dirPart = path.dirname(relFromJobs)
812
- const fileBase = path.basename(relFromJobs, path.extname(relFromJobs))
813
- relativeJobSubpath = dirPart === '.' ? `${fileBase}.json` : path.join(dirPart, `${fileBase}.json`)
814
- } else {
815
- // Fallback to job name if path isn't within jobs dir
816
- relativeJobSubpath = `${jobName}.json`
817
- }
818
- }
819
-
820
- const outputFilePath = path.join(outputRoot, relativeJobSubpath)
821
- const outputFileDir = path.dirname(outputFilePath)
822
- await fs.mkdir(outputFileDir, { recursive: true })
823
-
824
- // Group networks by identical status and outputs
825
- const groupedResults = this.groupNetworkResults(resultData.outputs, resultData.job)
826
-
827
- const fileContent = {
828
- jobName: resultData.job.name,
829
- jobVersion: resultData.job.version,
830
- lastRun: new Date().toISOString(),
831
- networks: groupedResults
832
- }
833
-
834
- await fs.writeFile(outputFilePath, JSON.stringify(fileContent, null, 2))
835
- this.events.emitEvent({
836
- type: 'output_file_written',
837
- level: 'info',
838
- data: {
839
- relativePath: path.relative(this.options.projectRoot, outputFilePath)
840
- }
841
- })
842
- }
843
- }
844
-
845
- /**
846
- * Filters outputs according to job actions' output selection:
847
- * - output: true -> include all outputs for that action
848
- * - output: false -> exclude outputs for that action
849
- * - output: object -> include ONLY the specified keys, resolved from context if they are placeholders
850
- *
851
- * If no actions have output: true or object (i.e., only false/undefined), includes all outputs (backward compatibility),
852
- * but excludes dependency outputs when there are explicit dependencies defined.
853
- */
854
- private filterOutputsByActionFlags(outputs: Map<string, unknown>, job: Job): Record<string, unknown> {
855
- // Partition actions by output config
856
- const actionsWithCustomMap = job.actions.filter(a => a.output && typeof a.output === 'object' && a.output !== null) as Array<Job['actions'][number] & { output: Record<string, unknown> }>
857
- const actionsWithTrue = job.actions.filter(a => a.output === true)
858
- const actionsWithFalse = new Set(job.actions.filter(a => a.output === false).map(a => a.name))
859
-
860
- // If there are any custom maps, include only those mapped keys for those actions.
861
- // Collect explicit inclusions here.
862
- const result = new Map<string, unknown>()
863
-
864
- // Helper to include by prefix
865
- const includeAllForAction = (actionName: string) => {
866
- for (const [key, value] of outputs) {
867
- if (key.startsWith(`${actionName}.`)) {
868
- result.set(key, value)
869
- }
870
- }
871
- }
872
-
873
- // 1) Handle custom output maps (highest precedence and explicit selection)
874
- if (actionsWithCustomMap.length > 0) {
875
- for (const action of actionsWithCustomMap) {
876
- const prefix = `${action.name}.`
877
- // For mapped keys, accept either fully qualified keys (e.g., "txHash") which we map to `${action.name}.txHash`
878
- // or already-qualified keys (rare). We'll normalize to prefixed keys in the output.
879
- for (const mappedKey of Object.keys(action.output)) {
880
- // If user provided fully-qualified "action.key", strip if redundant
881
- const normalizedKey = mappedKey.startsWith(prefix) ? mappedKey : `${prefix}${mappedKey}`
882
- // Only include if present in outputs map
883
- if (outputs.has(normalizedKey)) {
884
- result.set(normalizedKey, outputs.get(normalizedKey)!)
885
- }
886
- }
887
- }
888
- // Note: when any custom maps exist, we DO NOT automatically include actionsWithTrue;
889
- // the requirement states "if action specifies custom output, then the output is defined by them and not by the template".
890
- // That means for those actions, only mapped keys are included. For other actions (without custom maps),
891
- // they will be handled by output:true rules below.
892
- }
893
-
894
- // 2) Include all for actions marked output: true (that do not have a custom map)
895
- const actionsWithTrueNames = new Set(actionsWithTrue.map(a => a.name))
896
- for (const actionName of actionsWithTrueNames) {
897
- // If this action also had a custom map, custom map already handled it and should be authoritative.
898
- const hadCustom = actionsWithCustomMap.some(a => a.name === actionName)
899
- if (!hadCustom) {
900
- includeAllForAction(actionName)
901
- }
902
- }
903
-
904
- // 3) Exclude any actions explicitly marked false (they won't be included by rules above anyway)
905
-
906
- const hasExplicitOutputSelection = actionsWithCustomMap.length > 0 || actionsWithTrue.length > 0
907
-
908
- // If the job has any explicit output selection, return the selected outputs even when empty.
909
- if (hasExplicitOutputSelection) {
910
- // Additionally, filter out any accidentally included outputs from actions marked false
911
- for (const falseActionName of actionsWithFalse) {
912
- for (const key of Array.from(result.keys())) {
913
- if (key.startsWith(`${falseActionName}.`)) {
914
- result.delete(key)
915
- }
916
- }
917
- }
918
- return Object.fromEntries(result)
919
- }
920
-
921
- // 4) Backward compatibility: include all outputs if no action opted-in via true/object.
922
- // Exclude dependency outputs if the job has explicit dependencies.
923
- if (job.depends_on && job.depends_on.length > 0) {
924
- return this.filterOutDependencyOutputs(outputs, job)
925
- }
926
- return Object.fromEntries(outputs)
927
- }
928
-
929
- /**
930
- * Filters out dependency outputs from the outputs map.
931
- * Dependency outputs are identified by being prefixed with dependency job names.
932
- */
933
- private filterOutDependencyOutputs(outputs: Map<string, unknown>, job: Job): Record<string, unknown> {
934
- const filtered = new Map<string, unknown>()
935
-
936
- // Get list of dependency job names
937
- const dependencyNames = job.depends_on || []
938
-
939
- for (const [key, value] of outputs) {
940
- // Check if this output key starts with any dependency job name prefix
941
- const isDependencyOutput = dependencyNames.some(depName => key.startsWith(`${depName}.`))
942
-
943
- // Only include outputs that are NOT from dependencies
944
- if (!isDependencyOutput) {
945
- filtered.set(key, value)
946
- }
947
- }
948
-
949
- return Object.fromEntries(filtered)
950
- }
951
-
952
- /**
953
- * Groups network results by status and outputs.
954
- * - Success states with identical outputs are grouped together with chainIds array
955
- * - Error states are kept separate (one entry per network)
956
- */
957
- private groupNetworkResults(outputs: Map<number, { status: 'success' | 'error' | 'skipped'; data: Map<string, unknown> | string }>, job: Job): Array<{
958
- status: 'success' | 'error' | 'skipped';
959
- chainIds?: string[];
960
- chainId?: string;
961
- outputs?: Record<string, unknown>;
962
- error?: string;
963
- }> {
964
- const successGroups = new Map<string, { chainIds: string[], outputs: Record<string, unknown> }>()
965
- const errorEntries: Array<{
966
- status: 'error';
967
- chainId: string;
968
- error: string;
969
- }> = []
970
-
971
- for (const [chainId, result] of outputs.entries()) {
972
- if (result.status === 'success') {
973
- // Group successful results by identical outputs, filtered by action output flags
974
- const outputsObj = result.data instanceof Map ? this.filterOutputsByActionFlags(result.data, job) : {}
975
- const key = JSON.stringify(outputsObj)
976
-
977
- if (!successGroups.has(key)) {
978
- successGroups.set(key, {
979
- chainIds: [],
980
- outputs: outputsObj
981
- })
982
- }
983
-
984
- successGroups.get(key)!.chainIds.push(chainId.toString())
985
- } else {
986
- // Keep error results separate - one entry per network
987
- errorEntries.push({
988
- status: 'error',
989
- chainId: chainId.toString(),
990
- error: result.data as string
991
- })
992
- }
993
- }
994
-
995
- // Convert success groups to array format
996
- const successEntries = Array.from(successGroups.values()).map(group => ({
997
- status: 'success' as const,
998
- chainIds: group.chainIds.sort(), // Sort for consistent output
999
- outputs: group.outputs
1000
- }))
1001
-
1002
- // Return all entries: successes first, then errors
1003
- return [...successEntries, ...errorEntries]
1004
- }
1005
- }