@0xsequence/catapult 1.2.0 → 1.3.1

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 (46) hide show
  1. package/README.md +10 -3
  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__/network-loader.spec.js +3 -2
  15. package/dist/lib/__tests__/network-loader.spec.js.map +1 -1
  16. package/dist/lib/__tests__/network-selection.spec.d.ts +2 -0
  17. package/dist/lib/__tests__/network-selection.spec.d.ts.map +1 -0
  18. package/dist/lib/__tests__/network-selection.spec.js +34 -0
  19. package/dist/lib/__tests__/network-selection.spec.js.map +1 -0
  20. package/dist/lib/deployer.d.ts.map +1 -1
  21. package/dist/lib/deployer.js +62 -5
  22. package/dist/lib/deployer.js.map +1 -1
  23. package/dist/lib/events/cli-adapter.d.ts.map +1 -1
  24. package/dist/lib/events/cli-adapter.js +16 -3
  25. package/dist/lib/events/cli-adapter.js.map +1 -1
  26. package/dist/lib/events/types.d.ts +18 -1
  27. package/dist/lib/events/types.d.ts.map +1 -1
  28. package/dist/lib/network-loader.d.ts.map +1 -1
  29. package/dist/lib/network-loader.js +1 -1
  30. package/dist/lib/network-loader.js.map +1 -1
  31. package/dist/lib/network-selection.d.ts +4 -0
  32. package/dist/lib/network-selection.d.ts.map +1 -0
  33. package/dist/lib/network-selection.js +49 -0
  34. package/dist/lib/network-selection.js.map +1 -0
  35. package/package.json +1 -1
  36. package/src/commands/dry.ts +4 -3
  37. package/src/commands/etherscan.ts +42 -42
  38. package/src/commands/run.ts +5 -3
  39. package/src/commands/utils.ts +163 -0
  40. package/src/lib/__tests__/network-loader.spec.ts +3 -2
  41. package/src/lib/__tests__/network-selection.spec.ts +41 -0
  42. package/src/lib/deployer.ts +77 -9
  43. package/src/lib/events/cli-adapter.ts +21 -5
  44. package/src/lib/events/types.ts +23 -0
  45. package/src/lib/network-loader.ts +2 -1
  46. package/src/lib/network-selection.ts +73 -0
@@ -1,6 +1,7 @@
1
1
  import { Command } from 'commander'
2
2
  import chalk from 'chalk'
3
3
  import { loadNetworks } from '../lib/network-loader'
4
+ import { resolveSingleChainId } from '../lib/network-selection'
4
5
  import { projectOption, verbosityOption } from './common'
5
6
  import { setVerbosity } from '../index'
6
7
  import * as solc from 'solc'
@@ -22,7 +23,7 @@ function getEtherscanApiUrl(chainId: number): string {
22
23
  }
23
24
 
24
25
  type EtherscanSourceEnvelope = {
25
- rawResult: Record<string, any>
26
+ rawResult: Record<string, unknown>
26
27
  parsedSource: unknown // standard-json object or flattened string
27
28
  }
28
29
 
