@0xsequence/catapult 1.2.1 → 1.3.2

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 (67) hide show
  1. package/README.md +42 -14
  2. package/dist/commands/dry.d.ts.map +1 -1
  3. package/dist/commands/dry.js +3 -2
  4. package/dist/commands/dry.js.map +1 -1
  5. package/dist/commands/etherscan.d.ts.map +1 -1
  6. package/dist/commands/etherscan.js +24 -36
  7. package/dist/commands/etherscan.js.map +1 -1
  8. package/dist/commands/run.d.ts.map +1 -1
  9. package/dist/commands/run.js +4 -2
  10. package/dist/commands/run.js.map +1 -1
  11. package/dist/commands/utils.d.ts.map +1 -1
  12. package/dist/commands/utils.js +191 -0
  13. package/dist/commands/utils.js.map +1 -1
  14. package/dist/lib/__tests__/deployer.spec.js +1 -1
  15. package/dist/lib/__tests__/deployer.spec.js.map +1 -1
  16. package/dist/lib/__tests__/network-loader.spec.js +3 -2
  17. package/dist/lib/__tests__/network-loader.spec.js.map +1 -1
  18. package/dist/lib/__tests__/network-selection.spec.d.ts +2 -0
  19. package/dist/lib/__tests__/network-selection.spec.d.ts.map +1 -0
  20. package/dist/lib/__tests__/network-selection.spec.js +34 -0
  21. package/dist/lib/__tests__/network-selection.spec.js.map +1 -0
  22. package/dist/lib/core/__tests__/engine.spec.js +26 -2
  23. package/dist/lib/core/__tests__/engine.spec.js.map +1 -1
  24. package/dist/lib/core/__tests__/json-integration.spec.js +1 -1
  25. package/dist/lib/core/__tests__/json-integration.spec.js.map +1 -1
  26. package/dist/lib/core/__tests__/multi-platform-verification.spec.js +1 -1
  27. package/dist/lib/core/__tests__/multi-platform-verification.spec.js.map +1 -1
  28. package/dist/lib/core/__tests__/static-action.spec.js +1 -1
  29. package/dist/lib/core/__tests__/static-action.spec.js.map +1 -1
  30. package/dist/lib/core/engine.d.ts +9 -1
  31. package/dist/lib/core/engine.d.ts.map +1 -1
  32. package/dist/lib/core/engine.js +17 -4
  33. package/dist/lib/core/engine.js.map +1 -1
  34. package/dist/lib/deployer.d.ts.map +1 -1
  35. package/dist/lib/deployer.js +56 -2
  36. package/dist/lib/deployer.js.map +1 -1
  37. package/dist/lib/events/cli-adapter.d.ts.map +1 -1
  38. package/dist/lib/events/cli-adapter.js +16 -3
  39. package/dist/lib/events/cli-adapter.js.map +1 -1
  40. package/dist/lib/events/types.d.ts +18 -1
  41. package/dist/lib/events/types.d.ts.map +1 -1
  42. package/dist/lib/network-loader.d.ts.map +1 -1
  43. package/dist/lib/network-loader.js +1 -1
  44. package/dist/lib/network-loader.js.map +1 -1
  45. package/dist/lib/network-selection.d.ts +4 -0
  46. package/dist/lib/network-selection.d.ts.map +1 -0
  47. package/dist/lib/network-selection.js +49 -0
  48. package/dist/lib/network-selection.js.map +1 -0
  49. package/package.json +1 -1
  50. package/src/commands/dry.ts +4 -3
  51. package/src/commands/etherscan.ts +42 -42
  52. package/src/commands/run.ts +5 -3
  53. package/src/commands/utils.ts +163 -0
  54. package/src/lib/__tests__/deployer.spec.ts +1 -3
  55. package/src/lib/__tests__/network-loader.spec.ts +3 -2
  56. package/src/lib/__tests__/network-selection.spec.ts +41 -0
  57. package/src/lib/core/__tests__/engine.spec.ts +33 -2
  58. package/src/lib/core/__tests__/json-integration.spec.ts +1 -1
  59. package/src/lib/core/__tests__/multi-platform-verification.spec.ts +1 -1
  60. package/src/lib/core/__tests__/static-action.spec.ts +1 -1
  61. package/src/lib/core/engine.ts +26 -4
  62. package/src/lib/deployer.ts +68 -3
  63. package/src/lib/events/cli-adapter.ts +21 -5
  64. package/src/lib/events/types.ts +23 -0
  65. package/src/lib/network-loader.ts +2 -1
  66. package/src/lib/network-selection.ts +73 -0
  67. package/CONCEPT.md +0 -24
