web3-tools-mcp 1.5.0 → 2.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.
Files changed (109) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +346 -164
  3. package/dist/anvil.d.ts +1 -1
  4. package/dist/anvil.d.ts.map +1 -1
  5. package/dist/anvil.js +11 -9
  6. package/dist/anvil.js.map +1 -1
  7. package/dist/chain-meta.d.ts.map +1 -1
  8. package/dist/chain-meta.js +7 -3
  9. package/dist/chain-meta.js.map +1 -1
  10. package/dist/chains.d.ts +960 -0
  11. package/dist/chains.d.ts.map +1 -0
  12. package/dist/chains.js +108 -0
  13. package/dist/chains.js.map +1 -0
  14. package/dist/client.d.ts +10 -1717
  15. package/dist/client.d.ts.map +1 -1
  16. package/dist/client.js +33 -216
  17. package/dist/client.js.map +1 -1
  18. package/dist/http-server.d.ts +18 -0
  19. package/dist/http-server.d.ts.map +1 -0
  20. package/dist/http-server.js +89 -0
  21. package/dist/http-server.js.map +1 -0
  22. package/dist/index.js +77 -32
  23. package/dist/index.js.map +1 -1
  24. package/dist/kv-storage.d.ts +23 -0
  25. package/dist/kv-storage.d.ts.map +1 -0
  26. package/dist/kv-storage.js +69 -0
  27. package/dist/kv-storage.js.map +1 -0
  28. package/dist/oauth.d.ts +31 -0
  29. package/dist/oauth.d.ts.map +1 -0
  30. package/dist/oauth.js +227 -0
  31. package/dist/oauth.js.map +1 -0
  32. package/dist/preview.d.ts.map +1 -1
  33. package/dist/preview.js +14 -4
  34. package/dist/preview.js.map +1 -1
  35. package/dist/tools/advanced.d.ts +4 -4
  36. package/dist/tools/advanced.d.ts.map +1 -1
  37. package/dist/tools/advanced.js +228 -247
  38. package/dist/tools/advanced.js.map +1 -1
  39. package/dist/tools/balance.d.ts +1 -1
  40. package/dist/tools/balance.d.ts.map +1 -1
  41. package/dist/tools/balance.js +97 -103
  42. package/dist/tools/balance.js.map +1 -1
  43. package/dist/tools/contract-info.d.ts +3 -3
  44. package/dist/tools/contract-info.d.ts.map +1 -1
  45. package/dist/tools/contract-info.js +150 -161
  46. package/dist/tools/contract-info.js.map +1 -1
  47. package/dist/tools/contract.d.ts +4 -4
  48. package/dist/tools/contract.d.ts.map +1 -1
  49. package/dist/tools/contract.js +91 -110
  50. package/dist/tools/contract.js.map +1 -1
  51. package/dist/tools/ens.d.ts +6 -6
  52. package/dist/tools/ens.d.ts.map +1 -1
  53. package/dist/tools/ens.js +239 -297
  54. package/dist/tools/ens.js.map +1 -1
  55. package/dist/tools/gas.d.ts +4 -4
  56. package/dist/tools/gas.d.ts.map +1 -1
  57. package/dist/tools/gas.js +90 -138
  58. package/dist/tools/gas.js.map +1 -1
  59. package/dist/tools/index.d.ts +1 -1
  60. package/dist/tools/index.d.ts.map +1 -1
  61. package/dist/tools/logs.d.ts +1 -1
  62. package/dist/tools/logs.d.ts.map +1 -1
  63. package/dist/tools/logs.js +36 -37
  64. package/dist/tools/logs.js.map +1 -1
  65. package/dist/tools/signatures.d.ts.map +1 -1
  66. package/dist/tools/signatures.js +67 -89
  67. package/dist/tools/signatures.js.map +1 -1
  68. package/dist/tools/transactions.d.ts +48 -4
  69. package/dist/tools/transactions.d.ts.map +1 -1
  70. package/dist/tools/transactions.js +228 -44
  71. package/dist/tools/transactions.js.map +1 -1
  72. package/dist/types.d.ts +2 -1
  73. package/dist/types.d.ts.map +1 -1
  74. package/dist/utils.d.ts +3 -8
  75. package/dist/utils.d.ts.map +1 -1
  76. package/dist/utils.js +36 -98
  77. package/dist/utils.js.map +1 -1
  78. package/dist/wallet-client.d.ts +11 -1
  79. package/dist/wallet-client.d.ts.map +1 -1
  80. package/dist/wallet-client.js +27 -9
  81. package/dist/wallet-client.js.map +1 -1
  82. package/dist/walletconnect.d.ts +52 -0
  83. package/dist/walletconnect.d.ts.map +1 -0
  84. package/dist/walletconnect.js +221 -0
  85. package/dist/walletconnect.js.map +1 -0
  86. package/package.json +21 -9
  87. package/src/anvil.ts +15 -18
  88. package/src/chain-meta.ts +8 -4
  89. package/src/chains.ts +145 -0
  90. package/src/client.ts +39 -252
  91. package/src/http-server.ts +117 -0
  92. package/src/index.ts +98 -49
  93. package/src/kv-storage.ts +83 -0
  94. package/src/oauth.ts +270 -0
  95. package/src/preview.ts +15 -14
  96. package/src/tools/advanced.ts +195 -213
  97. package/src/tools/balance.ts +103 -108
  98. package/src/tools/contract-info.ts +163 -184
  99. package/src/tools/contract.ts +123 -144
  100. package/src/tools/ens.ts +290 -345
  101. package/src/tools/gas.ts +122 -170
  102. package/src/tools/index.ts +2 -2
  103. package/src/tools/logs.ts +45 -43
  104. package/src/tools/signatures.ts +68 -86
  105. package/src/tools/transactions.ts +283 -50
  106. package/src/types.ts +2 -15
  107. package/src/utils.ts +56 -127
  108. package/src/wallet-client.ts +31 -9
  109. package/src/walletconnect.ts +269 -0
