@subsquid/evm-typegen 4.6.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,221 @@
1
+ import {keccak256} from '@subsquid/evm-abi'
2
+ import type {Abi, AbiEvent, AbiFunction, AbiParameter} from 'abitype'
3
+
4
+ export interface ContractDef {
5
+ events: EventDef[]
6
+ functions: FunctionDef[]
7
+ }
8
+
9
+ export interface DocDef {
10
+ notice?: string
11
+ dev?: string
12
+ params?: Record<string, string>
13
+ returns?: Record<string, string>
14
+ }
15
+
16
+ /** NatSpec documentation extracted from a compilation artifact's userdoc/devdoc fields. */
17
+ export interface NatSpec {
18
+ userdoc?: {
19
+ methods?: Record<string, {notice?: string}>
20
+ events?: Record<string, {notice?: string}>
21
+ }
22
+ devdoc?: {
23
+ methods?: Record<string, {details?: string; params?: Record<string, string>; returns?: Record<string, string>}>
24
+ events?: Record<string, {details?: string; params?: Record<string, string>}>
25
+ }
26
+ }
27
+
28
+ export interface EventDef {
29
+ name: string
30
+ signature: string
31
+ topic: string
32
+ inputs: FieldDef[]
33
+ key: string
34
+ typeName: string
35
+ docs?: DocDef
36
+ }
37
+
38
+ export interface FunctionDef {
39
+ name: string
40
+ signature: string
41
+ selector: string
42
+ inputs: FieldDef[]
43
+ outputs: FieldDef[]
44
+ key: string
45
+ paramsTypeName: string
46
+ returnTypeName: string
47
+ docs?: DocDef
48
+ }
49
+
50
+ export interface FieldDef {
51
+ name: string
52
+ type: TypeDef
53
+ indexed?: boolean
54
+ doc?: string
55
+ }
56
+
57
+ export type TypeDef =
58
+ | {kind: 'primitive'; name: string}
59
+ | {kind: 'array'; item: TypeDef}
60
+ | {kind: 'fixedArray'; item: TypeDef; size: number}
61
+ | {kind: 'tuple'; fields: FieldDef[]}
62
+
63
+ export function describe(abi: Abi, natspec?: NatSpec): ContractDef {
64
+ const rawEvents = abi.filter((x) => x.type === 'event') as AbiEvent[]
65
+ const rawFunctions = abi.filter((x) => x.type === 'function') as AbiFunction[]
66
+
67
+ const eventSuffix = overloadSuffixer(rawEvents)
68
+ const functionSuffix = overloadSuffixer(rawFunctions)
69
+
70
+ const events: EventDef[] = rawEvents.map((e) => {
71
+ const signature = eventSignature(e)
72
+ const docs = buildEventDocs(signature, natspec)
73
+ const inputs = e.inputs.map((p, i) => toFieldDef(p, i, true))
74
+ annotateParamDocs(inputs, docs?.params)
75
+ return {
76
+ name: e.name,
77
+ signature,
78
+ topic: `0x${keccak256(signature).toString('hex')}`,
79
+ inputs,
80
+ key: eventSuffix(e, e.name),
81
+ typeName: eventSuffix(e, `${capitalize(e.name)}EventArgs`),
82
+ docs,
83
+ }
84
+ })
85
+
86
+ const functions: FunctionDef[] = rawFunctions.map((f) => {
87
+ const signature = fnSignature(f)
88
+ const docs = buildFunctionDocs(signature, natspec)
89
+ const inputs = f.inputs.map((p, i) => toFieldDef(p, i, false))
90
+ const outputs = (f.outputs ?? []).map((p, i) => toFieldDef(p, i, false))
91
+ annotateParamDocs(inputs, docs?.params)
92
+ annotateReturnDocs(outputs, docs?.returns)
93
+ return {
94
+ name: f.name,
95
+ signature,
96
+ selector: `0x${keccak256(signature).slice(0, 4).toString('hex')}`,
97
+ inputs,
98
+ outputs,
99
+ key: functionSuffix(f, f.name),
100
+ paramsTypeName: functionSuffix(f, `${capitalize(f.name)}Params`),
101
+ returnTypeName: functionSuffix(f, `${capitalize(f.name)}Return`),
102
+ docs,
103
+ }
104
+ })
105
+
106
+ return {events, functions}
107
+ }
108
+
109
+ function buildEventDocs(signature: string, natspec: NatSpec | undefined): DocDef | undefined {
110
+ const notice = natspec?.userdoc?.events?.[signature]?.notice
111
+ const devEntry = natspec?.devdoc?.events?.[signature]
112
+ if (!notice && !devEntry) return undefined
113
+ return filterEmptyDoc({
114
+ notice,
115
+ dev: devEntry?.details,
116
+ params: devEntry?.params,
117
+ })
118
+ }
119
+
120
+ function buildFunctionDocs(signature: string, natspec: NatSpec | undefined): DocDef | undefined {
121
+ const notice = natspec?.userdoc?.methods?.[signature]?.notice
122
+ const devEntry = natspec?.devdoc?.methods?.[signature]
123
+ if (!notice && !devEntry) return undefined
124
+ return filterEmptyDoc({
125
+ notice,
126
+ dev: devEntry?.details,
127
+ params: devEntry?.params,
128
+ returns: devEntry?.returns,
129
+ })
130
+ }
131
+
132
+ function filterEmptyDoc(doc: DocDef): DocDef | undefined {
133
+ const hasContent =
134
+ doc.notice != null ||
135
+ doc.dev != null ||
136
+ (doc.params != null && Object.keys(doc.params).length > 0) ||
137
+ (doc.returns != null && Object.keys(doc.returns).length > 0)
138
+ return hasContent ? doc : undefined
139
+ }
140
+
141
+ function annotateParamDocs(fields: FieldDef[], params: Record<string, string> | undefined): void {
142
+ if (!params) return
143
+ for (const field of fields) {
144
+ const doc = params[field.name]
145
+ if (doc) field.doc = doc
146
+ }
147
+ }
148
+
149
+ function annotateReturnDocs(fields: FieldDef[], returns: Record<string, string> | undefined): void {
150
+ if (!returns) return
151
+ for (let i = 0; i < fields.length; i++) {
152
+ const field = fields[i]
153
+ const doc = returns[field.name] ?? returns[`_${i}`]
154
+ if (doc) field.doc = doc
155
+ }
156
+ }
157
+
158
+ function overloadSuffixer(items: readonly (AbiEvent | AbiFunction)[]) {
159
+ const counts = new Map<string, number>()
160
+ const indices = new Map<AbiEvent | AbiFunction, number>()
161
+ for (const item of items) {
162
+ const seen = counts.get(item.name) ?? 0
163
+ indices.set(item, seen)
164
+ counts.set(item.name, seen + 1)
165
+ }
166
+ return (item: AbiEvent | AbiFunction, base: string): string => {
167
+ if ((counts.get(item.name) ?? 1) <= 1) return base
168
+ const idx = indices.get(item)!
169
+ return idx === 0 ? base : `${base}_${idx}`
170
+ }
171
+ }
172
+
173
+ function toFieldDef(p: AbiParameter, index: number, isEventInput: boolean): FieldDef {
174
+ const field: FieldDef = {
175
+ name: p.name || `_${index}`,
176
+ type: toTypeDef(p),
177
+ }
178
+ if (isEventInput && (p as any).indexed) field.indexed = true
179
+ return field
180
+ }
181
+
182
+ function toTypeDef(p: AbiParameter): TypeDef {
183
+ const fixed = p.type.match(/\[(\d+)\]$/)
184
+ if (fixed) {
185
+ return {kind: 'fixedArray', size: Number(fixed[1]), item: toTypeDef(stripOuterArray(p))}
186
+ }
187
+ if (p.type.endsWith('[]')) {
188
+ return {kind: 'array', item: toTypeDef(stripOuterArray(p))}
189
+ }
190
+ if (p.type.startsWith('tuple')) {
191
+ const components = ((p as any).components || []) as AbiParameter[]
192
+ return {
193
+ kind: 'tuple',
194
+ fields: components.map((c, i) => toFieldDef(c, i, false)),
195
+ }
196
+ }
197
+ return {kind: 'primitive', name: p.type}
198
+ }
199
+
200
+ function stripOuterArray(p: AbiParameter): AbiParameter {
201
+ return {...(p as any), type: p.type.replace(/\[\d*\]$/, '')} as AbiParameter
202
+ }
203
+
204
+ export function canonicalType(p: AbiParameter): string {
205
+ if (!p.type.startsWith('tuple')) return p.type
206
+ const arrayBrackets = p.type.slice(5)
207
+ const components = (p as any).components as AbiParameter[]
208
+ return `(${components.map(canonicalType).join(',')})${arrayBrackets}`
209
+ }
210
+
211
+ function eventSignature(e: AbiEvent): string {
212
+ return `${e.name}(${e.inputs.map(canonicalType).join(',')})`
213
+ }
214
+
215
+ function fnSignature(f: AbiFunction): string {
216
+ return `${f.name}(${f.inputs.map(canonicalType).join(',')})`
217
+ }
218
+
219
+ function capitalize(s: string): string {
220
+ return s.charAt(0).toUpperCase() + s.slice(1)
221
+ }
package/src/main.ts CHANGED
@@ -1,12 +1,13 @@
1
- import * as fs from 'fs'
2
- import path from 'path'
3
- import { InvalidArgumentError, InvalidOptionArgumentError, program } from 'commander'
4
- import { createLogger } from '@subsquid/logger'
5
- import { runProgram, wait } from '@subsquid/util-internal'
1
+ import * as fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import {InvalidArgumentError, InvalidOptionArgumentError, program} from 'commander'
4
+ import {createLogger} from '@subsquid/logger'
5
+ import {runProgram, wait} from '@subsquid/util-internal'
6
6
  import * as validator from '@subsquid/util-internal-commander'