@@ -1,5 +1,7 @@
1
1
  import { Command } from 'commander'
2
2
  import chalk from 'chalk'
3
+ import * as fs from 'fs'
4
+ import * as path from 'path'
3
5
  import { projectOption, verbosityOption } from './common'
4
6
  import { loadNetworks } from '../lib/network-loader'
5
7
  import { setVerbosity } from '../index'
@@ -48,5 +50,166 @@ export function makeUtilsCommand(): Command {
48
50
 
49
51
  utils.addCommand(chainIdToName)
50
52
 
53
+ // utils gen-table <output-dir>
54
+ const genTable = new Command('gen-table')
55
+ .description('Generate a consolidated addresses table from an output directory')
56
+ .argument('<output-dir>', 'Directory containing job output JSON files (searches recursively)')
57
+ .option('--name', 'Include Name column', true)
58
+ .option('--key', 'Include Key column', false)
59
+ .option('--file', 'Include File column', false)
60
+ .option('--chain-ids, --chainIds', 'Include ChainIds column', false)
61
+ .option('--job', 'Include Job column', true)
62
+ .option('--address', 'Include Address column', true)
63
+ .option('--format <format>', "Output format: 'markdown' or 'ascii' (default)", 'ascii')
64
+ .action(async (outputDir: string, options: { name?: boolean; key?: boolean; file?: boolean; chainIds?: boolean; job?: boolean; address?: boolean; format?: string }) => {
65
+ try {
66
+ const absoluteDir = path.resolve(outputDir)
67
+ if (!fs.existsSync(absoluteDir) || !fs.statSync(absoluteDir).isDirectory()) {
68
+ console.error(chalk.red(`Output directory not found or not a directory: ${absoluteDir}`))
69
+ process.exit(1)
70
+ }
71
+
72
+ const jsonFiles: string[] = []
73
+ const walk = (dir: string) => {
74
+ for (const entry of fs.readdirSync(dir)) {
75
+ const full = path.join(dir, entry)
76
+ const stat = fs.statSync(full)
77
+ if (stat.isDirectory()) walk(full)
78
+ else if (stat.isFile() && entry.toLowerCase().endsWith('.json')) jsonFiles.push(full)
79
+ }
80
+ }
81
+ walk(absoluteDir)
82
+
83
+ type Row = { job: string; chainIds: string; name: string; address: string; key: string; file: string }
84
+ const rows: Row[] = []
85
+ const addressRegex = /^0x[a-fA-F0-9]{40}$/
86
+
87
+ const toTitleCase = (slug: string): string => slug.split(/[-_\s]+/).filter(Boolean).map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('')
88
+ const extractVersionSuffix = (jobName: string): string => {
89
+ const m = jobName.match(/[-_]?v(\d+)/i)
90
+ return m ? `V${m[1]}` : ''
91
+ }
92
+ const deriveName = (jobName: string, key: string): string => {
93
+ const version = extractVersionSuffix(jobName)
94
+ const baseJob = jobName.replace(/[-_]?v\d+$/i, '')
95
+ const keyCore = key.replace(/\.address$/i, '')
96
+ // Prefer descriptive key name; if too generic like 'factory', prefix with job base
97
+ const isGeneric = /^(factory|address)$/i.test(keyCore)
98
+ const nameCore = isGeneric ? `${toTitleCase(baseJob)} ${toTitleCase(keyCore)}` : toTitleCase(keyCore)
99
+ return `${nameCore.replace(/\s+/g, '')}${version}`
100
+ }
101
+
102
+ for (const file of jsonFiles) {
103
+ try {
104
+ const raw = fs.readFileSync(file, 'utf8')
105
+ const data = JSON.parse(raw)
106
+ if (!data || typeof data !== 'object' || !Array.isArray(data.networks)) continue
107
+ const jobName: string = data.jobName ?? path.basename(file, '.json')
108
+ for (const net of data.networks) {
109
+ if (!net || typeof net !== 'object') continue
110
+ const outputs = net.outputs as Record<string, unknown> | undefined
111
+ if (!outputs) continue
112
+ const chainIds: string[] = Array.isArray(net.chainIds) ? net.chainIds : []
113
+ for (const [key, value] of Object.entries(outputs)) {
114
+ let address: string | undefined
115
+ if (typeof value === 'string' && addressRegex.test(value)) {
116
+ address = value
117
+ } else if (value && typeof value === 'object' && 'address' in value && typeof value.address === 'string' && addressRegex.test(value.address)) {
118
+ address = value.address
119
+ }
120
+ if (!address) continue
121
+ rows.push({
122
+ job: jobName,
123
+ chainIds: chainIds.join(','),
124
+ name: deriveName(jobName, key),
125
+ address,
126
+ key,
127
+ file
128
+ })
129
+ }
130
+ }
131
+ } catch {
132
+ // skip invalid JSON
133
+ }
134
+ }
135
+
136
+ rows.sort((a, b) => a.job.localeCompare(b.job) || a.name.localeCompare(b.name))
137
+
138
+ if (rows.length === 0) {
139
+ console.log(chalk.yellow('No address entries found.'))
140
+ return
141
+ }
142
+
143
+ // Determine which columns to show
144
+ const showJob = !!options.job
145
+ const showAddress = !!options.address
146
+ const showName = !!options.name
147
+ const showKey = !!options.key
148
+ const showChainIds = !!options.chainIds
149
+ const showFile = !!options.file
150
+
151
+ const selectedHeaders: (keyof Row)[] = []
152
+ if (showJob) selectedHeaders.push('job')
153
+ if (showChainIds) selectedHeaders.push('chainIds')
154
+ if (showName) selectedHeaders.push('name')
155
+ if (showAddress) selectedHeaders.push('address')
156
+ if (showKey) selectedHeaders.push('key')
157
+ if (showFile) selectedHeaders.push('file')
158
+
159
+ // Titles for columns
160
+ const titles: Record<keyof Row, string> = {
161
+ job: 'Job',
162
+ chainIds: 'ChainIds',
163
+ name: 'Name',
164
+ address: 'Address',
165
+ key: 'Key',
166
+ file: 'File'
167
+ }
168
+ const format = String(options.format || 'markdown').toLowerCase()
169
+ if (format !== 'markdown' && format !== 'ascii') {
170
+ console.error(chalk.red("Invalid format. Use 'markdown' or 'ascii'."))
171
+ process.exit(1)
172
+ }
173
+
174
+ if (format === 'markdown') {
175
+ const header = '| ' + selectedHeaders.map(h => titles[h]).join(' | ') + ' |'
176
+ const sepMd = '| ' + selectedHeaders.map(h => '-'.repeat(Math.max(3, String(titles[h]).length))).join(' | ') + ' |'
177
+ console.log(header)
178
+ console.log(sepMd)
179
+ for (const r of rows) {
180
+ console.log('| ' + selectedHeaders.map(h => String(r[h])).join(' | ') + ' |')
181
+ }
182
+ } else {
183
+ // ascii rendering with box-drawing characters
184
+ const widths: Record<string, number> = {}
185
+ for (const h of selectedHeaders) {
186
+ widths[h] = Math.max(titles[h].length, ...rows.map(r => String(r[h]).length))
187
+ }
188
+ const makeSep = (left: string, mid: string, right: string, fill: string) => {
189
+ return left + selectedHeaders.map(h => fill.repeat(widths[h] + 2)).join(mid) + right
190
+ }
191
+ const pad = (s: string, w: number) => s + ' '.repeat(Math.max(0, w - s.length))
192
+
193
+ const top = makeSep('ā”Œ', '┬', '┐', '─')
194
+ const sep = makeSep('ā”œ', '┼', '┤', '─')
195
+ const bot = makeSep('ā””', '┓', 'ā”˜', '─')
196
+ const headerLine = '│' + selectedHeaders.map(h => ' ' + pad(titles[h], widths[h]) + ' ').join('│') + '│'
197
+ const lines = rows.map(r => '│' + selectedHeaders.map(h => ' ' + pad(String(r[h]), widths[h]) + ' ').join('│') + '│')
198
+
199
+ console.log(top)
200
+ console.log(headerLine)
201
+ console.log(sep)
202
+ for (const line of lines) console.log(line)
203
+ console.log(bot)
204
+ }
205
+
206
+ } catch (error) {
207
+ console.error(chalk.red('Error generating table:'), error instanceof Error ? error.message : String(error))
208
+ process.exit(1)
209
+ }
210
+ })
211
+
212
+ utils.addCommand(genTable)
213
+
51
214
  return utils
52
215
  }