@@ -0,0 +1,83 @@
1
+ /**
2
+ * WalletConnect session storage backed by Upstash Redis.
3
+ *
4
+ * The default store is a file, which is fine on a laptop and useless on a free-tier host:
5
+ * the filesystem is ephemeral, so every sleep/wake would lose the pairing and ask for the
6
+ * QR again. Everything lives in one Redis hash, which is exactly the shape WalletConnect's
7
+ * key-value interface expects.
8
+ *
9
+ * Uses the REST API over plain fetch — no Redis client, no TCP, works anywhere.
10
+ */
11
+
12
+ const HASH_KEY = 'web3-tools-mcp:walletconnect'
13
+
14
+ export interface KeyValueStorage {
15
+ getKeys(): Promise<string[]>
16
+ getEntries<T = unknown>(): Promise<[string, T][]>
17
+ getItem<T = unknown>(key: string): Promise<T | undefined>
18
+ setItem<T = unknown>(key: string, value: T): Promise<void>
19
+ removeItem(key: string): Promise<void>
20
+ }
21
+
22
+ class RedisKeyValueStorage implements KeyValueStorage {
23
+ constructor(
24
+ private url: string,
25
+ private token: string
26
+ ) {}
27
+
28
+ /** Upstash takes a command as a JSON array and answers `{ result }`. */
29
+ private async command<T>(...parts: (string | number)[]): Promise<T> {
30
+ const response = await fetch(this.url, {
31
+ method: 'POST',
32
+ headers: { authorization: `Bearer ${this.token}`, 'content-type': 'application/json' },
33
+ body: JSON.stringify(parts)
34
+ })
35
+
36
+ if (!response.ok) {
37
+ throw new Error(`Upstash request failed (${response.status}): ${await response.text()}`)
38
+ }
39
+
40
+ return ((await response.json()) as { result: T }).result
41
+ }
42
+
43
+ async getKeys(): Promise<string[]> {
44
+ return (await this.command<string[]>('HKEYS', HASH_KEY)) ?? []
45
+ }
46
+
47
+ async getEntries<T = unknown>(): Promise<[string, T][]> {
48
+ // HGETALL comes back as a flat [field, value, field, value, …] array.
49
+ const flat = (await this.command<string[]>('HGETALL', HASH_KEY)) ?? []
50
+ const entries: [string, T][] = []
51
+ for (let i = 0; i < flat.length; i += 2) {
52
+ const key = flat[i]
53
+ const value = flat[i + 1]
54
+ if (key === undefined || value === undefined) continue
55
+ entries.push([key, JSON.parse(value) as T])
56
+ }
57
+ return entries
58
+ }
59
+
60
+ async getItem<T = unknown>(key: string): Promise<T | undefined> {
61
+ const value = await this.command<string | null>('HGET', HASH_KEY, key)
62
+ return value == null ? undefined : (JSON.parse(value) as T)
63
+ }
64
+
65
+ async setItem<T = unknown>(key: string, value: T): Promise<void> {
66
+ await this.command('HSET', HASH_KEY, key, JSON.stringify(value))
67
+ }
68
+
69
+ async removeItem(key: string): Promise<void> {
70
+ await this.command('HDEL', HASH_KEY, key)
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Redis-backed storage when Upstash is configured, otherwise nothing — the caller falls
76
+ * back to WalletConnect's own file store.
77
+ */
78
+ export function getKeyValueStorage(): KeyValueStorage | undefined {
79
+ const url = process.env.UPSTASH_REDIS_REST_URL
80
+ const token = process.env.UPSTASH_REDIS_REST_TOKEN
81
+ if (!url || !token) return undefined
82
+ return new RedisKeyValueStorage(url, token)
83
+ }
package/src/oauth.ts ADDED
@@ -0,0 +1,270 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
2
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients.js'
6
+ import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js'
7
+ import type { AuthorizationParams, OAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/provider.js'
8
+ import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'
9
+ import type { OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js'
10
+ import type { Response } from 'express'
11
+ import { getKeyValueStorage, type KeyValueStorage } from './kv-storage.js'
12
+
13
+ /**
14
+ * OAuth for clients that will not send a static bearer token — claude.ai connectors, and
15
+ * so anything driving them from a browser or phone, only speak the OAuth flow the MCP spec
16
+ * defines.
17
+ *
18
+ * There are no user accounts here: the single credential is MCP_TOKEN, the same one this
19
+ * server already requires. The authorize step is a one-field page that asks for it, so a
20
+ * self-hoster needs no identity provider and no extra configuration.
21
+ */
22
+
23
+ const CODE_TTL = 10 * 60 * 1000
24
+ const TOKEN_TTL = 30 * 24 * 60 * 60
25
+
26
+ interface StoredCode {
27
+ clientId: string
28
+ codeChallenge: string
29
+ redirectUri: string
30
+ resource?: string
31
+ scopes: string[]
32
+ expiresAt: number
33
+ }
34
+
35
+ interface StoredToken {
36
+ clientId: string
37
+ scopes: string[]
38
+ expiresAt: number
39
+ }
40
+
41
+ /** Redis where configured, a JSON file otherwise — issued tokens must outlive a restart. */
42
+ class Store {
43
+ private redis: KeyValueStorage | undefined
44
+ private file: string
45
+ private cache: Record<string, unknown> | undefined
46
+
47
+ constructor() {
48
+ this.redis = getKeyValueStorage()
49
+ const dir = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'web3-tools-mcp')
50
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
51
+ this.file = join(dir, 'oauth.json')
52
+ }
53
+
54
+ private read(): Record<string, unknown> {
55
+ if (!this.cache) {
56
+ try {
57
+ this.cache = JSON.parse(readFileSync(this.file, 'utf8'))
58
+ } catch {
59
+ this.cache = {}
60
+ }
61
+ }
62
+ return this.cache as Record<string, unknown>
63
+ }
64
+
65
+ async get<T>(key: string): Promise<T | undefined> {
66
+ if (this.redis) return this.redis.getItem<T>(`oauth:${key}`)
67
+ return this.read()[key] as T | undefined
68
+ }
69
+
70
+ async set<T>(key: string, value: T): Promise<void> {
71
+ if (this.redis) return this.redis.setItem(`oauth:${key}`, value)
72
+ const data = this.read()
73
+ data[key] = value
74
+ writeFileSync(this.file, JSON.stringify(data), { mode: 0o600 })
75
+ }
76
+
77
+ async remove(key: string): Promise<void> {
78
+ if (this.redis) return this.redis.removeItem(`oauth:${key}`)
79
+ const data = this.read()
80
+ delete data[key]
81
+ writeFileSync(this.file, JSON.stringify(data), { mode: 0o600 })
82
+ }
83
+ }
84
+
85
+ function escapeHtml(value: string): string {
86
+ return value.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] as string)
87
+ }
88
+
89
+ function constantTimeEquals(a: string, b: string): boolean {
90
+ const left = Buffer.from(a)
91
+ const right = Buffer.from(b)
92
+ return left.length === right.length && timingSafeEqual(left, right)
93
+ }
94
+
95
+ function loginPage(params: { error?: string; fields: Record<string, string> }): string {
96
+ // The SDK's authorize handler reads its parameters from the body on POST, so the form
97
+ // has to carry the whole authorization request back, not just the token.
98
+ const hidden = Object.entries(params.fields)
99
+ .map(([name, value]) => `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}">`)
100
+ .join('\n ')
101
+
102
+ return `<!doctype html>
103
+ <meta charset="utf-8">
104
+ <meta name="viewport" content="width=device-width, initial-scale=1">
105
+ <title>Connect to web3-tools-mcp</title>
106
+ <style>
107
+ :root { color-scheme: light dark; }
108
+ body { font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; max-width: 26rem;
109
+ margin: 12vh auto; padding: 0 1.25rem; }
110
+ h1 { font-size: 1.15rem; margin-bottom: .25rem; }
111
+ p { color: #667; margin-top: 0; }
112
+ input, button { font: inherit; width: 100%; padding: .7rem .8rem; border-radius: .5rem;
113
+ box-sizing: border-box; }
114
+ input { border: 1px solid #ccd; font-family: ui-monospace, monospace; }
115
+ button { margin-top: .75rem; border: 0; background: #3856d6; color: #fff; font-weight: 600; }
116
+ .error { color: #b3251d; font-weight: 600; }
117
+ </style>
118
+ <h1>Connect to web3-tools-mcp</h1>
119
+ <p>Paste this server's access token to authorise the client.</p>
120
+ ${params.error ? `<p class="error">${params.error}</p>` : ''}
121
+ <form method="post">
122
+ ${hidden}
123
+ <input name="token" type="password" placeholder="MCP_TOKEN" autocomplete="off" autofocus>
124
+ <button type="submit">Authorise</button>
125
+ </form>`
126
+ }
127
+
128
+ export class SingleUserOAuthProvider implements OAuthServerProvider {
129
+ private store = new Store()
130
+
131
+ constructor(private accessToken: string) {}
132
+
133
+ get clientsStore(): OAuthRegisteredClientsStore {
134
+ const store = this.store
135
+ return {
136
+ async getClient(clientId) {
137
+ return store.get<OAuthClientInformationFull>(`client:${clientId}`)
138
+ },
139
+ // Registration is open because MCP clients register themselves before anyone can
140
+ // log in; holding MCP_TOKEN is what actually grants access.
141
+ async registerClient(client) {
142
+ const registered: OAuthClientInformationFull = {
143
+ ...client,
144
+ client_id: randomBytes(16).toString('hex'),
145
+ client_id_issued_at: Math.floor(Date.now() / 1000)
146
+ }
147
+ await store.set(`client:${registered.client_id}`, registered)
148
+ return registered
149
+ }
150
+ }
151
+ }
152
+
153
+ /**
154
+ * GET renders the token prompt; POST checks it and redirects back with a code. Handling
155
+ * both here keeps the whole login inside the SDK's authorize route.
156
+ */
157
+ async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
158
+ const request = res.req
159
+ const submitted = (request.body as { token?: string } | undefined)?.token ?? ''
160
+
161
+ // Everything except the token itself is replayed into the form, so the handler sees a
162
+ // complete authorization request when the form posts back.
163
+ const fields = Object.fromEntries(
164
+ Object.entries({ ...(request.query as Record<string, string>), ...(request.body as Record<string, string>) }).filter(
165
+ ([name, value]) => name !== 'token' && typeof value === 'string'
166
+ )
167
+ ) as Record<string, string>
168
+
169
+ if (request.method !== 'POST') {
170
+ res.setHeader('content-type', 'text/html; charset=utf-8').send(loginPage({ fields }))
171
+ return
172
+ }
173
+
174
+ if (!constantTimeEquals(submitted, this.accessToken)) {
175
+ res
176
+ .status(401)
177
+ .setHeader('content-type', 'text/html; charset=utf-8')
178
+ .send(loginPage({ fields, error: 'That token was not accepted.' }))
179
+ return
180
+ }
181
+
182
+ const code = randomBytes(24).toString('hex')
183
+ await this.store.set<StoredCode>(`code:${code}`, {
184
+ clientId: client.client_id,
185
+ codeChallenge: params.codeChallenge,
186
+ redirectUri: params.redirectUri,
187
+ resource: params.resource?.href,
188
+ scopes: params.scopes ?? [],
189
+ expiresAt: Date.now() + CODE_TTL
190
+ })
191
+
192
+ const redirect = new URL(params.redirectUri)
193
+ redirect.searchParams.set('code', code)
194
+ if (params.state) redirect.searchParams.set('state', params.state)
195
+ res.redirect(redirect.href)
196
+ }
197
+
198
+ async challengeForAuthorizationCode(_client: OAuthClientInformationFull, authorizationCode: string): Promise<string> {
199
+ const stored = await this.store.get<StoredCode>(`code:${authorizationCode}`)
200
+ if (!stored || stored.expiresAt < Date.now()) throw new Error('Authorization code is invalid or expired')
201
+ return stored.codeChallenge
202
+ }
203
+
204
+ async exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<OAuthTokens> {
205
+ const stored = await this.store.get<StoredCode>(`code:${authorizationCode}`)
206
+ if (!stored || stored.expiresAt < Date.now()) throw new Error('Authorization code is invalid or expired')
207
+ if (stored.clientId !== client.client_id) throw new Error('Authorization code was issued to a different client')
208
+
209
+ // One use only: a replayed code must not mint a second token.
210
+ await this.store.remove(`code:${authorizationCode}`)
211
+ return this.issue(client.client_id, stored.scopes)
212
+ }
213
+
214
+ async exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[]): Promise<OAuthTokens> {
215
+ const stored = await this.store.get<StoredToken>(`refresh:${refreshToken}`)
216
+ if (!stored) throw new Error('Refresh token is invalid')
217
+ if (stored.clientId !== client.client_id) throw new Error('Refresh token was issued to a different client')
218
+
219
+ await this.store.remove(`refresh:${refreshToken}`)
220
+ return this.issue(client.client_id, scopes ?? stored.scopes)
221
+ }
222
+
223
+ private async issue(clientId: string, scopes: string[]): Promise<OAuthTokens> {
224
+ const accessToken = randomBytes(32).toString('hex')
225
+ const refreshToken = randomBytes(32).toString('hex')
226
+ const expiresAt = Math.floor(Date.now() / 1000) + TOKEN_TTL
227
+
228
+ await this.store.set<StoredToken>(`token:${accessToken}`, { clientId, scopes, expiresAt })
229
+ await this.store.set<StoredToken>(`refresh:${refreshToken}`, { clientId, scopes, expiresAt })
230
+
231
+ return {
232
+ access_token: accessToken,
233
+ token_type: 'Bearer',
234
+ expires_in: TOKEN_TTL,
235
+ refresh_token: refreshToken,
236
+ scope: scopes.join(' ')
237
+ }
238
+ }
239
+
240
+ /** Accepts an issued token, or MCP_TOKEN itself so a client that can send a header still works. */
241
+ async verifyAccessToken(token: string): Promise<AuthInfo> {
242
+ if (constantTimeEquals(token, this.accessToken)) {
243
+ // requireBearerAuth refuses a token with no expiry, and this one never expires.
244
+ return { token, clientId: 'static', scopes: [], expiresAt: Math.floor(Date.now() / 1000) + TOKEN_TTL }
245
+ }
246
+
247
+ // Must be InvalidTokenError specifically: the SDK turns anything else into a 500, and
248
+ // a client needs the 401 to know it should authenticate again.
249
+ const stored = await this.store.get<StoredToken>(`token:${token}`)
250
+ if (!stored) throw new InvalidTokenError('Invalid access token')
251
+ if (stored.expiresAt * 1000 < Date.now()) {
252
+ await this.store.remove(`token:${token}`)
253
+ throw new InvalidTokenError('Access token has expired')
254
+ }
255
+
256
+ return { token, clientId: stored.clientId, scopes: stored.scopes, expiresAt: stored.expiresAt }
257
+ }
258
+
259
+ async revokeToken(_client: OAuthClientInformationFull, request: { token: string }): Promise<void> {
260
+ await this.store.remove(`token:${request.token}`)
261
+ await this.store.remove(`refresh:${request.token}`)
262
+ }
263
+ }
264
+
265
+ /** PKCE helper, used by the tests to drive the flow the way a real client would. */
266
+ export function pkce(): { verifier: string; challenge: string } {
267
+ const verifier = randomBytes(32).toString('base64url')
268
+ const challenge = createHash('sha256').update(verifier).digest('base64url')
269
+ return { verifier, challenge }
270
+ }
package/src/preview.ts CHANGED
@@ -1,17 +1,8 @@
1
- import {
2
- type Abi,
3
- type AbiFunction,
4
- type Address,
5
- decodeFunctionData,
6
- formatEther,
7
- formatUnits,
8
- parseAbiItem,
9
- toFunctionSelector
10
- } from 'viem'
1
+ import { type AbiFunction, type Address, decodeFunctionData, formatEther, formatUnits, toFunctionSelector } from 'viem'
11
2
  import { simulateBlocks } from 'viem/actions'
