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.
- package/README.md +63 -5
- package/dist/anvil.d.ts +34 -0
- package/dist/anvil.d.ts.map +1 -0
- package/dist/anvil.js +254 -0
- package/dist/anvil.js.map +1 -0
- package/dist/chain-meta.d.ts +27 -0
- package/dist/chain-meta.d.ts.map +1 -0
- package/dist/chain-meta.js +92 -0
- package/dist/chain-meta.js.map +1 -0
- package/dist/clear-signing.d.ts +62 -0
- package/dist/clear-signing.d.ts.map +1 -0
- package/dist/clear-signing.js +139 -0
- package/dist/clear-signing.js.map +1 -0
- package/dist/client.d.ts +3 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +11 -0
- package/dist/client.js.map +1 -1
- package/dist/erc7730-index.json.gz +0 -0
- package/dist/index.js +25 -5
- package/dist/index.js.map +1 -1
- package/dist/preview.d.ts +58 -0
- package/dist/preview.d.ts.map +1 -0
- package/dist/preview.js +182 -0
- package/dist/preview.js.map +1 -0
- package/dist/tools/advanced.d.ts +67 -9
- package/dist/tools/advanced.d.ts.map +1 -1
- package/dist/tools/advanced.js +206 -67
- package/dist/tools/advanced.js.map +1 -1
- package/dist/tools/balance.d.ts +5 -5
- package/dist/tools/contract-info.d.ts +9 -9
- package/dist/tools/contract.d.ts +8 -8
- package/dist/tools/ens.d.ts +15 -15
- package/dist/tools/gas.d.ts +18 -18
- package/dist/tools/logs.d.ts +3 -3
- package/dist/tools/transactions.d.ts +11 -11
- package/dist/tools/transactions.d.ts.map +1 -1
- package/dist/tools/transactions.js +92 -113
- package/dist/tools/transactions.js.map +1 -1
- package/dist/utils.d.ts +13 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +76 -0
- package/dist/utils.js.map +1 -1
- package/dist/wallet-client.d.ts +83 -0
- package/dist/wallet-client.d.ts.map +1 -0
- package/dist/wallet-client.js +357 -0
- package/dist/wallet-client.js.map +1 -0
- package/package.json +11 -9
- package/src/anvil.ts +323 -0
- package/src/chain-meta.ts +102 -0
- package/src/clear-signing.ts +206 -0
- package/src/client.ts +14 -0
- package/src/erc7730-index.json.gz +0 -0
- package/src/index.ts +26 -5
- package/src/preview.ts +265 -0
- package/src/tools/advanced.ts +239 -70
- package/src/tools/transactions.ts +98 -125
- package/src/utils.ts +95 -0
- package/src/wallet-client.ts +380 -0
- package/dist/wallet-server.d.ts +0 -35
- package/dist/wallet-server.d.ts.map +0 -1
- package/dist/wallet-server.js +0 -232
- package/dist/wallet-server.js.map +0 -1
- package/public/wallet-app.js +0 -677
- package/public/wallet.html +0 -723
- 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
|