@@ -1,6 +1,4 @@
1
1
  import * as fs from 'fs/promises'
2
- import * as path from 'path'
3
- import chalk from 'chalk'
4
2
  import { Deployer, DeployerOptions } from '../deployer'
5
3
  import { ProjectLoader } from '../core/loader'
6
4
  import { DependencyGraph } from '../core/graph'
@@ -174,7 +172,7 @@ describe('Deployer', () => {
174
172
  expect(mockLoader.load).toHaveBeenCalledTimes(1)
175
173
  expect(MockDependencyGraph).toHaveBeenCalledWith(mockLoader.jobs, mockLoader.templates)
176
174
  expect(mockGraph.getExecutionOrder).toHaveBeenCalledTimes(1)
177
- expect(MockExecutionEngine).toHaveBeenCalledWith(mockLoader.templates, expect.any(Object), expect.any(Object), false)
175
+ expect(MockExecutionEngine).toHaveBeenCalledWith(mockLoader.templates, expect.any(Object))
178
176
  expect(mockEngine.executeJob).toHaveBeenCalledTimes(5) // job1&job2 on 2 networks + job3 on 1 network
179
177
  expect(MockExecutionContext).toHaveBeenCalledTimes(5)
180
178
  expect(mockFs.mkdir).toHaveBeenCalledWith('/test/project/output', { recursive: true })
@@ -84,7 +84,7 @@ describe('network-loader rpcUrl token replacement', () => {
84
84
  expect(networks[0].rpcUrl).toBe('https://node.example.com/XYZ')
85
85
  })
86
86
 
87
- test('throws error when RPC token has no matching env var', async () => {
87
+ test('defaults to empty string when RPC token has no matching env var', async () => {
88
88
  const projectRoot = path.join(tmpDir, 'case5')
89
89
  delete process.env.RPC_MISSING
90
90
  const yaml = `
@@ -94,6 +94,7 @@ describe('network-loader rpcUrl token replacement', () => {
94
94
  `
95
95
  await writeNetworksYaml(projectRoot, yaml)
96
96
 
97
- await expect(loadNetworks(projectRoot)).rejects.toThrow('Environment variable RPC_MISSING is not set')
97
+ const networks = await loadNetworks(projectRoot)
98
+ expect(networks[0].rpcUrl).toBe('https://node.example.com/')
98
99
  })
99
100
  })
@@ -0,0 +1,41 @@
1
+ import { resolveSelectedChainIds, resolveSingleChainId } from '../../lib/network-selection'
2
+ import { Network } from '../../lib/types'
3
+
4
+ describe('network-selection', () => {
5
+ const networks: Network[] = [
6
+ { name: 'Mainnet', chainId: 1, rpcUrl: 'https://mainnet' },
7
+ { name: 'Arbitrum One', chainId: 42161, rpcUrl: 'https://arb' },
8
+ { name: 'Polygon', chainId: 137, rpcUrl: 'https://polygon' },
9
+ { name: 'Mainnet', chainId: 10_000, rpcUrl: 'https://fork' },
10
+ ]
11
+
12
+ it('returns undefined for empty/undefined input', () => {
13
+ expect(resolveSelectedChainIds(undefined, networks)).toBeUndefined()
14
+ expect(resolveSelectedChainIds('', networks)).toBeUndefined()
15
+ expect(resolveSelectedChainIds(' ', networks)).toBeUndefined()
16
+ })
17
+
18
+ it('parses numeric IDs and removes duplicates', () => {
19
+ expect(resolveSelectedChainIds('1,1,42161', networks)).toEqual([1, 42161])
20
+ })
21
+
22
+ it('matches names case-insensitively and includes all with same name', () => {
23
+ expect(resolveSelectedChainIds('mainnet', networks)).toEqual([1, 10000])
24
+ expect(resolveSelectedChainIds('MAINNET,polygon', networks)).toEqual([1, 10000, 137])
25
+ })
26
+
27
+ it('preserves token order and then network order for name matches', () => {
28
+ expect(resolveSelectedChainIds('polygon,mainnet', networks)).toEqual([137, 1, 10000])
29
+ })
30
+
31
+ it('throws on unknown name', () => {
32
+ expect(() => resolveSelectedChainIds('unknown', networks)).toThrow(/Unknown network selector/)
33
+ })
34
+
35
+ it('resolveSingleChainId returns first match for name and first token for multi', () => {
36
+ expect(resolveSingleChainId('mainnet', networks)).toBe(1)
37
+ expect(resolveSingleChainId('137,1', networks)).toBe(137)
38
+ })
39
+ })
40
+
41
+
@@ -26,7 +26,9 @@ const TEST_BYTECODES = {
26
26
  // Mini contract with multiplication function
27
27
  MINI_CONTRACT: '0x608060405234801561000f575f5ffd5b5060043610610034575f3560e01c80636df5b97a14610038578063f8a8fd6d14610068575b5f5ffd5b610052600480360381019061004d91906100da565b610086565b60405161005f9190610127565b60405180910390f35b61007061009b565b60405161007d9190610127565b60405180910390f35b5f8183610093919061016d565b905092915050565b5f602a905090565b5f5ffd5b5f819050919050565b6100b9816100a7565b81146100c3575f5ffd5b50565b5f813590506100d4816100b0565b92915050565b5f5f604083850312156100f0576100ef6100a3565b5b5f6100fd858286016100c6565b925050602061010e858286016100c6565b9150509250929050565b610121816100a7565b82525050565b5f60208201905061013a5f830184610118565b92915050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f610177826100a7565b9150610182836100a7565b9250828202610190816100a7565b915082820484148315176101a7576101a6610140565b5b509291505056fea264697066735822122071d40daa3d2beacd91f29d29ccf1c0b6f312e805f50b37166267c0a2a55e6e6164736f6c634300081c0033',
28
28
  // Minimal deployment bytecode
29
- MINIMAL_DEPLOY: '0x608060405234801561000f575f5ffd5b50603e80601c5f395ff3fe60806040525f80fdfea264697066735822122071d40daa3d2beacd91f29d29ccf1c0b6f312e805f50b37166267c0a2a55e6e6164736f6c634300081c0033'
29
+ MINIMAL_DEPLOY: '0x608060405234801561000f575f5ffd5b50603e80601c5f395ff3fe60806040525f80fdfea264697066735822122071d40daa3d2beacd91f29d29ccf1c0b6f312e805f50b37166267c0a2a55e6e6164736f6c634300081c0033',
30
+ // Broken bytecode
31
+ BROKEN_BYTECODE: '0xff',
30
32
  } as const
31
33
 
32
34
  const TEST_VALUES = {
@@ -68,7 +70,7 @@ describe('ExecutionEngine', () => {
68
70
 
69
71
  // Create empty verification registry for tests
70
72
  const verificationRegistry = new VerificationPlatformRegistry()
71
- engine = new ExecutionEngine(templates, undefined, verificationRegistry)
73
+ engine = new ExecutionEngine(templates, { verificationRegistry })
72
74
  })
73
75
 
74
76
  afterEach(async () => {
@@ -1480,6 +1482,35 @@ describe('ExecutionEngine', () => {
1480
1482
  expect(context.getOutput('nick-test.success')).toBe(true)
1481
1483
  })
1482
1484
 
1485
+ it('should prevent failing Nick\'s method from being tested twice', async () => {
1486
+ const action: Action = {
1487
+ type: 'test-nicks-method',
1488
+ name: 'nick-test-fail-twice',
1489
+ arguments: {
1490
+ bytecode: TEST_BYTECODES.BROKEN_BYTECODE, // Will break
1491
+ }
1492
+ }
1493
+
1494
+ await expect((engine as any).executeAction(action, context, new Map())).rejects.toThrow(new Error(`Nick's method test failed for action "nick-test-fail-twice"`))
1495
+ await expect((engine as any).executeAction(action, context, new Map())).rejects.toThrow(new Error('Nick\'s method test already performed this run'))
1496
+ })
1497
+
1498
+ it('should prevent passing Nick\'s method from being tested twice', async () => {
1499
+ const action: Action = {
1500
+ type: 'test-nicks-method',
1501
+ name: 'nick-test-pass-twice',
1502
+ arguments: {
1503
+ bytecode: TEST_BYTECODES.SIMPLE_RETURN_42, // Will pass
1504
+ }
1505
+ }
1506
+
1507
+ await (engine as any).executeAction(action, context, new Map())
1508
+ await (engine as any).executeAction(action, context, new Map())
1509
+
1510
+ // Should have success output
1511
+ expect(context.getOutput('nick-test-pass-twice.success')).toBe(true)
1512
+ })
1513
+
1483
1514
  it('should handle missing optional parameters', async () => {
1484
1515
  const action: Action = {
1485
1516
  type: 'test-nicks-method',
@@ -31,7 +31,7 @@ describe('JSON Integration Tests', () => {
31
31
 
32
32
  templates = new Map()
33
33
  const verificationRegistry = new VerificationPlatformRegistry()
34
- engine = new ExecutionEngine(templates, undefined, verificationRegistry)
34
+ engine = new ExecutionEngine(templates, { verificationRegistry })
35
35
  resolver = new ValueResolver()
36
36
  })
37
37
 
@@ -68,7 +68,7 @@ describe('Multi-Platform Verification Integration', () => {
68
68
  verificationRegistry.register(mockSourcifyPlatform)
69
69
 
70
70
  templates = new Map()
71
- engine = new ExecutionEngine(templates, undefined, verificationRegistry)
71
+ engine = new ExecutionEngine(templates, { verificationRegistry })
72
72
 
73
73
  // Set up mock contract and build info
74
74
  const mockContract = {
@@ -27,7 +27,7 @@ describe('Static Action', () => {
27
27
 
28
28
  templates = new Map()
29
29
  const verificationRegistry = new VerificationPlatformRegistry()
30
- engine = new ExecutionEngine(templates, undefined, verificationRegistry)
30
+ engine = new ExecutionEngine(templates, { verificationRegistry })
31
31
  })
32
32
 
33
33
  describe('static primitive action', () => {
@@ -8,6 +8,13 @@ import { createDefaultVerificationRegistry, VerificationPlatformRegistry } from
8
8
  import { BuildInfo } from '../types/buildinfo'
9
9
  import { ethers } from 'ethers'
10
10
 
11
+ export type EngineOptions = {
12
+ eventEmitter?: DeploymentEventEmitter
13
+ verificationRegistry?: VerificationPlatformRegistry
14
+ noPostCheckConditions?: boolean
15
+ allowMultipleNicksMethodTests?: boolean
16
+ }
17
+
11
18
  /**
12
19
  * The ExecutionEngine is the core component that runs jobs and their actions.
13
20
  * It interprets the declarative YAML files, resolves values, interacts with the
@@ -19,13 +26,16 @@ export class ExecutionEngine {
19
26
  private readonly events: DeploymentEventEmitter
20
27
  private readonly verificationRegistry: VerificationPlatformRegistry
21
28
  private readonly noPostCheckConditions: boolean
29
+ private readonly allowMultipleNicksMethodTests: boolean
30
+ private nicksMethodTested: boolean = false
22
31
 
23
- constructor(templates: Map<string, Template>, eventEmitter?: DeploymentEventEmitter, verificationRegistry?: VerificationPlatformRegistry, noPostCheckConditions?: boolean) {
32
+ constructor(templates: Map<string, Template>, options?: EngineOptions) {
24
33
  this.resolver = new ValueResolver()
25
34
  this.templates = templates
26
- this.events = eventEmitter || deploymentEvents
27
- this.verificationRegistry = verificationRegistry || createDefaultVerificationRegistry()
28
- this.noPostCheckConditions = noPostCheckConditions ?? false
35
+ this.events = options?.eventEmitter || deploymentEvents
36
+ this.verificationRegistry = options?.verificationRegistry || createDefaultVerificationRegistry()
37
+ this.noPostCheckConditions = options?.noPostCheckConditions ?? false
38
+ this.allowMultipleNicksMethodTests = options?.allowMultipleNicksMethodTests ?? false
29
39
  }
30
40
 
31
41
  /**
@@ -781,6 +791,18 @@ export class ExecutionEngine {
781
791
  break
782
792
  }
783
793
  case 'test-nicks-method': {
794
+ if (this.nicksMethodTested && !this.allowMultipleNicksMethodTests) {
795
+ try {
796
+ if (context.getOutput(`${action.name}.success`) === true) {
797
+ // Return previous result
798
+ break
799
+ }
800
+ } catch (e) {
801
+ throw new Error(`Nick's method test already performed this run`)
802
+ }
803
+ }
804
+ this.nicksMethodTested = true
805
+
784
806
  // Default bytecode if none provided
785
807
  const defaultBytecode = '0x608060405234801561001057600080fd5b5061013d806100206000396000f3fe60806040526004361061001e5760003560e01c80639c4ae2d014610023575b600080fd5b6100cb6004803603604081101561003957600080fd5b81019060208101813564010000000081111561005457600080fd5b82018360208201111561006657600080fd5b8035906020019184600183028401116401000000008311171561008857600080fd5b91908080601f01602080910402602001604051908101604052809392919081815260200183838082843760009201919091525092955050913592506100cd915050565b005b60008183516020850134f56040805173ffffffffffffffffffffffffffffffffffffffff83168152905191925081900360200190a050505056fea264697066735822122033609f614f03931b92d88c309d698449bb77efcd517328d341fa4f923c5d8c7964736f6c63430007060033'
786
808
 
@@ -154,10 +154,16 @@ export class Deployer {
154
154
 
155
155
  // 4. Execute the plan.
156
156
  const verificationRegistry = createDefaultVerificationRegistry(this.options.etherscanApiKey)
157
- const engine = new ExecutionEngine(this.loader.templates, this.events, verificationRegistry, this.noPostCheckConditions)
157
+ const engine = new ExecutionEngine(this.loader.templates, {
158
+ eventEmitter: this.events,
159
+ verificationRegistry,
160
+ noPostCheckConditions: this.noPostCheckConditions
161
+ })
158
162
 
159
163
  // Track if any jobs have failed
160
164
  let hasFailures = false
165
+ // Emit signer info once per network (chainId)
166
+ const signerInfoPrintedForChain = new Set<number>()
161
167
 
162
168
  for (const network of targetNetworks) {
163
169
  this.events.emitEvent({
@@ -168,7 +174,6 @@ export class Deployer {
168
174
  chainId: network.chainId
169
175
  }
170
176
  })
171
-
172
177
  for (const jobName of jobsToRun) {
173
178
  const job = this.loader.jobs.get(jobName)!
174
179
 
@@ -204,6 +209,46 @@ export class Deployer {
204
209
  (context as unknown as { setJobConstants: (constants: unknown) => void }).setJobConstants(job.constants)
205
210
  }
206
211
 
212
+ // Emit signer info once per network using the first job's context
213
+ if (!signerInfoPrintedForChain.has(network.chainId)) {
214
+ try {
215
+ const getSignerFn = (context as unknown as {
216
+ getResolvedSigner?: () => Promise<{ getAddress: () => Promise<string> }>
217
+ signer?: { getAddress: () => Promise<string> }
218
+ }).getResolvedSigner
219
+ const signer = getSignerFn
220
+ ? await getSignerFn.call(context)
221
+ : (context as unknown as { signer?: { getAddress: () => Promise<string> } }).signer
222
+ if (signer && typeof signer.getAddress === 'function') {
223
+ const address = await signer.getAddress()
224
+ // provider may not exist on mocked contexts; guard for it
225
+ const provider = (context as unknown as {
226
+ provider?: { getBalance: (addr: string) => Promise<bigint | number | { toString: () => string }> }
227
+ }).provider
228
+ if (provider && typeof provider.getBalance === 'function') {
229
+ const balanceBn = await provider.getBalance(address)
230
+ const balanceWei = balanceBn.toString()
231
+ const balanceEth = (Number(balanceBn) / 1e18).toString()
232
+ this.events.emitEvent({
233
+ type: 'network_signer_info',
234
+ level: 'info',
235
+ data: {
236
+ networkName: network.name,
237
+ chainId: network.chainId,
238
+ address,
239
+ balanceWei,
240
+ balance: balanceEth
241
+ }
242
+ })
243
+ }
244
+ }
245
+ } catch {
246
+ // ignore non-fatal signer info errors
247
+ } finally {
248
+ signerInfoPrintedForChain.add(network.chainId)
249
+ }
250
+ }
251
+
207
252
  // Populate context with outputs from previously executed dependent jobs
208
253
  this.populateContextWithDependentJobOutputs(job, context, network)
209
254
 
@@ -275,12 +320,32 @@ export class Deployer {
275
320
  // Check if any jobs failed and exit with error if so
276
321
  if (hasFailures) {
277
322
  const error = new Error('One or more jobs failed during execution')
323
+
324
+ // Build a flat list of failed jobs with network context and error messages
325
+ const failedJobs: Array<{ jobName: string; networkName: string; chainId: number; error: string }> = []
326
+ for (const [, result] of this.results) {
327
+ const job = result.job
328
+ for (const [chainId, netResult] of result.outputs) {
329
+ if (netResult.status === 'error') {
330
+ // Resolve network name from configured networks (fallback to chainId if missing)
331
+ const network = this.options.networks.find(n => n.chainId === chainId)
332
+ failedJobs.push({
333
+ jobName: job.name,
334
+ networkName: network?.name || `chain-${chainId}`,
335
+ chainId,
336
+ error: String(netResult.data)
337
+ })
338
+ }
339
+ }
340
+ }
341
+
278
342
  this.events.emitEvent({
279
343
  type: 'deployment_failed',
280
344
  level: 'error',
281
345
  data: {
282
346
  error: error.message,
283
- stack: error.stack
347
+ stack: error.stack,
348
+ failedJobs
284
349
  }
285
350
  })
286
351
  throw error
@@ -48,7 +48,7 @@ export class CLIEventAdapter {
48
48
  const level0Events = new Set([
49
49
  'deployment_started', 'deployment_completed', 'deployment_failed',
50
50
  'job_started', 'job_completed', 'job_skipped', 'job_execution_failed',
51
- 'network_started',
51
+ 'network_started', 'network_signer_info',
52
52
  'duplicate_artifact_warning', 'missing_network_config_warning',
53
53
  'unhandled_rejection', 'uncaught_exception', 'cli_error',
54
54
  'verification_failed'
@@ -115,6 +115,11 @@ export class CLIEventAdapter {
115
115
  console.log(chalk.cyan.bold(`\nNetwork: ${event.data.networkName} (ChainID: ${event.data.chainId})`))
116
116
  break
117
117
 
118
+ case 'network_signer_info':
119
+ console.log(chalk.gray(` Sender: ${event.data.address}`))
120
+ console.log(chalk.gray(` Balance: ${event.data.balance} ETH (${event.data.balanceWei} wei)`))
121
+ break
122
+
118
123
  case 'job_started':
119
124
  console.log(chalk.cyan.bold(`\nšŸš€ Starting job: ${event.data.jobName} (v${event.data.jobVersion})`))
120
125
  break
@@ -197,10 +202,21 @@ export class CLIEventAdapter {
197
202
 
198
203
  case 'deployment_failed':
199
204
  console.error(chalk.red.bold('\nšŸ’„ DEPLOYMENT FAILED!'))
200
- if (event.data.stack) {
201
- console.error(chalk.red(event.data.stack))
202
- } else {
203
- console.error(chalk.red(event.data.error))
205
+ // Show concise failed jobs table if present
206
+ const failedJobs = (event as any).data?.failedJobs as Array<{ jobName: string; networkName: string; chainId: number; error: string }> | undefined
207
+ if (Array.isArray(failedJobs) && failedJobs.length > 0) {
208
+ console.error(chalk.red(' āœ— Failed jobs:'))
209
+ for (const f of failedJobs) {
210
+ const where = `${f.networkName} (ChainID: ${f.chainId})`
211
+ console.error(chalk.red(` - ${f.jobName} on ${where}`))
212
+ console.error(chalk.red(` Error: ${f.error}`))
213
+ }
214
+ }
215
+ // Always print the top-level error and stack last
216
+ if ((event as any).data?.stack) {
217
+ console.error(chalk.red((event as any).data.stack))
218
+ } else if ((event as any).data?.error) {
219
+ console.error(chalk.red((event as any).data.error))
204
220
  }
205
221
  break
206
222
 
@@ -29,6 +29,12 @@ export interface DeploymentFailedEvent extends BaseEvent {
29
29
  data: {
30
30
  error: string
31
31
  stack?: string
32
+ failedJobs?: Array<{
33
+ jobName: string
34
+ networkName: string
35
+ chainId: number
36
+ error: string
37
+ }>
32
38
  }
33
39
  }
34
40
 
@@ -312,6 +318,22 @@ export interface NetworkStartedEvent extends BaseEvent {
312
318
  }
313
319
  }
314
320
 
321
+ /**
322
+ * Emitted right after a network run starts to inform which address will be used
323
+ * to send transactions and its current balance.
324
+ */
325
+ export interface NetworkSignerInfoEvent extends BaseEvent {
326
+ type: 'network_signer_info'
327
+ level: 'info'
328
+ data: {
329
+ networkName: string
330
+ chainId: number
331
+ address: string
332
+ balanceWei: string
333
+ balance: string // formatted in ETH
334
+ }
335
+ }
336
+
315
337
  // Process error events
316
338
  export interface UnhandledRejectionEvent extends BaseEvent {
317
339
  type: 'unhandled_rejection'
@@ -457,6 +479,7 @@ export type DeploymentEvent =
457
479
  | ContextDisposalWarningEvent
458
480
  | DeprecatedJobsSkippedEvent
459
481
  | NetworkStartedEvent
482
+ | NetworkSignerInfoEvent
460
483
  | UnhandledRejectionEvent
461
484
  | UncaughtExceptionEvent
462
485
  | CLIErrorEvent
@@ -37,7 +37,8 @@ function resolveRpcUrlTokens(rpcUrl: string): string {
37
37
  }
38
38
  const value = process.env[varName]
39
39
  if (typeof value === 'undefined') {
40
- throw new Error(`Environment variable ${varName} is not set but is required for rpcUrl token replacement`)
40
+ // Default to empty string if missing
41
+ return ''
41
42
  }
42
43
  return value
43
44
  })