web3-tools-mcp 1.3.4 → 1.5.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.
Files changed (65) hide show
  1. package/README.md +63 -5
  2. package/dist/anvil.d.ts +34 -0
  3. package/dist/anvil.d.ts.map +1 -0
  4. package/dist/anvil.js +254 -0
  5. package/dist/anvil.js.map +1 -0
  6. package/dist/chain-meta.d.ts +27 -0
  7. package/dist/chain-meta.d.ts.map +1 -0
  8. package/dist/chain-meta.js +92 -0
  9. package/dist/chain-meta.js.map +1 -0
  10. package/dist/clear-signing.d.ts +62 -0
  11. package/dist/clear-signing.d.ts.map +1 -0
  12. package/dist/clear-signing.js +139 -0
  13. package/dist/clear-signing.js.map +1 -0
  14. package/dist/client.d.ts +3 -0
  15. package/dist/client.d.ts.map +1 -1
  16. package/dist/client.js +11 -0
  17. package/dist/client.js.map +1 -1
  18. package/dist/erc7730-index.json.gz +0 -0
  19. package/dist/index.js +25 -5
  20. package/dist/index.js.map +1 -1
  21. package/dist/preview.d.ts +58 -0
  22. package/dist/preview.d.ts.map +1 -0
  23. package/dist/preview.js +182 -0
  24. package/dist/preview.js.map +1 -0
  25. package/dist/tools/advanced.d.ts +67 -9
  26. package/dist/tools/advanced.d.ts.map +1 -1
  27. package/dist/tools/advanced.js +206 -67
  28. package/dist/tools/advanced.js.map +1 -1
  29. package/dist/tools/balance.d.ts +5 -5
  30. package/dist/tools/contract-info.d.ts +9 -9
  31. package/dist/tools/contract.d.ts +8 -8
  32. package/dist/tools/ens.d.ts +15 -15
  33. package/dist/tools/gas.d.ts +18 -18
  34. package/dist/tools/logs.d.ts +3 -3
  35. package/dist/tools/transactions.d.ts +11 -11
  36. package/dist/tools/transactions.d.ts.map +1 -1
  37. package/dist/tools/transactions.js +92 -113
  38. package/dist/tools/transactions.js.map +1 -1
  39. package/dist/utils.d.ts +13 -0
  40. package/dist/utils.d.ts.map +1 -1
  41. package/dist/utils.js +76 -0
  42. package/dist/utils.js.map +1 -1
  43. package/dist/wallet-client.d.ts +83 -0
  44. package/dist/wallet-client.d.ts.map +1 -0
  45. package/dist/wallet-client.js +357 -0
  46. package/dist/wallet-client.js.map +1 -0
  47. package/package.json +11 -9
  48. package/src/anvil.ts +323 -0
  49. package/src/chain-meta.ts +102 -0
  50. package/src/clear-signing.ts +206 -0
  51. package/src/client.ts +14 -0
  52. package/src/erc7730-index.json.gz +0 -0
  53. package/src/index.ts +26 -5
  54. package/src/preview.ts +265 -0
  55. package/src/tools/advanced.ts +239 -70
  56. package/src/tools/transactions.ts +98 -125
  57. package/src/utils.ts +95 -0
  58. package/src/wallet-client.ts +380 -0
  59. package/dist/wallet-server.d.ts +0 -35
  60. package/dist/wallet-server.d.ts.map +0 -1
  61. package/dist/wallet-server.js +0 -232
  62. package/dist/wallet-server.js.map +0 -1
  63. package/public/wallet-app.js +0 -677
  64. package/public/wallet.html +0 -723
  65. package/src/wallet-server.ts +0 -283