@@ -81,7 +82,7 @@ async function fetchFromEtherscan(
81
82
  if (!Array.isArray(data.result) || data.result.length === 0) {
82
83
  throw new Error('Empty result from Etherscan')
83
84
  }
84
- const first = (data.result as Array<{ SourceCode?: string }>)[0] as Record<string, any>
85
+ const first = (data.result as Array<{ SourceCode?: string }>)[0] as Record<string, unknown>
85
86
  const sourceCodeRaw = first?.SourceCode as string
86
87
  if (typeof sourceCodeRaw !== 'string' || sourceCodeRaw.length === 0) {
87
88
  throw new Error('No SourceCode found on Etherscan')
@@ -120,7 +121,7 @@ export function makeEtherscanCommand(): Command {
120
121
  verbosityOption(cmd)
121
122
  cmd
122
123
  .option('--etherscan-api-key <key>', 'Etherscan API key. Can also be set via ETHERSCAN_API_KEY env var.')
123
- .option('-n, --network <chainId>', 'Target network chain ID (required to select proper Etherscan endpoint)')
124
+ .option('-n, --network <selector>', 'Target network (chain ID or name). When a name matches multiple networks, the first match is used.')
124
125
  .option('-a, --address <address>', 'Contract address to query', '')
125
126
  .option('--raw', 'Print raw response (no pretty JSON). Useful for piping.', false)
126
127
  return cmd
@@ -147,22 +148,15 @@ export function makeEtherscanCommand(): Command {
147
148
 
148
149
  // Determine chainId
149
150
  let chainId: number | undefined
151
+ const networks = await loadNetworks(options.project)
150
152
  if (options.network) {
151
- const parsed = Number(options.network)
152
- if (Number.isNaN(parsed)) {
153
- console.error(chalk.red('Invalid --network value. Must be a chain ID number.'))
154
- process.exit(1)
155
- }
156
- chainId = parsed
157
- } else {
158
- // If network not provided, try to infer: if only one network configured, use it
159
- const networks = await loadNetworks(options.project)
160
- if (networks.length === 1) {
161
- chainId = networks[0].chainId
162
- } else {
163
- console.error(chalk.red('Please provide --network <chainId> (multiple or zero networks configured).'))
164
- process.exit(1)
165
- }
153
+ chainId = resolveSingleChainId(options.network, networks)
154
+ } else if (networks.length === 1) {
155
+ chainId = networks[0].chainId
156
+ }
157
+ if (!chainId) {
158
+ console.error(chalk.red('Please provide --network <selector>. When multiple networks are configured, selection is required.'))
159
+ process.exit(1)
166
160
  }
167
161
 
168
162
  const result = await fetchFromEtherscan(chainId!, apiKey, options.address, 'getabi')
@@ -201,21 +195,15 @@ export function makeEtherscanCommand(): Command {
201
195
 
202
196
  // Determine chainId
203
197
  let chainId: number | undefined
198
+ const networks2 = await loadNetworks(options.project)
204
199
  if (options.network) {
205
- const parsed = Number(options.network)
206
- if (Number.isNaN(parsed)) {
207
- console.error(chalk.red('Invalid --network value. Must be a chain ID number.'))
208
- process.exit(1)
209
- }
210
- chainId = parsed
211
- } else {
212
- const networks = await loadNetworks(options.project)
213
- if (networks.length === 1) {
214
- chainId = networks[0].chainId
215
- } else {
216
- console.error(chalk.red('Please provide --network <chainId> (multiple or zero networks configured).'))
217
- process.exit(1)
218
- }
200
+ chainId = resolveSingleChainId(options.network, networks2)
201
+ } else if (networks2.length === 1) {
202
+ chainId = networks2[0].chainId
203
+ }
204
+ if (!chainId) {
205
+ console.error(chalk.red('Please provide --network <selector>. When multiple networks are configured, selection is required.'))
206
+ process.exit(1)
219
207
  }
220
208
 
221
209
  const result = await fetchFromEtherscan(chainId!, apiKey, options.address, 'getsourcecode') as EtherscanSourceEnvelope
@@ -228,18 +216,29 @@ export function makeEtherscanCommand(): Command {
228
216
  const optimizationUsed = (raw?.OptimizationUsed as string | undefined) || ''
229
217
  const runsStr = (raw?.Runs as string | undefined) || ''
230
218
  const evmVersionRaw = (raw?.EVMVersion as string | undefined) || ''
231
- const isStandardJson = parsed && typeof parsed === 'object' && (parsed as any).language && (parsed as any).sources
219
+ const isStandardJson = !!(parsed && typeof parsed === 'object' && 'language' in parsed && 'sources' in parsed)
232
220
 
233
221
  // If we have a standard JSON input, use it; otherwise build one from flattened source
234
- let input: any
222
+ type SolcInput = {
223
+ language: string
224
+ sources: Record<string, { content?: string }>
225
+ settings?: {
226
+ optimizer?: { enabled?: boolean; runs?: number }
227
+ evmVersion?: string
228
+ outputSelection?: Record<string, Record<string, string[]>>
229
+ viaIR?: boolean
230
+ libraries?: Record<string, Record<string, string>>
231
+ }
232
+ }
233
+ let input: SolcInput
235
234
  if (isStandardJson) {
236
- input = { ...(parsed as any) }
235
+ input = parsed as SolcInput
237
236
  // Ensure outputSelection includes required entries to get creation bytecode and metadata
238
- const currentSel = input.settings?.outputSelection || {}
239
- const mergedSel = {
237
+ const currentSel = (input.settings?.outputSelection ?? {}) as Record<string, Record<string, string[]>>
238
+ const mergedSel: Record<string, Record<string, string[]>> = {
240
239
  '*': {
241
- '*': Array.from(new Set([
242
- ...(currentSel?.['*']?.['*'] || []),
240
+ '*': Array.from(new Set<string>([
241
+ ...((currentSel?.['*']?.['*']) || []),
243
242
  'abi',
244
243
  'evm.bytecode',
245
244
  'evm.deployedBytecode',
@@ -295,8 +294,8 @@ export function makeEtherscanCommand(): Command {
295
294
  if (versionTag) {
296
295
  outputRaw = await new Promise<string>((resolve, reject) => {
297
296
  // @ts-ignore - loadRemoteVersion exists in solc js
298
- solc.loadRemoteVersion(versionTag, (err: any, specificSolc: any) => {
299
- if (err || !specificSolc) return reject(err || new Error('Failed to load solc version'))
297
+ solc.loadRemoteVersion(versionTag, (err: unknown, specificSolc: { compile: (input: string) => string } | undefined) => {
298
+ if (err || !specificSolc) return reject((err as Error) || new Error('Failed to load solc version'))
300
299
  try {
301
300
  resolve(specificSolc.compile(solcInput))
302
301
  } catch (e) {
@@ -314,7 +313,8 @@ export function makeEtherscanCommand(): Command {
314
313
 
315
314
  // Determine solc versions
316
315
  const solcLongVersion = (output?.compiler?.version as string | undefined) || (compilerVersion ? compilerVersion.replace(/^v/, '') : undefined)
317
- const solcVersion = (solcLongVersion || '').split('+')[0] || (typeof (solc as any).version === 'function' ? (solc as any).version() : 'unknown')
316
+ const solcMaybe = solc as unknown as { version?: () => string }
317
+ const solcVersion = (solcLongVersion || '').split('+')[0] || (typeof solcMaybe.version === 'function' ? solcMaybe.version() : 'unknown')
318
318
 
319
319
  // Augment settings with defaults similar to reference format
320
320
  const basePath = process.cwd()
@@ -5,12 +5,13 @@ import { detectNetworkFromRpc, isValidRpcUrl } from '../lib/network-utils'
5
5
  import { deploymentEvents } from '../lib/events'
6
6
  import { Network } from '../lib/types'
7
7
  import { projectOption, dotenvOption, noStdOption, verbosityOption, loadDotenv } from './common'
8
+ import { resolveSelectedChainIds } from '../lib/network-selection'
8
9
  import { setVerbosity } from '../index'
9
10
 
10
11
  interface RunOptions {
11
12
  project: string
12
13
  privateKey?: string
13
- network?: string[]
14
+ network?: string
14
15
  rpcUrl?: string
15
16
  dotenv?: string
16
17
  std: boolean
@@ -27,7 +28,7 @@ export function makeRunCommand(): Command {
27
28
  .description('Run deployment jobs on specified networks')
28
29
  .argument('[jobs...]', 'Specific job names or patterns to run (and their dependencies). Supports wildcards like "sequence/*" or "job?". If not provided, all jobs are run.')
29
30
  .option('-k, --private-key <key>', 'Signer private key. Can also be set via PRIVATE_KEY env var.')
30
- .option('-n, --network <chainIds...>', 'One or more network chain IDs to run on. If not provided, runs on all configured networks.')
31
+ .option('-n, --network <selectors>', 'Comma-separated network selectors (by chain ID or name). If not provided, runs on all configured networks.')
31
32
  .option('--rpc-url <url>', 'Custom RPC URL to run on. The system will automatically detect chainId and network information. This overrides networks.yaml configuration.')
32
33
  .option('--etherscan-api-key <key>', 'Etherscan API key for contract verification. Can also be set via ETHERSCAN_API_KEY env var.')
33
34
  .option('--fail-early', 'Stop execution as soon as any job fails. Default: false', false)
@@ -97,12 +98,13 @@ export function makeRunCommand(): Command {
97
98
  throw new Error('No networks configured. Please create a networks.yaml file in your project root or use --rpc-url to specify a custom network.')
98
99
  }
99
100
 
101
+ const selectedChainIds = resolveSelectedChainIds(options.network, networks)
100
102
  const deployerOptions: DeployerOptions = {
101
103
  projectRoot,
102
104
  privateKey,
103
105
  networks,
104
106
  runJobs: jobs.length > 0 ? jobs : undefined,
105
- runOnNetworks: options.network?.map(Number),
107
+ runOnNetworks: selectedChainIds,
106
108
  etherscanApiKey,
107
109
  failEarly: options.failEarly,
108
110
  noPostCheckConditions: options.noPostCheckConditions,
@@ -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
  }
@@ -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
+
@@ -158,6 +158,8 @@ export class Deployer {
158
158
 
159
159
  // Track if any jobs have failed
160
160
  let hasFailures = false
161
+ // Emit signer info once per network (chainId)
162
+ const signerInfoPrintedForChain = new Set<number>()
161
163
 
162
164
  for (const network of targetNetworks) {
163
165
  this.events.emitEvent({
@@ -168,7 +170,6 @@ export class Deployer {
168
170
  chainId: network.chainId
169
171
  }
170
172
  })
171
-
172
173
  for (const jobName of jobsToRun) {
173
174
  const job = this.loader.jobs.get(jobName)!
174
175
 
@@ -204,6 +205,46 @@ export class Deployer {
204
205
  (context as unknown as { setJobConstants: (constants: unknown) => void }).setJobConstants(job.constants)
205
206
  }
206
207
 
208
+ // Emit signer info once per network using the first job's context
209
+ if (!signerInfoPrintedForChain.has(network.chainId)) {
210
+ try {
211
+ const getSignerFn = (context as unknown as {
212
+ getResolvedSigner?: () => Promise<{ getAddress: () => Promise<string> }>
213
+ signer?: { getAddress: () => Promise<string> }
214
+ }).getResolvedSigner
215
+ const signer = getSignerFn
216
+ ? await getSignerFn.call(context)
217
+ : (context as unknown as { signer?: { getAddress: () => Promise<string> } }).signer
218
+ if (signer && typeof signer.getAddress === 'function') {
219
+ const address = await signer.getAddress()
220
+ // provider may not exist on mocked contexts; guard for it
221
+ const provider = (context as unknown as {
222
+ provider?: { getBalance: (addr: string) => Promise<bigint | number | { toString: () => string }> }
223
+ }).provider
224
+ if (provider && typeof provider.getBalance === 'function') {
225
+ const balanceBn = await provider.getBalance(address)
226
+ const balanceWei = balanceBn.toString()
227
+ const balanceEth = (Number(balanceBn) / 1e18).toString()
228
+ this.events.emitEvent({
229
+ type: 'network_signer_info',
230
+ level: 'info',
231
+ data: {
232
+ networkName: network.name,
233
+ chainId: network.chainId,
234
+ address,
235
+ balanceWei,
236
+ balance: balanceEth
237
+ }
238
+ })
239
+ }
240
+ }
241
+ } catch {
242
+ // ignore non-fatal signer info errors
243
+ } finally {
244
+ signerInfoPrintedForChain.add(network.chainId)
245
+ }
246
+ }
247
+
207
248
  // Populate context with outputs from previously executed dependent jobs
208
249
  this.populateContextWithDependentJobOutputs(job, context, network)
209
250
 
@@ -275,12 +316,32 @@ export class Deployer {
275
316
  // Check if any jobs failed and exit with error if so
276
317
  if (hasFailures) {
277
318
  const error = new Error('One or more jobs failed during execution')
319
+
320
+ // Build a flat list of failed jobs with network context and error messages
321
+ const failedJobs: Array<{ jobName: string; networkName: string; chainId: number; error: string }> = []
322
+ for (const [, result] of this.results) {
323
+ const job = result.job
324
+ for (const [chainId, netResult] of result.outputs) {
325
+ if (netResult.status === 'error') {
326
+ // Resolve network name from configured networks (fallback to chainId if missing)
327
+ const network = this.options.networks.find(n => n.chainId === chainId)
328
+ failedJobs.push({
329
+ jobName: job.name,
330
+ networkName: network?.name || `chain-${chainId}`,
331
+ chainId,
332
+ error: String(netResult.data)
333
+ })
334
+ }
335
+ }
336
+ }
337
+
278
338
  this.events.emitEvent({
279
339
  type: 'deployment_failed',
280
340
  level: 'error',
281
341
  data: {
282
342
  error: error.message,
283
- stack: error.stack
343
+ stack: error.stack,
344
+ failedJobs
284
345
  }
285
346
  })
286
347
  throw error
@@ -504,13 +565,20 @@ export class Deployer {
504
565
  const jobWithNetworkFilters = job as Job & { only_networks?: number[]; skip_networks?: number[]; min_evm_version?: string }
505
566
 
506
567
  // Check only_networks: if present, the job only runs on these networks.
507
- if (jobWithNetworkFilters.only_networks && jobWithNetworkFilters.only_networks.length > 0) {
508
- return !jobWithNetworkFilters.only_networks.includes(network.chainId)
509
- }
510
-
511
- // Check skip_networks: if present, the job skips these networks.
512
- if (jobWithNetworkFilters.skip_networks && jobWithNetworkFilters.skip_networks.length > 0) {
513
- return jobWithNetworkFilters.skip_networks.includes(network.chainId)
568
+ // If the network is NOT in only_networks, skip immediately. If it IS included, continue to min_evm_version checks.
569
+ const hasOnly = !!(jobWithNetworkFilters.only_networks && jobWithNetworkFilters.only_networks.length > 0)
570
+ if (hasOnly) {
571
+ if (!jobWithNetworkFilters.only_networks!.includes(network.chainId)) {
572
+ return true
573
+ }
574
+ // When only_networks is present and the network is allowed, skip_networks is ignored by design.
575
+ } else {
576
+ // Only consider skip_networks when only_networks is not set.
577
+ if (jobWithNetworkFilters.skip_networks && jobWithNetworkFilters.skip_networks.length > 0) {
578
+ if (jobWithNetworkFilters.skip_networks.includes(network.chainId)) {
579
+ return true
580
+ }
581
+ }
514
582
  }
515
583
 
516
584
  // Check minimal EVM hardfork requirement if present on job and network declares an EVM version
@@ -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
  })