12
- import { getClientManager } from './client.js'
13
3
  import { addressLabel, loadAbi, tokenMeta, UNLIMITED_THRESHOLD } from './chain-meta.js'
14
4
  import { lookupContract, protocolLabel, resolveClearSigning } from './clear-signing.js'
5
+ import { getClientManager } from './client.js'
15
6
  import type { ChainName } from './types.js'
16
7
 
17
8
  export interface PreviewField {
@@ -244,8 +235,18 @@ export async function buildTxPreview(chain: ChainName, tx: RawTx, from?: string)
244
235
  const value = tx.value ? BigInt(tx.value).toString() : undefined
245
236
 
246
237
  const [decoded, simulation, toLabel] = await Promise.all([
247
- decodeCalldata(chain, tx).catch(() => undefined),
248
- from ? simulate(chain, tx, from as Address).catch(() => undefined) : Promise.resolve(undefined),
238
+ // A preview that cannot be built must not block the transaction, but swallowing the
239
+ // reason makes "no details shown" impossible to diagnose.
240
+ decodeCalldata(chain, tx).catch((error) => {
241
+ console.error('[Preview] Could not decode calldata:', error instanceof Error ? error.message : error)
242
+ return undefined
243
+ }),
244
+ from
245
+ ? simulate(chain, tx, from as Address).catch((error) => {
246
+ console.error('[Preview] Could not simulate:', error instanceof Error ? error.message : error)
247
+ return undefined
248
+ })
249
+ : Promise.resolve(undefined),
249
250
  // The registry's protocol name beats anything on-chain: "Aave", not a proxy's class name.
250
251
  Promise.resolve(lookupContract(chain, tx.to))
251
252
  .then((entry) => (entry ? protocolLabel(entry.protocol) : addressLabel(chain, tx.to)))
@@ -256,7 +257,7 @@ export async function buildTxPreview(chain: ChainName, tx: RawTx, from?: string)
256
257
  chain,
257
258
  to: tx.to,
258
259
  toLabel,
259
- explorer: `https://${getClientManager().getEtherscanDomain(chain)}`,
260
+ explorer: getClientManager().explorerUrl(chain),
260
261
  value,
261
262
  valueFormatted: value && value !== '0' ? formatEther(BigInt(value)) : undefined,
262
263
  decoded,