package/src/anvil.ts ADDED
@@ -0,0 +1,323 @@
1
+ import { spawn, ChildProcess } from 'child_process'
2
+ import { createPublicClient, http } from 'viem'
3
+ import { mainnet } from 'viem/chains'
4
+
5
+ export interface AnvilInstance {
6
+ process: ChildProcess
7
+ port: number
8
+ rpcUrl: string
9
+ client: ReturnType<typeof createPublicClient>
10
+ }
11
+
12
+ export interface AnvilConfig {
13
+ forkUrl: string
14
+ forkBlockNumber?: bigint
15
+ port?: number
16
+ chainId?: number
17
+ }
18
+
19
+ // Check if anvil is installed
20
+ export async function isAnvilInstalled(): Promise<boolean> {
21
+ return new Promise(resolve => {
22
+ const proc = spawn('anvil', ['--version'], { shell: true })
23
+ proc.on('close', code => resolve(code === 0))
24
+ proc.on('error', () => resolve(false))
25
+ })
26
+ }
27
+
28
+ // Find an available port
29
+ async function findAvailablePort(startPort: number = 8545): Promise<number> {
30
+ const net = await import('net')
31
+ return new Promise((resolve, reject) => {
32
+ const server = net.createServer()
33
+ server.listen(startPort, () => {
34
+ const address = server.address()
35
+ if (address && typeof address === 'object') {
36
+ server.close(() => resolve(address.port))
37
+ } else {
38
+ server.close(() => reject(new Error('Could not get port')))
39
+ }
40
+ })
41
+ server.on('error', () => {
42
+ // Port in use, try next one
43
+ findAvailablePort(startPort + 1).then(resolve).catch(reject)
44
+ })
45
+ })
46
+ }
47
+
48
+ // Start an Anvil instance
49
+ export async function startAnvil(config: AnvilConfig): Promise<AnvilInstance> {
50
+ const installed = await isAnvilInstalled()
51
+ if (!installed) {
52
+ throw new Error(
53
+ 'Anvil is not installed. Please install Foundry: https://book.getfoundry.sh/getting-started/installation'
54
+ )
55
+ }
56
+
57
+ const port = config.port ?? (await findAvailablePort())
58
+ const rpcUrl = `http://127.0.0.1:${port}`
59
+
60
+ const args = ['--fork-url', config.forkUrl, '--port', port.toString(), '--silent']
61
+
62
+ if (config.forkBlockNumber !== undefined) {
63
+ args.push('--fork-block-number', config.forkBlockNumber.toString())
64
+ }
65
+
66
+ if (config.chainId !== undefined) {
67
+ args.push('--chain-id', config.chainId.toString())
68
+ }
69
+
70
+ return new Promise((resolve, reject) => {
71
+ const proc = spawn('anvil', args, {
72
+ shell: true,
73
+ stdio: ['ignore', 'pipe', 'pipe']
74
+ })
75
+
76
+ let stderr = ''
77
+ proc.stderr?.on('data', data => {
78
+ stderr += data.toString()
79
+ })
80
+
81
+ proc.on('error', err => {
82
+ reject(new Error(`Failed to start Anvil: ${err.message}`))
83
+ })
84
+
85
+ proc.on('close', code => {
86
+ if (code !== 0 && code !== null) {
87
+ reject(new Error(`Anvil exited with code ${code}: ${stderr}`))
88
+ }
89
+ })
90
+
91
+ // Wait for Anvil to be ready by polling the RPC
92
+ const maxAttempts = 50 // 5 seconds
93
+ let attempts = 0
94
+
95
+ const checkReady = async () => {
96
+ attempts++
97
+ try {
98
+ const response = await fetch(rpcUrl, {
99
+ method: 'POST',
100
+ headers: { 'Content-Type': 'application/json' },
101
+ body: JSON.stringify({
102
+ jsonrpc: '2.0',
103
+ method: 'eth_blockNumber',
104
+ params: [],
105
+ id: 1
106
+ })
107
+ })
108
+ if (response.ok) {
109
+ // Add small delay to ensure all RPC methods are ready (including debug_*)
110
+ await new Promise(r => setTimeout(r, 100))
111
+ const client = createPublicClient({
112
+ chain: mainnet, // Chain is overridden by fork anyway
113
+ transport: http(rpcUrl)
114
+ })
115
+ resolve({ process: proc, port, rpcUrl, client })
116
+ } else if (attempts < maxAttempts) {
117
+ setTimeout(checkReady, 100)
118
+ } else {
119
+ proc.kill()
120
+ reject(new Error('Anvil failed to start within timeout'))
121
+ }
122
+ } catch {
123
+ if (attempts < maxAttempts) {
124
+ setTimeout(checkReady, 100)
125
+ } else {
126
+ proc.kill()
127
+ reject(new Error('Anvil failed to start within timeout'))
128
+ }
129
+ }
130
+ }
131
+
132
+ // Start checking after a small delay
133
+ setTimeout(checkReady, 200)
134
+ })
135
+ }
136
+
137
+ // Stop an Anvil instance
138
+ export function stopAnvil(instance: AnvilInstance): void {
139
+ if (instance.process && !instance.process.killed) {
140
+ instance.process.kill('SIGTERM')
141
+ }
142
+ }
143
+
144
+ // Helper to run a function with a temporary Anvil instance
145
+ export async function withAnvil<T>(
146
+ config: AnvilConfig,
147
+ fn: (instance: AnvilInstance) => Promise<T>
148
+ ): Promise<T> {
149
+ const instance = await startAnvil(config)
150
+ try {
151
+ return await fn(instance)
152
+ } finally {
153
+ stopAnvil(instance)
154
+ }
155
+ }
156
+
157
+ // Archive RPC URLs for chains that need historical state access
158
+ const ARCHIVE_RPCS: Record<string, string> = {
159
+ arbitrum: 'https://arbitrum-one.arpc.x.superfluid.dev'
160
+ }
161
+
162
+ // Trace a transaction using Anvil fork
163
+ export async function traceTransactionWithAnvil(
164
+ forkUrl: string,
165
+ transactionHash: string,
166
+ blockNumber: bigint,
167
+ tracer: 'callTracer' | 'prestateTracer' | 'stateDiffTracer' = 'callTracer',
168
+ chainName?: string
169
+ ): Promise<unknown> {
170
+ // Use archive RPC if available for better historical state access
171
+ const archiveUrl = chainName && ARCHIVE_RPCS[chainName] ? ARCHIVE_RPCS[chainName] : forkUrl
172
+
173
+ // First, fetch the original transaction from the RPC
174
+ const fetchClient = createPublicClient({
175
+ chain: mainnet,
176
+ transport: http(forkUrl)
177
+ })
178
+
179
+ const tx = await fetchClient.getTransaction({
180
+ hash: transactionHash as `0x${string}`
181
+ })
182
+
183
+ if (!tx) {
184
+ throw new Error(`Transaction ${transactionHash} not found`)
185
+ }
186
+
187
+ // Fork at the block BEFORE the transaction to replay it accurately
188
+ // Note: This requires an archival RPC that has historical state
189
+ const forkBlock = blockNumber > 0n ? blockNumber - 1n : 0n
190
+
191
+ return withAnvil(
192
+ {
193
+ forkUrl: archiveUrl,
194
+ forkBlockNumber: forkBlock
195
+ },
196
+ async instance => {
197
+ // Use debug_traceCall to simulate and trace the transaction
198
+ // Note: This replays the tx against the post-block state, which may differ slightly
199
+ // from the original execution if state changed within the block
200
+ const trace = await instance.client.request({
201
+ method: 'debug_traceCall' as never,
202
+ params: [
203
+ {
204
+ from: tx.from,
205
+ to: tx.to,
206
+ data: tx.input,
207
+ value: tx.value ? `0x${tx.value.toString(16)}` : '0x0',
208
+ gas: tx.gas ? `0x${tx.gas.toString(16)}` : undefined
209
+ },
210
+ 'latest',
211
+ { tracer }
212
+ ] as never
213
+ })
214
+
215
+ return trace
216
+ }
217
+ )
218
+ }
219
+
220
+ // Simulate a call with tracing
221
+ export interface SimulateCallParams {
222
+ to: string
223
+ data?: string
224
+ from?: string
225
+ value?: bigint
226
+ gas?: bigint
227
+ }
228
+
229
+ export async function simulateCallWithTrace(
230
+ forkUrl: string,
231
+ params: SimulateCallParams,
232
+ blockNumber?: bigint,
233
+ tracer: 'callTracer' | 'prestateTracer' = 'callTracer'
234
+ ): Promise<{
235
+ result: string
236
+ trace: unknown
237
+ gasUsed: bigint
238
+ success: boolean
239
+ revertReason?: string
240
+ }> {
241
+ return withAnvil(
242
+ {
243
+ forkUrl,
244
+ forkBlockNumber: blockNumber
245
+ },
246
+ async instance => {
247
+ // First, try a regular call to get the result
248
+ let result = '0x'
249
+ let success = true
250
+ let revertReason: string | undefined
251
+ let gasUsed = 0n
252
+
253
+ try {
254
+ // Use eth_call to get the result
255
+ result = (await instance.client.request({
256
+ method: 'eth_call' as never,
257
+ params: [
258
+ {
259
+ to: params.to,
260
+ data: params.data,
261
+ from: params.from ?? '0x0000000000000000000000000000000000000000',
262
+ value: params.value ? `0x${params.value.toString(16)}` : undefined,
263
+ gas: params.gas ? `0x${params.gas.toString(16)}` : undefined
264
+ },
265
+ 'latest'
266
+ ] as never
267
+ })) as string
268
+
269
+ // Estimate gas
270
+ try {
271
+ gasUsed = await instance.client.estimateGas({
272
+ to: params.to as `0x${string}`,
273
+ data: params.data as `0x${string}` | undefined,
274
+ account: params.from as `0x${string}` | undefined,
275
+ value: params.value
276
+ })
277
+ } catch {
278
+ // Ignore gas estimation errors
279
+ }
280
+ } catch (error) {
281
+ success = false
282
+ const errorMessage = (error as Error).message
283
+ // Try to extract revert reason
284
+ const revertMatch = errorMessage.match(/revert(?:ed)?[:\s]*(.+?)(?:\n|$)/i)
285
+ if (revertMatch) {
286
+ revertReason = revertMatch[1].trim()
287
+ } else {
288
+ revertReason = errorMessage
289
+ }
290
+ }
291
+
292
+ // Get the trace using debug_traceCall
293
+ let trace: unknown = null
294
+ try {
295
+ trace = await instance.client.request({
296
+ method: 'debug_traceCall' as never,
297
+ params: [
298
+ {
299
+ to: params.to,
300
+ data: params.data,
301
+ from: params.from ?? '0x0000000000000000000000000000000000000000',
302
+ value: params.value ? `0x${params.value.toString(16)}` : undefined,
303
+ gas: params.gas ? `0x${params.gas.toString(16)}` : undefined
304
+ },
305
+ 'latest',
306
+ { tracer }
307
+ ] as never
308
+ })
309
+ } catch (traceError) {
310
+ // If tracing fails, include the error
311
+ trace = { error: (traceError as Error).message }
312
+ }
313
+
314
+ return {
315
+ result,
316
+ trace,
317
+ gasUsed,
318
+ success,
319
+ revertReason
320
+ }
321
+ }
322
+ )
323
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * On-chain metadata every layer of the preview needs: contract ABIs, token symbols and
3
+ * human labels for addresses. Kept apart from the preview and the clear-signing registry
4
+ * so both can use it without importing each other.
5
+ */
6
+ import { type Abi, type Address, parseAbiItem } from 'viem'
7
+ import { whatsabi } from '@shazow/whatsabi'
8
+ import { getClientManager } from './client.js'
9
+ import type { ChainName } from './types.js'
10
+
11
+ const ERC20_META_ABI = [
12
+ parseAbiItem('function decimals() view returns (uint8)'),
13
+ parseAbiItem('function symbol() view returns (string)')
14
+ ]
15
+
16
+ const abiCache = new Map<string, { abi: Abi; source: 'verified' | 'guessed'; proxy?: string; name?: string }>()
17
+ const labelCache = new Map<string, string | undefined>()
18
+ const metaCache = new Map<string, { symbol?: string; decimals?: number }>()
19
+
20
+ export async function loadAbi(chain: ChainName, address: string) {
21
+ const key = `${chain}:${address.toLowerCase()}`
22
+ const cached = abiCache.get(key)
23
+ if (cached) return cached
24
+
25
+ const clientManager = getClientManager()
26
+ const client = clientManager.getClient(chain)
27
+ const etherscanApiKey = clientManager.getConfig().etherscanApiKey
28
+
29
+ const loaders: whatsabi.loaders.ABILoader[] = [new whatsabi.loaders.SourcifyABILoader({ chainId: clientManager.getChainId(chain) })]
30
+ if (etherscanApiKey) {
31
+ loaders.push(new whatsabi.loaders.EtherscanV2ABILoader({ apiKey: etherscanApiKey, chainId: clientManager.getChainId(chain) }))
32
+ }
33
+
34
+ const result = await whatsabi.autoload(address as Address, {
35
+ provider: client,
36
+ abiLoader: new whatsabi.loaders.MultiABILoader(loaders),
37
+ signatureLookup: new whatsabi.loaders.OpenChainSignatureLookup(),
38
+ followProxies: true
39
+ })
40
+
41
+ // Bytecode-guessed ABIs carry no argument names; verified ones do.
42
+ const source = result.abi.some((item) => item.type === 'function' && item.inputs?.some((i) => i.name)) ? 'verified' : 'guessed'
43
+ const loaded = {
44
+ abi: result.abi as Abi,
45
+ source: source as 'verified' | 'guessed',
46
+ proxy: result.address !== address ? result.address : undefined,
47
+ name: result.contractResult?.name ?? undefined
48
+ }
49
+ abiCache.set(key, loaded)
50
+ return loaded
51
+ }
52
+
53
+ export async function tokenMeta(chain: ChainName, token: string) {
54
+ const key = `${chain}:${token.toLowerCase()}`
55
+ const cached = metaCache.get(key)
56
+ if (cached) return cached
57
+
58
+ const client = getClientManager().getClient(chain)
59
+ const [decimals, symbol] = await client.multicall({
60
+ contracts: [
61
+ { address: token as Address, abi: ERC20_META_ABI, functionName: 'decimals' },
62
+ { address: token as Address, abi: ERC20_META_ABI, functionName: 'symbol' }
63
+ ],
64
+ ...(chain === 'localhost' && { deployless: true })
65
+ })
66
+
67
+ const meta = {
68
+ decimals: decimals.status === 'success' ? Number(decimals.result) : undefined,
69
+ symbol: symbol.status === 'success' ? (symbol.result as string) : undefined
70
+ }
71
+ metaCache.set(key, meta)
72
+ return meta
73
+ }
74
+
75
+ /**
76
+ * Human label for an address: token symbol first (more recognisable than the contract
77
+ * name — "USDC" beats "FiatTokenProxy"), then the verified contract name. EOAs get none,
78
+ * and the bytecode check keeps us from asking explorers about plain wallets.
79
+ */
80
+ export async function addressLabel(chain: ChainName, address: string): Promise<string | undefined> {
81
+ const key = `${chain}:${address.toLowerCase()}`
82
+ if (labelCache.has(key)) return labelCache.get(key)
83
+
84
+ let label: string | undefined
85
+ try {
86
+ const code = await getClientManager().getClient(chain).getBytecode({ address: address as Address })
87
+ if (code && code !== '0x') {
88
+ label = (await tokenMeta(chain, address).catch(() => ({ symbol: undefined }))).symbol
89
+ if (!label) label = (await loadAbi(chain, address)).name
90
+ }
91
+ } catch {
92
+ // Unknown address — show it bare rather than failing the preview.
93
+ }
94
+
95
+ labelCache.set(key, label)
96
+ return label
97
+ }
98
+
99
+
100
+ /** uint256 max and anything near it means "infinite" in an approval. */
101
+ export const MAX_UINT256 = (1n << 256n) - 1n
102
+ export const UNLIMITED_THRESHOLD = MAX_UINT256 / 2n
@@ -0,0 +1,206 @@
1
+ import { createRequire } from 'node:module'
2
+ import { gunzipSync } from 'node:zlib'
3
+ import { formatUnits, getAddress, isAddress } from 'viem'
4
+ import { addressLabel, tokenMeta, UNLIMITED_THRESHOLD } from './chain-meta.js'
5
+ import { getClientManager } from './client.js'
6
+ import type { ChainName } from './types.js'
7
+
8
+ /**
9
+ * Clear signing from the Ledger ERC-7730 registry: for a known contract and selector it
10
+ * says what the transaction *means* ("Swap", "Approve spending") and labels each field the
11
+ * way the protocol intends, instead of showing raw ABI argument names.
12
+ *
13
+ * The index is built from the registry by scripts/build-erc7730-index.mjs.
14
+ */
15
+
16
+ interface IndexField {
17
+ // A handful of registry descriptors omit these, so nothing here is guaranteed.
18
+ label?: string
19
+ path?: string
20
+ format?: string
21
+ /** tokenAmount: `tokenPath` points at the field holding the token address. */
22
+ params?: { tokenPath?: string; tokenAddress?: string; [k: string]: unknown }
23
+ }
24
+
25
+ interface IndexSelector {
26
+ name: string
27
+ intent: string
28
+ /** Full signature, needed to ABI-decode the calldata these paths address. */
29
+ signature?: string
30
+ fields: IndexField[]
31
+ }
32
+
33
+ interface IndexEntry {
34
+ protocol: string
35
+ chainIds: number[]
36
+ selectors: Record<string, IndexSelector>
37
+ }
38
+
39
+ export interface ClearSigningField {
40
+ label: string
41
+ format: string
42
+ value: string
43
+ address?: string
44
+ name?: string
45
+ }
46
+
47
+ export interface ClearSigningInfo {
48
+ protocol: string
49
+ intent: string
50
+ functionName: string
51
+ fields: ClearSigningField[]
52
+ }
53
+
54
+ // Prettier names for registry directory slugs; unknown ones read fine as-is.
55
+ const PROTOCOL_LABELS: Record<string, string> = {
56
+ lifi: 'LI.FI',
57
+ '1inch': '1inch',
58
+ uniswap: 'Uniswap',
59
+ paraswap: 'ParaSwap',
60
+ cowswap: 'CoW Swap',
61
+ '0x': '0x Protocol',
62
+ aave: 'Aave',
63
+ morpho: 'Morpho',
64
+ safe: 'Safe',
65
+ lido: 'Lido',
66
+ eigenlayer: 'EigenLayer',
67
+ ens: 'ENS'
68
+ }
69
+
70
+ const require = createRequire(import.meta.url)
71
+ let index: Record<string, IndexEntry> | undefined
72
+
73
+ /** Loaded on first use: ~1.5MB of JSON, shipped gzipped at ~60KB. */
74
+ function registry(): Record<string, IndexEntry> {
75
+ if (!index) {
76
+ try {
77
+ const path = require.resolve('./erc7730-index.json.gz')
78
+ index = JSON.parse(gunzipSync(require('node:fs').readFileSync(path)).toString())
79
+ } catch {
80
+ index = {}
81
+ }
82
+ }
83
+ return index as Record<string, IndexEntry>
84
+ }
85
+
86
+ export function protocolLabel(slug: string): string {
87
+ return PROTOCOL_LABELS[slug] ?? slug
88
+ }
89
+
90
+ /** Resolve a registry path ("params.amountIn", "#.to", "[0].value") against decoded args. */
91
+ function getByPath(root: unknown, path: string): unknown {
92
+ let current: unknown = root
93
+ for (const part of path.replace(/^[#@]\./, '').split('.')) {
94
+ if (current == null) return undefined
95
+ const arrayIndex = part.match(/^\[(\d*)\]$/)
96
+ if (arrayIndex) {
97
+ current = Array.isArray(current) ? current[arrayIndex[1] === '' ? 0 : Number(arrayIndex[1])] : undefined
98
+ } else {
99
+ current = (current as Record<string, unknown>)[part]
100
+ }
101
+ }
102
+ return current
103
+ }
104
+
105
+ interface TxContext {
106
+ to: string
107
+ value?: string
108
+ from?: string
109
+ }
110
+
111
+ function resolvePath(root: unknown, tx: TxContext, path: string): unknown {
112
+ if (path.startsWith('@.')) {
113
+ if (path.slice(2) === 'to') return tx.to
114
+ if (path.slice(2) === 'value') return tx.value
115
+ if (path.slice(2) === 'from') return tx.from
116
+ return undefined
117
+ }
118
+ return getByPath(root, path)
119
+ }
120
+
121
+ async function formatField(chain: ChainName, field: IndexField, root: unknown, tx: TxContext): Promise<ClearSigningField> {
122
+ // A missing format with a token reference still means an amount.
123
+ const format = field.format ?? (field.params?.tokenPath || field.params?.tokenAddress ? 'tokenAmount' : 'raw')
124
+ const label = field.label ?? field.path ?? format
125
+ const raw = field.path ? resolvePath(root, tx, field.path) : undefined
126
+ const out: ClearSigningField = { label, format, value: '' }
127
+
128
+ // Unresolved: show the descriptor's path rather than an empty row.
129
+ if (raw == null) {
130
+ out.value = field.path ?? ''
131
+ return out
132
+ }
133
+
134
+ switch (format) {
135
+ case 'tokenAmount':
136
+ case 'amount': {
137
+ const amount = typeof raw === 'bigint' ? raw : BigInt(raw as string | number)
138
+ const token =
139
+ field.params?.tokenAddress ?? (field.params?.tokenPath ? resolvePath(root, tx, field.params.tokenPath) : undefined)
140
+
141
+ const meta =
142
+ typeof token === 'string' && isAddress(token)
143
+ ? await tokenMeta(chain, token).catch(() => ({ symbol: undefined, decimals: undefined }))
144
+ : { symbol: undefined, decimals: undefined }
145
+
146
+ const symbol = meta.symbol ? ` ${meta.symbol}` : ''
147
+ out.value = amount > UNLIMITED_THRESHOLD ? `Unlimited${symbol}` : `${formatUnits(amount, meta.decimals ?? 18)}${symbol}`
148
+ return out
149
+ }
150
+
151
+ case 'addressName':
152
+ case 'address':
153
+ case 'tokenName': {
154
+ if (!isAddress(raw as string)) {
155
+ out.value = String(raw)
156
+ return out
157
+ }
158
+ const address = getAddress(raw as string)
159
+ const name = registry()[address.toLowerCase()]?.protocol
160
+ out.address = address
161
+ out.name = name ? protocolLabel(name) : await addressLabel(chain, address).catch(() => undefined)
162
+ out.value = out.name ?? address
163
+ return out
164
+ }
165
+
166
+ default:
167
+ out.value = typeof raw === 'bigint' ? raw.toString() : String(raw)
168
+ return out
169
+ }
170
+ }
171
+
172
+ /** Registry entry for a contract, if it is listed for this chain. */
173
+ export function lookupContract(chain: ChainName, address: string): IndexEntry | undefined {
174
+ const entry = registry()[address.toLowerCase()]
175
+ if (!entry) return undefined
176
+ const chainId = getClientManager().getChainId(chain)
177
+ return entry.chainIds.includes(chainId) ? entry : undefined
178
+ }
179
+
180
+ /**
181
+ * Clear-signing info for a transaction, or null when the contract/selector is unlisted.
182
+ * `decodedArgs` is supplied by the caller, which has already decoded the calldata against
183
+ * the contract's real ABI — the registry's own signature is only a fallback for naming.
184
+ */
185
+ export async function resolveClearSigning(
186
+ chain: ChainName,
187
+ tx: TxContext & { data?: string },
188
+ decodedArgs: Record<string, unknown>
189
+ ): Promise<ClearSigningInfo | null> {
190
+ if (!tx.data || tx.data.length < 10) return null
191
+
192
+ const entry = lookupContract(chain, tx.to)
193
+ const format = entry?.selectors[tx.data.slice(0, 10).toLowerCase()]
194
+ if (!entry || !format) return null
195
+
196
+ // Fields with neither a path nor a label carry nothing a signer could read.
197
+ const usable = format.fields.filter((field) => field.path || field.label)
198
+ const fields = await Promise.all(usable.map((field) => formatField(chain, field, decodedArgs, tx)))
199
+
200
+ return {
201
+ protocol: protocolLabel(entry.protocol),
202
+ intent: format.intent,
203
+ functionName: format.name,
204
+ fields
205
+ }
206
+ }
package/src/client.ts CHANGED
@@ -238,6 +238,10 @@ export class ClientManager {
238
238
  return client as ReturnType<typeof createPublicClient>
239
239
  }
240
240
 
241
+ getConfig(): Config {
242
+ return this.config
243
+ }
244
+
241
245
  getEtherscanDomain(chainName: ChainName): string {
242
246
  const domains: Record<ChainName, string> = {
243
247
  mainnet: 'etherscan.io',
@@ -256,6 +260,16 @@ export class ClientManager {
256
260
  }
257
261
  return domains[chainName] || 'etherscan.io'
258
262
  }
263
+
264
+ getRpcUrl(chainName: ChainName): string {
265
+ const chains = CHAINS(this.config)
266
+ return chains[chainName].rpc
267
+ }
268
+
269
+ getChainId(chainName: ChainName): number {
270
+ const chains = CHAINS(this.config)
271
+ return chains[chainName].chain.id
272
+ }
259
273
  }
260
274
 
261
275
  // Export a global client manager instance
Binary file