@0xsequence/catapult 1.2.1 ā 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.
- package/README.md +10 -3
- package/dist/commands/dry.d.ts.map +1 -1
- package/dist/commands/dry.js +3 -2
- package/dist/commands/dry.js.map +1 -1
- package/dist/commands/etherscan.d.ts.map +1 -1
- package/dist/commands/etherscan.js +24 -36
- package/dist/commands/etherscan.js.map +1 -1
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +4 -2
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/utils.d.ts.map +1 -1
- package/dist/commands/utils.js +191 -0
- package/dist/commands/utils.js.map +1 -1
- package/dist/lib/__tests__/network-loader.spec.js +3 -2
- package/dist/lib/__tests__/network-loader.spec.js.map +1 -1
- package/dist/lib/__tests__/network-selection.spec.d.ts +2 -0
- package/dist/lib/__tests__/network-selection.spec.d.ts.map +1 -0
- package/dist/lib/__tests__/network-selection.spec.js +34 -0
- package/dist/lib/__tests__/network-selection.spec.js.map +1 -0
- package/dist/lib/deployer.d.ts.map +1 -1
- package/dist/lib/deployer.js +51 -1
- package/dist/lib/deployer.js.map +1 -1
- package/dist/lib/events/cli-adapter.d.ts.map +1 -1
- package/dist/lib/events/cli-adapter.js +16 -3
- package/dist/lib/events/cli-adapter.js.map +1 -1
- package/dist/lib/events/types.d.ts +18 -1
- package/dist/lib/events/types.d.ts.map +1 -1
- package/dist/lib/network-loader.d.ts.map +1 -1
- package/dist/lib/network-loader.js +1 -1
- package/dist/lib/network-loader.js.map +1 -1
- package/dist/lib/network-selection.d.ts +4 -0
- package/dist/lib/network-selection.d.ts.map +1 -0
- package/dist/lib/network-selection.js +49 -0
- package/dist/lib/network-selection.js.map +1 -0
- package/package.json +1 -1
- package/src/commands/dry.ts +4 -3
- package/src/commands/etherscan.ts +42 -42
- package/src/commands/run.ts +5 -3
- package/src/commands/utils.ts +163 -0
- package/src/lib/__tests__/network-loader.spec.ts +3 -2
- package/src/lib/__tests__/network-selection.spec.ts +41 -0
- package/src/lib/deployer.ts +63 -2
- package/src/lib/events/cli-adapter.ts +21 -5
- package/src/lib/events/types.ts +23 -0
- package/src/lib/network-loader.ts +2 -1
- 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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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' &&
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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
|
|
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()
|
package/src/commands/run.ts
CHANGED
|
@@ -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 <
|
|
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:
|
|
107
|
+
runOnNetworks: selectedChainIds,
|
|
106
108
|
etherscanApiKey,
|
|
107
109
|
failEarly: options.failEarly,
|
|
108
110
|
noPostCheckConditions: options.noPostCheckConditions,
|
package/src/commands/utils.ts
CHANGED
|
@@ -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('
|
|
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
|
|
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
|
+
|
package/src/lib/deployer.ts
CHANGED
|
@@ -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
|
|
@@ -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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
console.error(chalk.red(
|
|
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
|
|
package/src/lib/events/types.ts
CHANGED
|
@@ -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
|
-
|
|
40
|
+
// Default to empty string if missing
|
|
41
|
+
return ''
|
|
41
42
|
}
|
|
42
43
|
return value
|
|
43
44
|
})
|