7
- import { Typegen } from './typegen'
8
- import { GET } from './util/fetch'
9
- import { OutDir } from '@subsquid/util-internal-code-printer'
7
+ import {Typegen} from './typegen'
8
+ import type {NatSpec} from './description'
9
+ import {GET} from './util/fetch'
10
+ import {OutDir} from '@subsquid/util-internal-code-printer'
10
11
  import {chainIdOption} from './chainIds'
11
12
 
12
13
  const LOG = createLogger('sqd:evm-typegen')
@@ -14,40 +15,40 @@ const PROXY_ETHERSCAN = 'https://cloud.sqd.dev/chains/api/v1/evm/abi'
14
15
  const ORIGIN_ETHERSCAN = 'https://api.etherscan.io/v2/api'
15
16
 
16
17
  runProgram(
17
- async function () {
18
- program
19
- .description(
20
- `
18
+ async function () {
19
+ program
20
+ .description(
21
+ `
21
22
  Generates TypeScript facades for EVM transactions, logs and eth_call queries.
22
23
 
23
24
  The generated facades are assumed to be used by "squids" indexing EVM data.
24
25
  `.trim(),
25
- )
26
- .name('squid-evm-typegen')
27
- .argument('<output-dir>', 'output directory for generated definitions')
28
- .argument('[abi...]', 'ABI file', specArgument)
29
- .option('--multicall', 'generate facade for MakerDAO multicall contract')
30
- .option(
31
- '--etherscan-api <url>',
32
- 'etherscan API to fetch contract ABI by a known address\n(if no API token is provided, the default value equals to SQD Proxy service, otherwise equals to Etherscan API)',
33
- validator.Url(['http:', 'https:']),
34
- )
35
- .option('--etherscan-api-key <key>', 'etherscan API key')
36
- .option(
37
- '--chain-id <id>',
38
- 'chain ID (numeric or named, e.g., "1" or "ethereum") to fetch the contract from',
39
- chainIdOption,
40
- 1,
41
- )
42
- .option(
43
- '--etherscan-chain-id <id>',
44
- 'DEPRECATED: use --chain-id instead. Chain ID (numeric or named, e.g., "1" or "ethereum") to fetch the contract from',
45
- chainIdOption,
46
- )
47
- .option('--clean', 'delete output directory before run')
48
- .addHelpText(
49
- 'afterAll',
50
- `
26
+ )
27
+ .name('squid-evm-typegen')
28
+ .argument('<output-dir>', 'output directory for generated definitions')
29
+ .argument('[abi...]', 'ABI file', specArgument)
30
+ .option('--multicall', 'generate facade for MakerDAO multicall contract')
31
+ .option(
32
+ '--etherscan-api <url>',
33
+ 'etherscan API to fetch contract ABI by a known address\n(if no API token is provided, the default value equals to SQD Proxy service, otherwise equals to Etherscan API)',
34
+ validator.Url(['http:', 'https:']),
35
+ )
36
+ .option('--etherscan-api-key <key>', 'etherscan API key')
37
+ .option(
38
+ '--chain-id <id>',
39
+ 'chain ID (numeric or named, e.g., "1" or "ethereum") to fetch the contract from',
40
+ chainIdOption,
41
+ 1,
42
+ )
43
+ .option(
44
+ '--etherscan-chain-id <id>',
45
+ 'DEPRECATED: use --chain-id instead. Chain ID (numeric or named, e.g., "1" or "ethereum") to fetch the contract from',
46
+ chainIdOption,
47
+ )
48
+ .option('--clean', 'delete output directory before run')
49
+ .addHelpText(
50
+ 'afterAll',
51
+ `
51
52
  ABI file can be specified in three ways:
52
53
 
53
54
  1. as a plain JSON file:
@@ -67,186 +68,186 @@ You can overwrite basename of generated files using fragment (#) suffix.
67
68
 
68
69
  squid-evm-typegen src/abi 0xBB9bc244D798123fDe783fCc1C72d3Bb8C189413#contract
69
70
  `,
70
- )
71
-
72
- program.parse()
71
+ )
72
+
73
+ program.parse()
74
+
75
+ let opts = program.opts() as {
76
+ clean?: boolean
77
+ multicall?: boolean
78
+ etherscanApi?: string
79
+ etherscanApiKey?: string
80
+ chainId?: number
81
+ etherscanChainId?: number
82
+ }
83
+ let dest = new OutDir(program.processedArgs[0])
84
+ let specs = program.processedArgs[1] as Spec[]
85
+
86
+ if (opts.etherscanChainId) {
87
+ LOG.warn('Option --etherscan-chain-id is deprecated. Please use --chain-id instead')
88
+ if (opts.chainId) {
89
+ throw new InvalidOptionArgumentError(
90
+ 'Option --chain-id and --etherscan-chain-id cannot be used together',
91
+ )
92
+ }
93
+ opts.chainId = opts.etherscanChainId
94
+ opts.etherscanChainId = undefined
95
+ }
96
+
97
+ if (opts.clean && dest.exists()) {
98
+ LOG.info(`deleting ${dest.path()}`)
99
+ dest.del()
100
+ }
101
+
102
+ if (specs.length == 0 && !opts.multicall) {
103
+ LOG.warn('no ABI files given, nothing to generate')
104
+ return
105
+ }
106
+
107
+ if (opts.multicall) {
108
+ dest.add('multicall.ts', [__dirname, '../src/multicall.ts'])
109
+ LOG.info(`saved ${dest.path('multicall.ts')}`)
110
+ }
111
+
112
+ if (specs.length > 0) {
113
+ dest.add('abi.support.ts', [__dirname, '../src/abi.support.ts'])
114
+ LOG.info(`saved ${dest.path('abi.support.ts')}`)
115
+ }
116
+
117
+ for (let spec of specs) {
118
+ LOG.info(`processing ${spec.src}`)
119
+ let {abi, natspec} = await read(spec, opts)
120
+ await new Typegen(dest, abi, spec.name, LOG, natspec).generate()
121
+ }
122
+ },
123
+ (err) => LOG.fatal(err),
124
+ )
73
125
 
