@chainlink/ccip-cli 0.0.0 → 0.90.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.
- package/LICENSE +21 -0
- package/README.md +238 -0
- package/dist/commands/index.d.ts +2 -0
- package/dist/commands/index.d.ts.map +1 -0
- package/dist/commands/index.js +2 -0
- package/dist/commands/index.js.map +1 -0
- package/dist/commands/manual-exec.d.ts +56 -0
- package/dist/commands/manual-exec.d.ts.map +1 -0
- package/dist/commands/manual-exec.js +405 -0
- package/dist/commands/manual-exec.js.map +1 -0
- package/dist/commands/parse.d.ts +9 -0
- package/dist/commands/parse.d.ts.map +1 -0
- package/dist/commands/parse.js +47 -0
- package/dist/commands/parse.js.map +1 -0
- package/dist/commands/send.d.ts +80 -0
- package/dist/commands/send.d.ts.map +1 -0
- package/dist/commands/send.js +258 -0
- package/dist/commands/send.js.map +1 -0
- package/dist/commands/show.d.ts +18 -0
- package/dist/commands/show.d.ts.map +1 -0
- package/dist/commands/show.js +112 -0
- package/dist/commands/show.js.map +1 -0
- package/dist/commands/supported-tokens.d.ts +37 -0
- package/dist/commands/supported-tokens.d.ts.map +1 -0
- package/dist/commands/supported-tokens.js +214 -0
- package/dist/commands/supported-tokens.js.map +1 -0
- package/dist/commands/types.d.ts +7 -0
- package/dist/commands/types.d.ts.map +1 -0
- package/dist/commands/types.js +6 -0
- package/dist/commands/types.js.map +1 -0
- package/dist/commands/utils.d.ts +40 -0
- package/dist/commands/utils.d.ts.map +1 -0
- package/dist/commands/utils.js +330 -0
- package/dist/commands/utils.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +63 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/aptos.d.ts +15 -0
- package/dist/providers/aptos.d.ts.map +1 -0
- package/dist/providers/aptos.js +74 -0
- package/dist/providers/aptos.js.map +1 -0
- package/dist/providers/evm.d.ts +2 -0
- package/dist/providers/evm.d.ts.map +1 -0
- package/dist/providers/evm.js +42 -0
- package/dist/providers/evm.js.map +1 -0
- package/dist/providers/index.d.ts +13 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/index.js +104 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/solana.d.ts +13 -0
- package/dist/providers/solana.d.ts.map +1 -0
- package/dist/providers/solana.js +79 -0
- package/dist/providers/solana.js.map +1 -0
- package/package.json +57 -8
- package/src/commands/index.ts +1 -0
- package/src/commands/manual-exec.ts +468 -0
- package/src/commands/parse.ts +52 -0
- package/src/commands/send.ts +316 -0
- package/src/commands/show.ts +151 -0
- package/src/commands/supported-tokens.ts +245 -0
- package/src/commands/types.ts +6 -0
- package/src/commands/utils.ts +404 -0
- package/src/index.ts +70 -0
- package/src/providers/aptos.ts +100 -0
- package/src/providers/evm.ts +48 -0
- package/src/providers/index.ts +141 -0
- package/src/providers/solana.ts +93 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyMessage,
|
|
3
|
+
type CCIPVersion,
|
|
4
|
+
type ChainStatic,
|
|
5
|
+
type EVMChain,
|
|
6
|
+
type ExtraArgs,
|
|
7
|
+
ChainFamily,
|
|
8
|
+
bigIntReplacer,
|
|
9
|
+
estimateExecGasForRequest,
|
|
10
|
+
fetchCCIPRequestsInTx,
|
|
11
|
+
getDataBytes,
|
|
12
|
+
networkInfo,
|
|
13
|
+
sourceToDestTokenAmounts,
|
|
14
|
+
} from '@chainlink/ccip-sdk/src/index.ts'
|
|
15
|
+
import { type BytesLike, dataLength, formatUnits, toUtf8Bytes } from 'ethers'
|
|
16
|
+
import type { Argv } from 'yargs'
|
|
17
|
+
|
|
18
|
+
import type { GlobalOpts } from '../index.ts'
|
|
19
|
+
import { Format } from './types.ts'
|
|
20
|
+
import { logParsedError, parseTokenAmounts, prettyRequest, withDateTimestamp } from './utils.ts'
|
|
21
|
+
import { fetchChainsFromRpcs } from '../providers/index.ts'
|
|
22
|
+
|
|
23
|
+
export const command = 'send <source> <router> <dest>'
|
|
24
|
+
export const describe = 'Send a CCIP message from router on source to dest'
|
|
25
|
+
|
|
26
|
+
export const builder = (yargs: Argv) =>
|
|
27
|
+
yargs
|
|
28
|
+
.positional('source', {
|
|
29
|
+
type: 'string',
|
|
30
|
+
demandOption: true,
|
|
31
|
+
describe: 'source network, chainId or name',
|
|
32
|
+
example: 'ethereum-testnet-sepolia',
|
|
33
|
+
})
|
|
34
|
+
.positional('router', {
|
|
35
|
+
type: 'string',
|
|
36
|
+
demandOption: true,
|
|
37
|
+
describe: 'router contract address on source',
|
|
38
|
+
})
|
|
39
|
+
.positional('dest', {
|
|
40
|
+
type: 'string',
|
|
41
|
+
demandOption: true,
|
|
42
|
+
describe: 'destination network, chainId or name',
|
|
43
|
+
example: 'ethereum-testnet-sepolia-arbitrum-1',
|
|
44
|
+
})
|
|
45
|
+
.options({
|
|
46
|
+
receiver: {
|
|
47
|
+
alias: 'R',
|
|
48
|
+
type: 'string',
|
|
49
|
+
describe:
|
|
50
|
+
'Receiver of the message; defaults to the sender wallet address if same network family',
|
|
51
|
+
},
|
|
52
|
+
data: {
|
|
53
|
+
alias: 'd',
|
|
54
|
+
type: 'string',
|
|
55
|
+
describe: 'Data to send in the message (non-hex will be utf-8 encoded)',
|
|
56
|
+
example: '0x1234',
|
|
57
|
+
},
|
|
58
|
+
'gas-limit': {
|
|
59
|
+
alias: ['L', 'compute-units'],
|
|
60
|
+
type: 'number',
|
|
61
|
+
describe:
|
|
62
|
+
'Gas limit for receiver callback execution; defaults to default configured on ramps',
|
|
63
|
+
default: 0,
|
|
64
|
+
},
|
|
65
|
+
'estimate-gas-limit': {
|
|
66
|
+
type: 'number',
|
|
67
|
+
describe:
|
|
68
|
+
'Estimate gas limit for receiver callback execution; argument is a % margin to add to the estimate',
|
|
69
|
+
example: '10',
|
|
70
|
+
conflicts: 'gas-limit',
|
|
71
|
+
},
|
|
72
|
+
'allow-out-of-order-exec': {
|
|
73
|
+
alias: 'ooo',
|
|
74
|
+
type: 'boolean',
|
|
75
|
+
describe:
|
|
76
|
+
'Allow execution of messages out of order (i.e. sender nonce not enforced, only v1.5+ lanes, mandatory for some dests)',
|
|
77
|
+
},
|
|
78
|
+
'fee-token': {
|
|
79
|
+
type: 'string',
|
|
80
|
+
describe:
|
|
81
|
+
'Address or symbol of the fee token (e.g. LINK address on source); if not provided, will pay in native',
|
|
82
|
+
},
|
|
83
|
+
'transfer-tokens': {
|
|
84
|
+
alias: 't',
|
|
85
|
+
type: 'array',
|
|
86
|
+
string: true,
|
|
87
|
+
describe: 'List of token amounts (on source) to transfer to the receiver',
|
|
88
|
+
example: '0xtoken=0.1',
|
|
89
|
+
},
|
|
90
|
+
wallet: {
|
|
91
|
+
alias: 'w',
|
|
92
|
+
type: 'string',
|
|
93
|
+
describe:
|
|
94
|
+
'Wallet to send transactions with; pass `ledger[:index_or_derivation]` to use Ledger USB hardware wallet, or private key in `USER_KEY` environment variable',
|
|
95
|
+
},
|
|
96
|
+
'token-receiver': {
|
|
97
|
+
type: 'string',
|
|
98
|
+
describe: "Address of the Solana tokenReceiver (if different than program's receiver)",
|
|
99
|
+
},
|
|
100
|
+
account: {
|
|
101
|
+
type: 'array',
|
|
102
|
+
string: true,
|
|
103
|
+
describe:
|
|
104
|
+
'List of accounts needed by Solana receiver program; append `=rw` to specify account as writable; can be specified multiple times',
|
|
105
|
+
example: 'requiredPdaAddress=rw',
|
|
106
|
+
},
|
|
107
|
+
'only-get-fee': {
|
|
108
|
+
type: 'boolean',
|
|
109
|
+
describe: 'Fetch and print the fee for the transaction, then exit',
|
|
110
|
+
},
|
|
111
|
+
'only-estimate': {
|
|
112
|
+
type: 'boolean',
|
|
113
|
+
describe: 'Only estimate dest exec gasLimit',
|
|
114
|
+
},
|
|
115
|
+
'approve-max': {
|
|
116
|
+
type: 'boolean',
|
|
117
|
+
describe:
|
|
118
|
+
"Approve the maximum amount of tokens to transfer; default=false approves only what's needed",
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
.check(
|
|
122
|
+
({ 'transfer-tokens': transferTokens }) =>
|
|
123
|
+
!transferTokens || transferTokens.every((t) => /^[^=]+=\d+(\.\d+)?$/.test(t)),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
export async function handler(argv: Awaited<ReturnType<typeof builder>['argv']> & GlobalOpts) {
|
|
127
|
+
let destroy
|
|
128
|
+
const destroy$ = new Promise((resolve) => {
|
|
129
|
+
destroy = resolve
|
|
130
|
+
})
|
|
131
|
+
return sendMessage(argv, destroy$)
|
|
132
|
+
.catch((err) => {
|
|
133
|
+
process.exitCode = 1
|
|
134
|
+
if (!logParsedError(err)) console.error(err)
|
|
135
|
+
})
|
|
136
|
+
.finally(destroy)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function sendMessage(
|
|
140
|
+
argv: Awaited<ReturnType<typeof builder>['argv']> & GlobalOpts,
|
|
141
|
+
destroy: Promise<unknown>,
|
|
142
|
+
) {
|
|
143
|
+
const sourceNetwork = networkInfo(argv.source)
|
|
144
|
+
const destNetwork = networkInfo(argv.dest)
|
|
145
|
+
const getChain = fetchChainsFromRpcs(argv, undefined, destroy)
|
|
146
|
+
const source = await getChain(sourceNetwork.name)
|
|
147
|
+
|
|
148
|
+
let data: BytesLike
|
|
149
|
+
if (argv.data) {
|
|
150
|
+
try {
|
|
151
|
+
data = getDataBytes(argv.data)
|
|
152
|
+
} catch (_) {
|
|
153
|
+
data = toUtf8Bytes(argv.data)
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
data = '0x'
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const tokenAmounts: { token: string; amount: bigint }[] = argv.transferTokens?.length
|
|
160
|
+
? await parseTokenAmounts(source, argv.transferTokens)
|
|
161
|
+
: []
|
|
162
|
+
|
|
163
|
+
let receiver = argv.receiver
|
|
164
|
+
let tokenReceiver
|
|
165
|
+
let accounts,
|
|
166
|
+
accountIsWritableBitmap = 0n
|
|
167
|
+
if (destNetwork.family === ChainFamily.Solana) {
|
|
168
|
+
if (argv.tokenReceiver) tokenReceiver = argv.tokenReceiver
|
|
169
|
+
else if (!tokenAmounts.length) {
|
|
170
|
+
tokenReceiver = '11111111111111111111111111111111'
|
|
171
|
+
} else if (!dataLength(data)) {
|
|
172
|
+
// sending tokens without data, i.e. not for a receiver contract
|
|
173
|
+
tokenReceiver = receiver
|
|
174
|
+
receiver = '11111111111111111111111111111111'
|
|
175
|
+
} else {
|
|
176
|
+
throw new Error('--token-receiver is required when sending tokens with data')
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (argv.account) {
|
|
180
|
+
accounts = argv.account.map((account, i) => {
|
|
181
|
+
if (account.endsWith('=rw')) {
|
|
182
|
+
accountIsWritableBitmap |= 1n << BigInt(i)
|
|
183
|
+
account = account.substring(0, account.length - 3)
|
|
184
|
+
}
|
|
185
|
+
return account
|
|
186
|
+
})
|
|
187
|
+
} else accounts = [] as string[]
|
|
188
|
+
} else if (argv.tokenReceiver || argv.account?.length) {
|
|
189
|
+
throw new Error('--token-receiver and --account intended only for Solana dest')
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!receiver) {
|
|
193
|
+
if (sourceNetwork.family !== destNetwork.family)
|
|
194
|
+
throw new Error('--receiver is required when sending to a different chain family')
|
|
195
|
+
receiver = await source.getWalletAddress(argv) // send to self if same family
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (argv.estimateGasLimit != null || argv.onlyEstimate) {
|
|
199
|
+
// TODO: implement for all chain families
|
|
200
|
+
if (destNetwork.family !== ChainFamily.EVM)
|
|
201
|
+
throw new Error(`Estimating gasLimit supported only on EVM, got=${destNetwork.family}`)
|
|
202
|
+
const dest = (await getChain(destNetwork.chainSelector)) as unknown as EVMChain
|
|
203
|
+
const onRamp = await source.getOnRampForRouter(argv.router, destNetwork.chainSelector)
|
|
204
|
+
const lane = {
|
|
205
|
+
sourceChainSelector: source.network.chainSelector,
|
|
206
|
+
destChainSelector: destNetwork.chainSelector,
|
|
207
|
+
onRamp,
|
|
208
|
+
version: (await source.typeAndVersion(onRamp))[1] as CCIPVersion,
|
|
209
|
+
}
|
|
210
|
+
const destTokenAmounts = await sourceToDestTokenAmounts(
|
|
211
|
+
source,
|
|
212
|
+
destNetwork.chainSelector,
|
|
213
|
+
onRamp,
|
|
214
|
+
tokenAmounts,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
const estimated = await estimateExecGasForRequest(source, dest, {
|
|
218
|
+
lane,
|
|
219
|
+
message: {
|
|
220
|
+
sender: await source.getWalletAddress(argv),
|
|
221
|
+
receiver,
|
|
222
|
+
data,
|
|
223
|
+
tokenAmounts: destTokenAmounts,
|
|
224
|
+
},
|
|
225
|
+
})
|
|
226
|
+
console.log('Estimated gasLimit:', estimated)
|
|
227
|
+
argv.gasLimit = Math.ceil(estimated * (1 + (argv.estimateGasLimit ?? 0) / 100))
|
|
228
|
+
if (argv.onlyEstimate) return
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// `--allow-out-of-order-exec` forces EVMExtraArgsV2, which shouldn't work on v1.2 lanes;
|
|
232
|
+
// otherwise, fallsback to EVMExtraArgsV1 (compatible with v1.2 & v1.5)
|
|
233
|
+
const extraArgs = {
|
|
234
|
+
...(argv.allowOutOfOrderExec != null || destNetwork.family !== ChainFamily.EVM
|
|
235
|
+
? { allowOutOfOrderExecution: !!argv.allowOutOfOrderExec }
|
|
236
|
+
: {}),
|
|
237
|
+
...(destNetwork.family === ChainFamily.Solana
|
|
238
|
+
? { computeUnits: BigInt(argv.gasLimit) }
|
|
239
|
+
: { gasLimit: BigInt(argv.gasLimit) }),
|
|
240
|
+
...(tokenReceiver ? { tokenReceiver } : {}),
|
|
241
|
+
...(accounts ? { accounts, accountIsWritableBitmap } : {}),
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let feeToken, feeTokenInfo
|
|
245
|
+
if (argv.feeToken) {
|
|
246
|
+
try {
|
|
247
|
+
feeToken = (source.constructor as ChainStatic).getAddress(argv.feeToken)
|
|
248
|
+
feeTokenInfo = await source.getTokenInfo(feeToken)
|
|
249
|
+
} catch (_) {
|
|
250
|
+
const feeTokens = await source.listFeeTokens(argv.router)
|
|
251
|
+
console.debug('supported feeTokens:', feeTokens)
|
|
252
|
+
for (const [token, info] of Object.entries(feeTokens)) {
|
|
253
|
+
if (info.symbol === 'UNKNOWN' || info.symbol !== argv.feeToken) continue
|
|
254
|
+
feeToken = token
|
|
255
|
+
feeTokenInfo = info
|
|
256
|
+
break
|
|
257
|
+
}
|
|
258
|
+
if (!feeTokenInfo) throw new Error(`Fee token "${argv.feeToken}" not found`)
|
|
259
|
+
}
|
|
260
|
+
} else {
|
|
261
|
+
const nativeToken = await source.getNativeTokenForRouter(argv.router)
|
|
262
|
+
feeTokenInfo = await source.getTokenInfo(nativeToken)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const message: AnyMessage = {
|
|
266
|
+
receiver,
|
|
267
|
+
data,
|
|
268
|
+
extraArgs: extraArgs as ExtraArgs,
|
|
269
|
+
feeToken, // feeToken==ZeroAddress means native
|
|
270
|
+
tokenAmounts,
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// calculate fee
|
|
274
|
+
const fee = await source.getFee(argv.router, destNetwork.chainSelector, message)
|
|
275
|
+
|
|
276
|
+
console.info(
|
|
277
|
+
'Fee:',
|
|
278
|
+
fee,
|
|
279
|
+
'=',
|
|
280
|
+
formatUnits(fee, feeTokenInfo.decimals),
|
|
281
|
+
!argv.feeToken && feeTokenInfo.symbol.startsWith('W')
|
|
282
|
+
? feeTokenInfo.symbol.substring(1)
|
|
283
|
+
: feeTokenInfo.symbol,
|
|
284
|
+
)
|
|
285
|
+
if (argv.onlyGetFee) return
|
|
286
|
+
|
|
287
|
+
const tx = await source.sendMessage(
|
|
288
|
+
argv.router,
|
|
289
|
+
destNetwork.chainSelector,
|
|
290
|
+
{ ...message, fee },
|
|
291
|
+
argv,
|
|
292
|
+
)
|
|
293
|
+
console.log(
|
|
294
|
+
'🚀 Sending message to',
|
|
295
|
+
tokenReceiver || receiver,
|
|
296
|
+
'@',
|
|
297
|
+
destNetwork.name,
|
|
298
|
+
', tx =>',
|
|
299
|
+
tx.hash,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
// print CCIPRequest from tx receipt
|
|
303
|
+
const request = (await fetchCCIPRequestsInTx(tx))[0]
|
|
304
|
+
|
|
305
|
+
switch (argv.format) {
|
|
306
|
+
case Format.log:
|
|
307
|
+
console.log(`message ${request.log.index} =`, withDateTimestamp(request))
|
|
308
|
+
break
|
|
309
|
+
case Format.pretty:
|
|
310
|
+
await prettyRequest(source, request)
|
|
311
|
+
break
|
|
312
|
+
case Format.json:
|
|
313
|
+
console.info(JSON.stringify(request, bigIntReplacer, 2))
|
|
314
|
+
break
|
|
315
|
+
}
|
|
316
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import util from 'util'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type CCIPRequest,
|
|
5
|
+
ChainFamily,
|
|
6
|
+
bigIntReplacer,
|
|
7
|
+
discoverOffRamp,
|
|
8
|
+
fetchCCIPMessageById,
|
|
9
|
+
fetchCCIPRequestsInTx,
|
|
10
|
+
networkInfo,
|
|
11
|
+
} from '@chainlink/ccip-sdk/src/index.ts'
|
|
12
|
+
import type { Argv } from 'yargs'
|
|
13
|
+
|
|
14
|
+
import type { GlobalOpts } from '../index.ts'
|
|
15
|
+
import { Format } from './types.ts'
|
|
16
|
+
import {
|
|
17
|
+
logParsedError,
|
|
18
|
+
prettyCommit,
|
|
19
|
+
prettyReceipt,
|
|
20
|
+
prettyRequest,
|
|
21
|
+
selectRequest,
|
|
22
|
+
withDateTimestamp,
|
|
23
|
+
} from './utils.ts'
|
|
24
|
+
import { fetchChainsFromRpcs } from '../providers/index.ts'
|
|
25
|
+
|
|
26
|
+
export const command = ['show <tx-hash>', '* <tx-hash>']
|
|
27
|
+
export const describe = 'Show details of a CCIP request'
|
|
28
|
+
|
|
29
|
+
export const builder = (yargs: Argv) =>
|
|
30
|
+
yargs
|
|
31
|
+
.positional('tx-hash', {
|
|
32
|
+
type: 'string',
|
|
33
|
+
demandOption: true,
|
|
34
|
+
describe: 'transaction hash of the request (source) message',
|
|
35
|
+
})
|
|
36
|
+
.options({
|
|
37
|
+
'log-index': {
|
|
38
|
+
type: 'number',
|
|
39
|
+
describe:
|
|
40
|
+
'Pre-select a message request by logIndex, if more than one in tx; by default, a selection menu is shown',
|
|
41
|
+
},
|
|
42
|
+
'id-from-source': {
|
|
43
|
+
type: 'string',
|
|
44
|
+
describe: 'Search by messageId instead of txHash; requires specifying source network',
|
|
45
|
+
},
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
export async function handler(argv: Awaited<ReturnType<typeof builder>['argv']> & GlobalOpts) {
|
|
49
|
+
let destroy
|
|
50
|
+
const destroy$ = new Promise((resolve) => {
|
|
51
|
+
destroy = resolve
|
|
52
|
+
})
|
|
53
|
+
return showRequests(argv, destroy$)
|
|
54
|
+
.catch((err) => {
|
|
55
|
+
process.exitCode = 1
|
|
56
|
+
if (!logParsedError(err)) console.error(err)
|
|
57
|
+
})
|
|
58
|
+
.finally(destroy)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function showRequests(argv: Parameters<typeof handler>[0], destroy: Promise<unknown>) {
|
|
62
|
+
let source, getChain, tx, request: CCIPRequest
|
|
63
|
+
// messageId not yet implemented for Solana
|
|
64
|
+
if (argv.idFromSource) {
|
|
65
|
+
getChain = fetchChainsFromRpcs(argv, undefined, destroy)
|
|
66
|
+
const sourceNetwork = networkInfo(argv.idFromSource)
|
|
67
|
+
if (sourceNetwork.family === ChainFamily.Solana) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Message ID search is not yet supported for Solana networks.\n` +
|
|
70
|
+
`Please use show with Solana transaction signature instead`,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
source = await getChain(sourceNetwork.chainId)
|
|
74
|
+
request = await fetchCCIPMessageById(source, argv.txHash, argv)
|
|
75
|
+
} else {
|
|
76
|
+
const [getChain_, tx$] = fetchChainsFromRpcs(argv, argv.txHash, destroy)
|
|
77
|
+
getChain = getChain_
|
|
78
|
+
tx = await tx$
|
|
79
|
+
source = tx.chain
|
|
80
|
+
request = await selectRequest(await fetchCCIPRequestsInTx(tx), 'to know more', argv)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const offchainTokenData = await source.fetchOffchainTokenData(request)
|
|
84
|
+
|
|
85
|
+
switch (argv.format) {
|
|
86
|
+
case Format.log: {
|
|
87
|
+
console.log(
|
|
88
|
+
`message ${request.log.index} =`,
|
|
89
|
+
withDateTimestamp(request),
|
|
90
|
+
'\nattestations =',
|
|
91
|
+
offchainTokenData,
|
|
92
|
+
)
|
|
93
|
+
break
|
|
94
|
+
}
|
|
95
|
+
case Format.pretty:
|
|
96
|
+
await prettyRequest(source, request, offchainTokenData)
|
|
97
|
+
break
|
|
98
|
+
case Format.json:
|
|
99
|
+
console.info(JSON.stringify({ ...request, offchainTokenData }, bigIntReplacer, 2))
|
|
100
|
+
break
|
|
101
|
+
}
|
|
102
|
+
if (request.tx.error) throw new Error(`Request tx reverted: ${util.inspect(request.tx.error)}`)
|
|
103
|
+
|
|
104
|
+
const dest = await getChain(request.lane.destChainSelector)
|
|
105
|
+
const offRamp = await discoverOffRamp(source, dest, request.lane.onRamp)
|
|
106
|
+
const commitStore = await dest.getCommitStoreForOffRamp(offRamp)
|
|
107
|
+
|
|
108
|
+
const commit = await dest.fetchCommitReport(commitStore, request, argv)
|
|
109
|
+
switch (argv.format) {
|
|
110
|
+
case Format.log:
|
|
111
|
+
console.log('commit =', commit)
|
|
112
|
+
break
|
|
113
|
+
case Format.pretty:
|
|
114
|
+
await prettyCommit(dest, commit, request)
|
|
115
|
+
break
|
|
116
|
+
case Format.json:
|
|
117
|
+
console.info(JSON.stringify(commit, bigIntReplacer, 2))
|
|
118
|
+
break
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let found = false
|
|
122
|
+
for await (const receipt of dest.fetchExecutionReceipts(
|
|
123
|
+
offRamp,
|
|
124
|
+
new Set([request.message.header.messageId]),
|
|
125
|
+
{
|
|
126
|
+
startBlock: commit.log.blockNumber,
|
|
127
|
+
page: argv.page,
|
|
128
|
+
commit: commit.report,
|
|
129
|
+
},
|
|
130
|
+
)) {
|
|
131
|
+
switch (argv.format) {
|
|
132
|
+
case Format.log:
|
|
133
|
+
console.log('receipt =', withDateTimestamp(receipt))
|
|
134
|
+
break
|
|
135
|
+
case Format.pretty:
|
|
136
|
+
if (!found) console.info('Receipts (dest):')
|
|
137
|
+
prettyReceipt(
|
|
138
|
+
receipt,
|
|
139
|
+
request,
|
|
140
|
+
receipt.log.tx?.from ??
|
|
141
|
+
(await dest.getTransaction(receipt.log.transactionHash).catch(() => null))?.from,
|
|
142
|
+
)
|
|
143
|
+
break
|
|
144
|
+
case Format.json:
|
|
145
|
+
console.info(JSON.stringify(receipt, bigIntReplacer, 2))
|
|
146
|
+
break
|
|
147
|
+
}
|
|
148
|
+
found = true
|
|
149
|
+
}
|
|
150
|
+
if (!found) console.warn(`No execution receipt found for request`)
|
|
151
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CCIP Token Discovery Service
|
|
3
|
+
*
|
|
4
|
+
* Discovers and validates tokens that can be transferred between chains using Chainlink's CCIP.
|
|
5
|
+
* The service handles pagination, parallel processing, and comprehensive error collection.
|
|
6
|
+
*
|
|
7
|
+
* Architecture:
|
|
8
|
+
* 1. Chain & Contract Setup: Validates cross-chain paths and initializes core contracts
|
|
9
|
+
* 2. Token Discovery: Fetches all registered tokens with pagination
|
|
10
|
+
* 3. Support Validation: Checks token support for destination chain
|
|
11
|
+
* 4. Detail Collection: Gathers token and pool information in parallel
|
|
12
|
+
*
|
|
13
|
+
* Performance Considerations:
|
|
14
|
+
* - Uses batching to prevent RPC timeouts (configurable batch sizes)
|
|
15
|
+
* - Implements parallel processing with rate limiting
|
|
16
|
+
* - Memory-efficient token processing through pagination
|
|
17
|
+
*
|
|
18
|
+
* Error Handling:
|
|
19
|
+
* - Individual token failures don't halt the process
|
|
20
|
+
* - Errors are collected and reported comprehensively
|
|
21
|
+
* - Detailed error reporting for debugging
|
|
22
|
+
*
|
|
23
|
+
* @module supported-tokens
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
type Chain,
|
|
28
|
+
type RateLimiterState,
|
|
29
|
+
bigIntReplacer,
|
|
30
|
+
networkInfo,
|
|
31
|
+
} from '@chainlink/ccip-sdk/src/index.ts'
|
|
32
|
+
import { search } from '@inquirer/prompts'
|
|
33
|
+
import { formatUnits } from 'ethers'
|
|
34
|
+
import type { Argv } from 'yargs'
|
|
35
|
+
|
|
36
|
+
import { Format } from './types.ts'
|
|
37
|
+
import { formatDuration, logParsedError, prettyTable } from './utils.ts'
|
|
38
|
+
import type { GlobalOpts } from '../index.ts'
|
|
39
|
+
import { fetchChainsFromRpcs } from '../providers/index.ts'
|
|
40
|
+
|
|
41
|
+
export const command = ['getSupportedTokens <source> <address> [token]']
|
|
42
|
+
export const describe =
|
|
43
|
+
'List supported tokens in a given Router/OnRamp/TokenAdminRegistry, and/or show info about token/pool'
|
|
44
|
+
|
|
45
|
+
export const builder = (yargs: Argv) =>
|
|
46
|
+
yargs
|
|
47
|
+
.positional('source', {
|
|
48
|
+
type: 'string',
|
|
49
|
+
demandOption: true,
|
|
50
|
+
describe: 'source network, chainId or name',
|
|
51
|
+
example: 'ethereum-testnet-sepolia',
|
|
52
|
+
})
|
|
53
|
+
.positional('address', {
|
|
54
|
+
type: 'string',
|
|
55
|
+
demandOption: true,
|
|
56
|
+
describe: 'router/onramp/tokenAdminRegistry/tokenPool contract address on source',
|
|
57
|
+
})
|
|
58
|
+
.positional('token', {
|
|
59
|
+
type: 'string',
|
|
60
|
+
demandOption: false,
|
|
61
|
+
describe:
|
|
62
|
+
'If address is router/onramp/tokenAdminRegistry, token may be used to pre-select a token from the supported list',
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
export async function handler(argv: Awaited<ReturnType<typeof builder>['argv']> & GlobalOpts) {
|
|
66
|
+
let destroy
|
|
67
|
+
const destroy$ = new Promise((resolve) => {
|
|
68
|
+
destroy = resolve
|
|
69
|
+
})
|
|
70
|
+
return getSupportedTokens(argv, destroy$)
|
|
71
|
+
.catch((err) => {
|
|
72
|
+
process.exitCode = 1
|
|
73
|
+
if (!logParsedError(err)) console.error(err)
|
|
74
|
+
})
|
|
75
|
+
.finally(destroy)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function getSupportedTokens(argv: Parameters<typeof handler>[0], destroy: Promise<unknown>) {
|
|
79
|
+
const sourceNetwork = networkInfo(argv.source)
|
|
80
|
+
const getChain = fetchChainsFromRpcs(argv, undefined, destroy)
|
|
81
|
+
const source = await getChain(sourceNetwork.name)
|
|
82
|
+
let registry
|
|
83
|
+
try {
|
|
84
|
+
registry = await source.getTokenAdminRegistryFor(argv.address)
|
|
85
|
+
} catch (_) {
|
|
86
|
+
// ignore
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let info, tokenPool, poolConfigs, registryConfig
|
|
90
|
+
if (registry && !argv.token) {
|
|
91
|
+
const feeTokens = await source.listFeeTokens(argv.address)
|
|
92
|
+
switch (argv.format) {
|
|
93
|
+
case Format.pretty:
|
|
94
|
+
console.info('Fee Tokens:')
|
|
95
|
+
console.table(feeTokens)
|
|
96
|
+
break
|
|
97
|
+
case Format.json:
|
|
98
|
+
console.log(JSON.stringify(feeTokens, null, 2))
|
|
99
|
+
break
|
|
100
|
+
default:
|
|
101
|
+
console.log('feeTokens:', feeTokens)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// router + interactive list
|
|
105
|
+
info = await listTokens(source, registry, argv)
|
|
106
|
+
if (!info) return // format != pretty
|
|
107
|
+
registryConfig = await source.getRegistryTokenConfig(registry, info.token)
|
|
108
|
+
tokenPool = registryConfig.tokenPool
|
|
109
|
+
if (!tokenPool)
|
|
110
|
+
throw new Error(`TokenPool not set in tokenAdminRegistry=${registry} for token=${info.token}`)
|
|
111
|
+
poolConfigs = await source.getTokenPoolConfigs(tokenPool)
|
|
112
|
+
} else {
|
|
113
|
+
if (!argv.token) {
|
|
114
|
+
// tokenPool
|
|
115
|
+
tokenPool = argv.address
|
|
116
|
+
poolConfigs = await source.getTokenPoolConfigs(tokenPool)
|
|
117
|
+
registry ??= await source.getTokenAdminRegistryFor(poolConfigs.router)
|
|
118
|
+
;[info, registryConfig] = await Promise.all([
|
|
119
|
+
source.getTokenInfo(poolConfigs.token),
|
|
120
|
+
source.getRegistryTokenConfig(registry, poolConfigs.token),
|
|
121
|
+
])
|
|
122
|
+
} else {
|
|
123
|
+
registry ??= await source.getTokenAdminRegistryFor(argv.address)
|
|
124
|
+
// router|ramp|registry + token
|
|
125
|
+
info = await source.getTokenInfo(argv.token)
|
|
126
|
+
|
|
127
|
+
registryConfig = await source.getRegistryTokenConfig(registry, argv.token)
|
|
128
|
+
tokenPool = registryConfig.tokenPool
|
|
129
|
+
if (!tokenPool)
|
|
130
|
+
throw new Error(
|
|
131
|
+
`TokenPool not set in tokenAdminRegistry=${registry} for token=${argv.token}`,
|
|
132
|
+
)
|
|
133
|
+
poolConfigs = await source.getTokenPoolConfigs(tokenPool)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (argv.format === Format.json) {
|
|
137
|
+
console.log(JSON.stringify({ ...info, tokenPool, ...poolConfigs }, bigIntReplacer, 2))
|
|
138
|
+
return
|
|
139
|
+
} else if (argv.format === Format.log) {
|
|
140
|
+
console.log('Token:', poolConfigs.token, info)
|
|
141
|
+
console.log('Token Pool:', tokenPool)
|
|
142
|
+
console.log('Pool Configs:', poolConfigs)
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const remotes = await source.getTokenPoolRemotes(tokenPool)
|
|
147
|
+
|
|
148
|
+
prettyTable({
|
|
149
|
+
network: `${source.network.name} [${source.network.chainSelector}]`,
|
|
150
|
+
token: poolConfigs.token,
|
|
151
|
+
symbol: info.symbol,
|
|
152
|
+
name: info.name,
|
|
153
|
+
decimals: info.decimals,
|
|
154
|
+
tokenPool,
|
|
155
|
+
typeAndVersion: poolConfigs.typeAndVersion,
|
|
156
|
+
router: poolConfigs.router,
|
|
157
|
+
tokenAdminRegistry: registry,
|
|
158
|
+
administrator: registryConfig.administrator,
|
|
159
|
+
...(registryConfig.pendingAdministrator && {
|
|
160
|
+
pendingAdministrator: registryConfig.pendingAdministrator,
|
|
161
|
+
}),
|
|
162
|
+
})
|
|
163
|
+
const remotesLen = Object.keys(remotes).length
|
|
164
|
+
if (remotesLen > 0) console.info('Remotes [', remotesLen, ']:')
|
|
165
|
+
for (const [network, remote] of Object.entries(remotes))
|
|
166
|
+
prettyTable({
|
|
167
|
+
remoteNetwork: `${network} [${networkInfo(network).chainSelector}]`,
|
|
168
|
+
remoteToken: remote.remoteToken,
|
|
169
|
+
remotePool: remote.remotePools,
|
|
170
|
+
inbound: prettyRateLimiter(remote.inboundRateLimiterState, info),
|
|
171
|
+
outbound: prettyRateLimiter(remote.outboundRateLimiterState, info),
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function listTokens(source: Chain, registry: string, argv: GlobalOpts) {
|
|
176
|
+
const tokens = await source.getSupportedTokens(registry)
|
|
177
|
+
const infos: { token: string; symbol: string; decimals: number; name?: string }[] = []
|
|
178
|
+
const batch = 500
|
|
179
|
+
for (let i = 0; i < tokens.length; i += batch) {
|
|
180
|
+
const infos_ = (
|
|
181
|
+
await Promise.all(
|
|
182
|
+
tokens.slice(i, i + batch).map((token) =>
|
|
183
|
+
source.getTokenInfo(token).then(
|
|
184
|
+
(info) => {
|
|
185
|
+
const res = { token, ...info }
|
|
186
|
+
if (argv.format === Format.log) {
|
|
187
|
+
// Format.log prints out-of-order, as it fetches data, concurrently
|
|
188
|
+
console.info(token, '=', info)
|
|
189
|
+
}
|
|
190
|
+
return res
|
|
191
|
+
},
|
|
192
|
+
(err) => {
|
|
193
|
+
console.debug(`getTokenInfo errored`, token, err)
|
|
194
|
+
},
|
|
195
|
+
),
|
|
196
|
+
),
|
|
197
|
+
)
|
|
198
|
+
).filter((e) => e !== undefined)
|
|
199
|
+
if (argv.format === Format.json) {
|
|
200
|
+
// Format.json keeps order, prints newline-separated objects
|
|
201
|
+
for (const info of infos_) {
|
|
202
|
+
console.log(JSON.stringify(info))
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
infos.push(...infos_)
|
|
206
|
+
}
|
|
207
|
+
if (argv.format !== Format.pretty) return // Format.pretty interactive search and details
|
|
208
|
+
|
|
209
|
+
return search({
|
|
210
|
+
message: 'Select a token to know more:',
|
|
211
|
+
pageSize: 20,
|
|
212
|
+
source: (term) => {
|
|
213
|
+
const filtered = infos.filter(
|
|
214
|
+
(info) =>
|
|
215
|
+
!term ||
|
|
216
|
+
`${info.token} ${info.symbol} ${info.name ?? ''} ${info.decimals}`
|
|
217
|
+
.toLowerCase()
|
|
218
|
+
.includes(term.toLowerCase()),
|
|
219
|
+
)
|
|
220
|
+
const symbolPad = Math.min(Math.max(...filtered.map(({ symbol }) => symbol.length)), 10)
|
|
221
|
+
const decimalsPad = Math.max(...filtered.map(({ decimals }) => decimals.toString().length))
|
|
222
|
+
return filtered.map((info, i) => ({
|
|
223
|
+
name: `${info.token}\t[${info.decimals.toString().padStart(decimalsPad)}] ${info.symbol.padEnd(symbolPad)}\t${info.name ?? ''}`,
|
|
224
|
+
value: info,
|
|
225
|
+
short: `${info.token} [${info.symbol}]`,
|
|
226
|
+
description: `${i + 1} / ${filtered.length} / ${tokens.length}`,
|
|
227
|
+
}))
|
|
228
|
+
},
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function prettyRateLimiter(
|
|
233
|
+
state: RateLimiterState,
|
|
234
|
+
{ decimals, symbol }: { decimals: number; symbol: string },
|
|
235
|
+
) {
|
|
236
|
+
if (!state) return null
|
|
237
|
+
return {
|
|
238
|
+
capacity: formatUnits(state.capacity, decimals) + ' ' + symbol,
|
|
239
|
+
tokens: `${formatUnits(state.tokens, decimals)} (${Math.round((Number(state.tokens) / Number(state.capacity)) * 100)}%)`,
|
|
240
|
+
rate: `${formatUnits(state.rate, decimals)}/s (0-to-full in ${formatDuration(Number(state.capacity / state.rate))})`,
|
|
241
|
+
...(state.tokens < state.capacity && {
|
|
242
|
+
timeToFull: formatDuration(Number(state.capacity - state.tokens) / Number(state.rate)),
|
|
243
|
+
}),
|
|
244
|
+
}
|
|
245
|
+
}
|