74
- let opts = program.opts() as {
75
- clean?: boolean
76
- multicall?: boolean
77
- etherscanApi?: string
78
- etherscanApiKey?: string
79
- chainId?: number
80
- etherscanChainId?: number
126
+ async function read(
127
+ spec: Spec,
128
+ options: {
129
+ etherscanApi?: string
130
+ chainId?: number
131
+ etherscanApiKey?: string
132
+ },
133
+ ): Promise<{abi: any; natspec?: NatSpec}> {
134
+ if (spec.kind == 'address') {
135
+ return {abi: await fetchFromEtherscan(spec.src, getEtherscanAPIConfig(options))}
81
136
  }
82
- let dest = new OutDir(program.processedArgs[0])
83
- let specs = program.processedArgs[1] as Spec[]
84
-
85
- if (opts.etherscanChainId) {
86
- LOG.warn('Option --etherscan-chain-id is deprecated. Please use --chain-id instead')
87
- if (opts.chainId) {
88
- throw new InvalidOptionArgumentError('Option --chain-id and --etherscan-chain-id cannot be used together')
89
- }
90
- opts.chainId = opts.etherscanChainId
91
- delete opts.etherscanChainId
137
+ let raw: any
138
+ if (spec.kind == 'url') {
139
+ raw = await GET(spec.src)
140
+ } else {
141
+ raw = JSON.parse(fs.readFileSync(spec.src, 'utf-8'))
92
142
  }
93
-
94
- if (opts.clean && dest.exists()) {
95
- LOG.info(`deleting ${dest.path()}`)
96
- dest.del()
143
+ if (Array.isArray(raw)) {
144
+ return {abi: raw}
97
145
  }
98
-
99
- if (specs.length == 0 && !opts.multicall) {
100
- LOG.warn('no ABI files given, nothing to generate')
101
- return
146
+ if (Array.isArray(raw?.abi)) {
147
+ return {abi: raw.abi, natspec: extractNatSpec(raw)}
102
148
  }
149
+ throw new Error('Unrecognized ABI format')
150
+ }
103
151
 
104
- if (opts.multicall) {
105
- dest.add('multicall.ts', [__dirname, '../src/multicall.ts'])
106
- LOG.info(`saved ${dest.path('multicall.ts')}`)
107
- }
152
+ function extractNatSpec(artifact: any): NatSpec | undefined {
153
+ const userdoc = artifact.userdoc
154
+ const devdoc = artifact.devdoc
155
+ if (!userdoc && !devdoc) return undefined
156
+ return {userdoc, devdoc}
157
+ }
108
158
 
109
- for (let spec of specs) {
110
- LOG.info(`processing ${spec.src}`)
111
- let abi_json = await read(spec, opts)
112
- await new Typegen(dest, abi_json, spec.name, LOG).generate()
113
- }
114
- },
115
- (err) => LOG.fatal(err),
116
- )
159
+ async function fetchFromEtherscan(address: string, config: EtherscanAPIConfig): Promise<any> {
160
+ let url = new URL(config.api)
117
161
 
118
- async function read(
119
- spec: Spec,
120
- options: {
121
- etherscanApi?: string;
122
- chainId?: number
123
- etherscanApiKey?: string,
124
- },
125
- ): Promise<any> {
126
- if (spec.kind == 'address') {
127
- return fetchFromEtherscan(spec.src, getEtherscanAPIConfig(options))
128
- }
129
- let abi: any
130
- if (spec.kind == 'url') {
131
- abi = await GET(spec.src)
132
- } else {
133
- abi = JSON.parse(fs.readFileSync(spec.src, 'utf-8'))
134
- }
135
- if (Array.isArray(abi)) {
136
- return abi
137
- } else if (Array.isArray(abi?.abi)) {
138
- return abi.abi
139
- } else {
140
- throw new Error('Unrecognized ABI format')
141
- }
142
- }
162
+ let params = new URLSearchParams({
163
+ module: 'contract',
164
+ action: 'getabi',
165
+ address,
166
+ chainid: config.chainId.toString(),
167
+ })
143
168
 
144
- async function fetchFromEtherscan(
145
- address: string,
146
- config: EtherscanAPIConfig,
147
- ): Promise<any> {
148
- let url = new URL(config.api)
149
-
150
- let params = new URLSearchParams({
151
- module: 'contract',
152
- action: 'getabi',
153
- address,
154
- chainid: config.chainId.toString(),
155
- })
156
-
157
- if (config.apiKey) {
158
- params.set('apiKey', config.apiKey);
159
- }
160
-
161
- url.search = params.toString()
162
-
163
- let response: { status: string; result: string }
164
- let attempts = 0
165
- while (true) {
166
- response = await GET(url.toString())
167
- if (
168
- response.status == '0' &&
169
- response.result.includes('rate limit') &&
170
- attempts < 4
171
- ) {
172
- attempts += 1
173
- let timeout = attempts * 2
174
- LOG.warn(
175
- `faced rate limit error while trying to fetch contract ABI. Trying again in ${timeout} seconds.`,
176
- )
177
- await wait(timeout * 1000)
178
- } else {
179
- break
169
+ if (config.apiKey) {
170
+ params.set('apiKey', config.apiKey)
180
171
  }
181
- }
182
- if (response.status == '1') {
183
- return JSON.parse(response.result)
184
- } else {
185
- throw new Error(
186
- `Failed to fetch contract ABI from ${config.api}: ${response.result}`,
187
- )
188
- }
172
+
173
+ url.search = params.toString()
174
+
175
+ let response: {status: string; result: string}
176
+ let attempts = 0
177
+ while (true) {
178
+ response = await GET(url.toString())
179
+ if (response.status == '0' && response.result.includes('rate limit') && attempts < 4) {
180
+ attempts += 1
181
+ let timeout = attempts * 2
182
+ LOG.warn(`faced rate limit error while trying to fetch contract ABI. Trying again in ${timeout} seconds.`)
183
+ await wait(timeout * 1000)
184
+ } else {
185
+ break
186
+ }
187
+ }
188
+ if (response.status == '1') {
189
+ return JSON.parse(response.result)
190
+ }
191
+ throw new Error(`Failed to fetch contract ABI from ${config.api}: ${response.result}`)
189
192
  }
190
193
 
191
194
  interface Spec {
192
- kind: 'address' | 'url' | 'file'
193
- src: string
194
- name: string
195
+ kind: 'address' | 'url' | 'file'
196
+ src: string
197
+ name: string
195
198
  }
196
199
 
197
200
  function specArgument(value: string, prev?: Spec[]): Spec[] {
198
- let spec = parseSpec(value)
199
- prev = prev || []
200
- prev.push(spec)
201
- return prev
201
+ let spec = parseSpec(value)
202
+ prev = prev || []
203
+ prev.push(spec)
204
+ return prev
202
205
  }
203
206
 
204
207
  function isAddress(spec: string): boolean {
205
- return spec.match(/^0x[0-9a-fA-F]{40}$/) !== null
208
+ return spec.match(/^0x[0-9a-fA-F]{40}$/) !== null
206
209
  }
207
210
 
208
211
  function parseSpec(spec: string): Spec {
209
- let [src, fragment] = splitFragment(spec)
210
- if (src.startsWith('0x')) {
211
- if (!isAddress(src))
212
- throw new InvalidArgumentError('Invalid contract address')
213
- return {
214
- kind: 'address',
215
- src,
216
- name: fragment || src,
212
+ let [src, fragment] = splitFragment(spec)
213
+ if (src.startsWith('0x')) {
214
+ if (!isAddress(src)) throw new InvalidArgumentError('Invalid contract address')
215
+ return {
216
+ kind: 'address',
217
+ src,
218
+ name: fragment || src,
219
+ }
217
220
  }
218
- } else if (src.includes('://')) {
219
- let u = new URL(validator.Url(['http:', 'https:'])(src))
220
- return {
221
- kind: 'url',
222
- src,
223
- name: fragment || basename(u.pathname),
221
+ if (src.includes('://')) {
222
+ let u = new URL(validator.Url(['http:', 'https:'])(src))
223
+ return {
224
+ kind: 'url',
225
+ src,
226
+ name: fragment || basename(u.pathname),
227
+ }
224
228
  }
225
- } else {
226
229
  return {
227
- kind: 'file',
228
- src,
229
- name: fragment || basename(src),
230
+ kind: 'file',
231
+ src,
232
+ name: fragment || basename(src),
230
233
  }
231
- }
232
234
  }
233
235
 
234
236
  function splitFragment(spec: string): [string, string] {
235
- let parts = spec.split('#')
236
- if (parts.length > 1) {
237
- let fragment = parts.pop()!
238
- return [parts.join('#'), fragment]
239
- } else {
237
+ let parts = spec.split('#')
238
+ if (parts.length > 1) {
239
+ let fragment = parts.pop()!
240
+ return [parts.join('#'), fragment]
241
+ }
240
242
  return [spec, '']
241
- }
242
243
  }
243
244
 
244
245
  function basename(file: string): string {
245
- let name = path.parse(file).name
246
- if (name) return name
247
- throw new InvalidArgumentError(
248
- `Can't derive target basename for output files. Use url fragment to specify it, e.g. #erc20`,
249
- )
246
+ let name = path.parse(file).name
247
+ if (name) return name
248
+ throw new InvalidArgumentError(
249
+ `Can't derive target basename for output files. Use url fragment to specify it, e.g. #erc20`,
250
+ )
250
251
  }
251
252
 
252
253
  interface EtherscanAPIConfig {
@@ -262,11 +263,11 @@ function getEtherscanAPIConfig(options: {
262
263
  }): EtherscanAPIConfig {
263
264
  let api: string
264
265
  if (options.etherscanApi != null) {
265
- api = normalizeEtherscanAPIUrl(options.etherscanApi)
266
- } else if (options.etherscanApiKey != null) {
267
- api = ORIGIN_ETHERSCAN
266
+ api = normalizeEtherscanAPIUrl(options.etherscanApi)
267
+ } else if (options.etherscanApiKey != null) {
268
+ api = ORIGIN_ETHERSCAN
268
269
  } else {
269
- api = PROXY_ETHERSCAN
270
+ api = PROXY_ETHERSCAN
270
271
  }
271
272
 
272
273
  return {
@@ -277,9 +278,9 @@ function getEtherscanAPIConfig(options: {
277
278
  }
278
279
 
279
280
  function normalizeEtherscanAPIUrl(url: string) {
280
- if (url.endsWith('/api')) {
281
- return url
282
- }
281
+ if (url.endsWith('/api')) {
282
+ return url
283
+ }
283
284
 
284
- return url.endsWith('/') ? url + 'api' : url + '/api'
285
- }
285
+ return url.endsWith('/') ? `${url}api` : `${url}/api`
286
+ }