@marigoldlabs/web3-tester 0.1.2 → 0.4.2

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 (96) hide show
  1. package/.env.example +26 -17
  2. package/LICENSE +21 -0
  3. package/README.md +480 -48
  4. package/dist/anvil.d.ts +90 -2
  5. package/dist/anvil.d.ts.map +1 -1
  6. package/dist/anvil.js +215 -13
  7. package/dist/anvil.js.map +1 -1
  8. package/dist/benchmark.d.ts +55 -0
  9. package/dist/benchmark.d.ts.map +1 -0
  10. package/dist/benchmark.js +168 -0
  11. package/dist/benchmark.js.map +1 -0
  12. package/dist/contracts/test-erc20.d.ts +227 -0
  13. package/dist/contracts/test-erc20.d.ts.map +1 -0
  14. package/dist/contracts/test-erc20.js +8 -0
  15. package/dist/contracts/test-erc20.js.map +1 -0
  16. package/dist/erc20.d.ts +38 -0
  17. package/dist/erc20.d.ts.map +1 -0
  18. package/dist/erc20.js +229 -0
  19. package/dist/erc20.js.map +1 -0
  20. package/dist/fixtures.d.ts +44 -2
  21. package/dist/fixtures.d.ts.map +1 -1
  22. package/dist/fixtures.js +162 -17
  23. package/dist/fixtures.js.map +1 -1
  24. package/dist/index.d.ts +26 -5
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +11 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/injected-provider.d.ts.map +1 -1
  29. package/dist/injected-provider.js +863 -77
  30. package/dist/injected-provider.js.map +1 -1
  31. package/dist/live-fixtures.d.ts +32 -3
  32. package/dist/live-fixtures.d.ts.map +1 -1
  33. package/dist/live-fixtures.js +64 -27
  34. package/dist/live-fixtures.js.map +1 -1
  35. package/dist/matchers.d.ts +90 -0
  36. package/dist/matchers.d.ts.map +1 -0
  37. package/dist/matchers.js +268 -0
  38. package/dist/matchers.js.map +1 -0
  39. package/dist/metamask-extension.d.ts +34 -0
  40. package/dist/metamask-extension.d.ts.map +1 -0
  41. package/dist/metamask-extension.js +97 -0
  42. package/dist/metamask-extension.js.map +1 -0
  43. package/dist/mock-wallet-controller.d.ts +354 -4
  44. package/dist/mock-wallet-controller.d.ts.map +1 -1
  45. package/dist/mock-wallet-controller.js +1444 -58
  46. package/dist/mock-wallet-controller.js.map +1 -1
  47. package/dist/private-key-rpc-client.d.ts +1744 -2
  48. package/dist/private-key-rpc-client.d.ts.map +1 -1
  49. package/dist/private-key-rpc-client.js +245 -30
  50. package/dist/private-key-rpc-client.js.map +1 -1
  51. package/dist/real-wallet-cache.d.ts +103 -0
  52. package/dist/real-wallet-cache.d.ts.map +1 -0
  53. package/dist/real-wallet-cache.js +331 -0
  54. package/dist/real-wallet-cache.js.map +1 -0
  55. package/dist/real-wallet-extension-fixtures.d.ts +35 -0
  56. package/dist/real-wallet-extension-fixtures.d.ts.map +1 -0
  57. package/dist/real-wallet-extension-fixtures.js +96 -0
  58. package/dist/real-wallet-extension-fixtures.js.map +1 -0
  59. package/dist/real-wallet-extension.d.ts +86 -0
  60. package/dist/real-wallet-extension.d.ts.map +1 -0
  61. package/dist/real-wallet-extension.js +245 -0
  62. package/dist/real-wallet-extension.js.map +1 -0
  63. package/dist/real-wallet-fixtures.d.ts +52 -0
  64. package/dist/real-wallet-fixtures.d.ts.map +1 -0
  65. package/dist/real-wallet-fixtures.js +88 -0
  66. package/dist/real-wallet-fixtures.js.map +1 -0
  67. package/dist/real-wallet.d.ts +119 -19
  68. package/dist/real-wallet.d.ts.map +1 -1
  69. package/dist/real-wallet.js +1656 -144
  70. package/dist/real-wallet.js.map +1 -1
  71. package/dist/safe.d.ts +311 -0
  72. package/dist/safe.d.ts.map +1 -0
  73. package/dist/safe.js +743 -0
  74. package/dist/safe.js.map +1 -0
  75. package/dist/transactions.d.ts +118 -0
  76. package/dist/transactions.d.ts.map +1 -0
  77. package/dist/transactions.js +207 -0
  78. package/dist/transactions.js.map +1 -0
  79. package/dist/types.d.ts +36 -1
  80. package/dist/types.d.ts.map +1 -1
  81. package/dist/wallet-personas.d.ts +99 -0
  82. package/dist/wallet-personas.d.ts.map +1 -0
  83. package/dist/wallet-personas.js +666 -0
  84. package/dist/wallet-personas.js.map +1 -0
  85. package/dist/walletconnect.d.ts +373 -0
  86. package/dist/walletconnect.d.ts.map +1 -0
  87. package/dist/walletconnect.js +799 -0
  88. package/dist/walletconnect.js.map +1 -0
  89. package/examples/live-sepolia.spec.ts +20 -1
  90. package/examples/playwright.config.ts +8 -0
  91. package/package.json +90 -8
  92. package/docs/API.md +0 -223
  93. package/docs/ARCHITECTURE.md +0 -81
  94. package/docs/CONSUMING_FROM_FJORD.md +0 -123
  95. package/docs/FJORD_LIVE_QA.md +0 -87
  96. package/docs/RELEASE_CHECKLIST.md +0 -55
@@ -1,17 +1,38 @@
1
- import { toHex } from 'viem';
1
+ import { randomBytes } from 'node:crypto';
2
+ import { http, isAddress, keccak256, toHex } from 'viem';
2
3
  import { providerError, serializeRpcError } from './errors.js';
3
4
  import { buildInjectedProviderScript, emitterName, rpcBridgeName, } from './injected-provider.js';
5
+ import { createWalletPersona, mockWalletPersona, } from './wallet-personas.js';
6
+ /**
7
+ * Adapter: EIP-1193 RpcClient over a plain JSON-RPC URL (viem http
8
+ * transport). URL-backed chains serve reads, eth_sendRawTransaction, and
9
+ * dapp-side flows; node-side signing (personal_sign, eth_sendTransaction)
10
+ * needs a node that signs — back those chains with Anvil or a
11
+ * PrivateKeyRpcClient instead.
12
+ */
13
+ export function httpRpcClient(url, options = {}) {
14
+ const transport = http(url, {
15
+ retryCount: options.retryCount ?? 0,
16
+ timeout: options.timeout,
17
+ })({});
18
+ const rpcTransport = transport;
19
+ return {
20
+ request: (request) => rpcTransport.request(request),
21
+ };
22
+ }
4
23
  const DEFAULT_PROVIDER_INFO = {
5
24
  uuid: '00000000-0000-4000-8000-000000000001',
6
25
  name: 'Mock Wallet',
7
26
  icon: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="%23111827"/><path d="M17 34h30v14H17z" fill="%2338bdf8"/><path d="M20 18h24v16H20z" fill="%23f59e0b"/><circle cx="43" cy="41" r="3" fill="%23111827"/></svg>',
8
27
  rdns: 'dev.invisible-wallet.mock',
9
28
  };
29
+ const DEFAULT_SOLANA_PUBLIC_KEY = '26qv4GCcx98RihuK3c4T6ozB3J7L6VwCuFVc7Ta2A3Uo';
10
30
  const defaultAdditionalProviderInfo = (index) => ({
11
31
  uuid: `00000000-0000-4000-8000-${String(index + 2).padStart(12, '0')}`,
12
32
  name: `Mock Wallet ${index + 2}`,
13
33
  icon: DEFAULT_PROVIDER_INFO.icon,
14
34
  rdns: `dev.invisible-wallet.mock.${index + 2}`,
35
+ flags: { isMetaMask: true, isMock: true },
15
36
  });
16
37
  const SIGNING_METHODS = new Set([
17
38
  'eth_sendTransaction',
@@ -20,13 +41,66 @@ const SIGNING_METHODS = new Set([
20
41
  'eth_signTypedData_v3',
21
42
  'eth_signTypedData_v4',
22
43
  'personal_sign',
44
+ 'solana_signIn',
45
+ 'solana_signAllTransactions',
46
+ 'solana_signAndSendAllTransactions',
47
+ 'solana_signAndSendTransaction',
48
+ 'solana_signMessage',
49
+ 'solana_signTransaction',
50
+ // A batch is a spend: 4100 while disconnected, approval-gated, covered by
51
+ // the default simulateRejection() set.
52
+ 'wallet_sendCalls',
23
53
  ]);
24
- const permissionResponse = [
25
- {
26
- parentCapability: 'eth_accounts',
27
- caveats: [],
28
- },
29
- ];
54
+ // Methods that open a wallet prompt in a real wallet but do not sign.
55
+ const PROMPT_METHODS = new Set([
56
+ 'coinbase_fetchPermissions',
57
+ 'wallet_addSubAccount',
58
+ 'wallet_addEthereumChain',
59
+ 'wallet_connect',
60
+ 'wallet_requestPermissions',
61
+ 'wallet_switchEthereumChain',
62
+ 'wallet_watchAsset',
63
+ 'solana_requestAccounts',
64
+ ]);
65
+ const APPROVAL_GATED_METHODS = new Set([
66
+ ...SIGNING_METHODS,
67
+ ...PROMPT_METHODS,
68
+ 'eth_requestAccounts',
69
+ // Broadcasts someone else's signed bytes — still a spend the user must
70
+ // approve, and it must not slip through the unguarded default forward.
71
+ 'eth_sendRawTransaction',
72
+ ]);
73
+ const UNLOCK_REQUIRED_METHODS = new Set([
74
+ ...APPROVAL_GATED_METHODS,
75
+ 'coinbase_fetchPermission',
76
+ 'wallet_getSubAccounts',
77
+ 'wallet_getCapabilities',
78
+ ]);
79
+ const HARDWARE_WALLET_DEFAULT_DELAY_MS = 750;
80
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
81
+ const inferHardwareWalletRequiredApp = (method) => method.startsWith('solana_') ? 'Solana' : 'Ethereum';
82
+ const hardwareWalletRequiredApp = (method, hardwareWallet) => hardwareWallet.requiredApps[method] ??
83
+ hardwareWallet.requiredApp ??
84
+ inferHardwareWalletRequiredApp(method);
85
+ const normalizeHardwareWalletSimulation = (options) => {
86
+ const input = options === true
87
+ ? { enabled: true }
88
+ : options === false || options === undefined
89
+ ? { enabled: false }
90
+ : options;
91
+ const approvalDelayMs = input.approvalDelayMs ?? HARDWARE_WALLET_DEFAULT_DELAY_MS;
92
+ if (!Number.isFinite(approvalDelayMs) || approvalDelayMs < 0) {
93
+ throw new Error('hardwareWallet.approvalDelayMs must be a non-negative finite number.');
94
+ }
95
+ return {
96
+ enabled: input.enabled ?? (options !== undefined),
97
+ approvalDelayMs,
98
+ deviceState: input.deviceState ?? 'ready',
99
+ methods: [...(input.methods ?? SIGNING_METHODS)],
100
+ requiredApp: input.requiredApp,
101
+ requiredApps: { ...(input.requiredApps ?? {}) },
102
+ };
103
+ };
30
104
  const normalizeParams = (params) => {
31
105
  if (params === undefined) {
32
106
  return [];
@@ -36,83 +110,583 @@ const normalizeParams = (params) => {
36
110
  }
37
111
  return [params];
38
112
  };
39
- const normalizeChainId = (chainId) => typeof chainId === 'number' ? toHex(chainId) : chainId;
113
+ // Canonical (lowercase, minimal) hex so '0xAA36A7', '0x0aa36a7', and
114
+ // 11155111 all map to one chain-registry key. Config/test-side input throws
115
+ // a plain Error on garbage; dapp params go through parseDappChainId instead.
116
+ const normalizeChainId = (chainId) => {
117
+ try {
118
+ return toHex(typeof chainId === 'number' ? chainId : BigInt(chainId));
119
+ }
120
+ catch {
121
+ throw new Error(`Invalid chain id "${String(chainId)}".`);
122
+ }
123
+ };
124
+ const parseDappChainId = (chainId) => {
125
+ if (typeof chainId !== 'string' || !chainId.startsWith('0x')) {
126
+ throw providerError(-32602, 'Expected a 0x-prefixed chainId.');
127
+ }
128
+ try {
129
+ return toHex(BigInt(chainId));
130
+ }
131
+ catch {
132
+ throw providerError(-32602, `Invalid chainId "${chainId}".`);
133
+ }
134
+ };
135
+ const parseCoinbaseChainId = (chainId, label = 'chainId') => {
136
+ if (typeof chainId === 'number' && Number.isSafeInteger(chainId) && chainId >= 0) {
137
+ return toHex(chainId);
138
+ }
139
+ if (typeof chainId === 'string' && chainId.startsWith('0x')) {
140
+ return parseDappChainId(chainId);
141
+ }
142
+ throw providerError(-32602, `${label} must be a hexadecimal string or non-negative integer.`);
143
+ };
144
+ const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
145
+ const assertRpcAddress = (value, label) => {
146
+ if (typeof value === 'string' && isAddress(value)) {
147
+ return value;
148
+ }
149
+ throw providerError(-32602, `${label} must be a valid address.`);
150
+ };
151
+ const assertRpcHex = (value, label, options = {}) => {
152
+ const pattern = options.allowEmpty ? /^0x[0-9a-fA-F]*$/ : /^0x[0-9a-fA-F]+$/;
153
+ if (typeof value === 'string' && pattern.test(value)) {
154
+ return value;
155
+ }
156
+ throw providerError(-32602, `${label} must be a 0x-prefixed hex string.`);
157
+ };
158
+ const assertRpcHash = (value, label) => {
159
+ if (typeof value === 'string' && /^0x[0-9a-fA-F]{64}$/.test(value)) {
160
+ return value;
161
+ }
162
+ throw providerError(-32602, `${label} must be a 32-byte 0x-prefixed hex string.`);
163
+ };
164
+ const assertRpcObject = (value, method) => {
165
+ if (isRecord(value)) {
166
+ return value;
167
+ }
168
+ throw providerError(-32602, `${method} requires an object parameter.`);
169
+ };
170
+ const requireConfigAddress = (value, label) => {
171
+ if (typeof value === 'string' && isAddress(value)) {
172
+ return value;
173
+ }
174
+ throw new Error(`${label} must be a valid address.`);
175
+ };
176
+ const requireConfigHex = (value, label, options = {}) => {
177
+ const pattern = options.hash
178
+ ? /^0x[0-9a-fA-F]{64}$/
179
+ : options.allowEmpty
180
+ ? /^0x[0-9a-fA-F]*$/
181
+ : /^0x[0-9a-fA-F]+$/;
182
+ if (typeof value === 'string' && pattern.test(value)) {
183
+ return value;
184
+ }
185
+ throw new Error(`${label} must be a ${options.hash ? '32-byte ' : ''}0x-prefixed hex string.`);
186
+ };
187
+ const requireFiniteNumber = (value, label) => {
188
+ if (typeof value === 'number' && Number.isFinite(value)) {
189
+ return value;
190
+ }
191
+ throw new Error(`${label} must be a finite number.`);
192
+ };
193
+ const requireConfigString = (value, label) => {
194
+ if (typeof value === 'string') {
195
+ return value;
196
+ }
197
+ throw new Error(`${label} must be a string.`);
198
+ };
199
+ const sameAddress = (left, right) => left.toLowerCase() === right.toLowerCase();
200
+ const cloneCoinbasePermission = (permission) => ({
201
+ createdAt: permission.createdAt,
202
+ permissionHash: permission.permissionHash,
203
+ signature: permission.signature,
204
+ spendPermission: { ...permission.spendPermission },
205
+ });
206
+ const cloneCoinbaseSubAccount = (subAccount) => ({
207
+ address: subAccount.address,
208
+ ...(subAccount.factory ? { factory: subAccount.factory } : {}),
209
+ ...(subAccount.factoryData ? { factoryData: subAccount.factoryData } : {}),
210
+ });
211
+ const normalizeCoinbasePermission = (permission, defaultChainId) => ({
212
+ createdAt: requireFiniteNumber(permission.createdAt, 'coinbase.permissions[].createdAt'),
213
+ permissionHash: requireConfigHex(permission.permissionHash, 'coinbase.permissions[].permissionHash', { hash: true }),
214
+ signature: requireConfigHex(permission.signature, 'coinbase.permissions[].signature'),
215
+ chainId: permission.chainId === undefined ? defaultChainId : normalizeChainId(permission.chainId),
216
+ spendPermission: {
217
+ account: requireConfigAddress(permission.spendPermission?.account, 'coinbase.permissions[].spendPermission.account'),
218
+ spender: requireConfigAddress(permission.spendPermission?.spender, 'coinbase.permissions[].spendPermission.spender'),
219
+ token: requireConfigAddress(permission.spendPermission?.token, 'coinbase.permissions[].spendPermission.token'),
220
+ allowance: requireConfigString(permission.spendPermission?.allowance, 'coinbase.permissions[].spendPermission.allowance'),
221
+ period: requireFiniteNumber(permission.spendPermission?.period, 'coinbase.permissions[].spendPermission.period'),
222
+ start: requireFiniteNumber(permission.spendPermission?.start, 'coinbase.permissions[].spendPermission.start'),
223
+ end: requireFiniteNumber(permission.spendPermission?.end, 'coinbase.permissions[].spendPermission.end'),
224
+ salt: requireConfigString(permission.spendPermission?.salt, 'coinbase.permissions[].spendPermission.salt'),
225
+ extraData: requireConfigHex(permission.spendPermission?.extraData, 'coinbase.permissions[].spendPermission.extraData', { allowEmpty: true }),
226
+ },
227
+ });
228
+ const normalizeCoinbaseSubAccount = (subAccount, defaultChainId, defaultAccount) => ({
229
+ address: requireConfigAddress(subAccount.address, 'coinbase.subAccounts[].address'),
230
+ chainId: subAccount.chainId === undefined ? defaultChainId : normalizeChainId(subAccount.chainId),
231
+ account: subAccount.account === undefined
232
+ ? defaultAccount
233
+ : requireConfigAddress(subAccount.account, 'coinbase.subAccounts[].account'),
234
+ ...(subAccount.domain ? { domain: subAccount.domain } : {}),
235
+ ...(subAccount.factory
236
+ ? { factory: requireConfigAddress(subAccount.factory, 'coinbase.subAccounts[].factory') }
237
+ : {}),
238
+ ...(subAccount.factoryData
239
+ ? {
240
+ factoryData: requireConfigHex(subAccount.factoryData, 'coinbase.subAccounts[].factoryData', { allowEmpty: true }),
241
+ }
242
+ : {}),
243
+ });
244
+ const normalizeCoinbaseWalletSimulation = (options, defaultEnabled, defaultChainId, defaultAccount) => {
245
+ if (options === false) {
246
+ return { enabled: false, permissions: [], subAccounts: [] };
247
+ }
248
+ const input = options === true ? { enabled: true } : (options ?? {});
249
+ return {
250
+ enabled: input.enabled ?? (options === undefined ? defaultEnabled : true),
251
+ permissions: [...(input.permissions ?? [])].map((permission) => normalizeCoinbasePermission(permission, defaultChainId)),
252
+ subAccounts: [...(input.subAccounts ?? [])].map((subAccount) => normalizeCoinbaseSubAccount(subAccount, defaultChainId, defaultAccount)),
253
+ ...(input.factory
254
+ ? { factory: requireConfigAddress(input.factory, 'coinbase.factory') }
255
+ : {}),
256
+ ...(input.factoryData
257
+ ? { factoryData: requireConfigHex(input.factoryData, 'coinbase.factoryData', { allowEmpty: true }) }
258
+ : {}),
259
+ };
260
+ };
261
+ const generatedSubAccountAddress = (owner, chainId, index, accountConfig) => {
262
+ const seed = JSON.stringify({ owner: owner.toLowerCase(), chainId, index, accountConfig });
263
+ return `0x${keccak256(toHex(seed)).slice(-40)}`;
264
+ };
265
+ // Bounded eth_chainId probe used when trustDappRpcUrls wires up a
266
+ // dapp-supplied RPC URL — a hung endpoint must not stall the dapp's promise.
267
+ const probeChainId = async (url, timeoutMs = 5_000) => {
268
+ const response = await fetch(url, {
269
+ method: 'POST',
270
+ headers: { 'content-type': 'application/json' },
271
+ body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId', params: [] }),
272
+ signal: AbortSignal.timeout(timeoutMs),
273
+ });
274
+ const body = (await response.json());
275
+ if (typeof body.result !== 'string') {
276
+ throw new Error(`No eth_chainId result from ${url}.`);
277
+ }
278
+ return toHex(BigInt(body.result));
279
+ };
280
+ const toOrigin = (url) => {
281
+ try {
282
+ return new URL(url).origin;
283
+ }
284
+ catch {
285
+ return 'null';
286
+ }
287
+ };
288
+ // Frames whose URL carries no origin of its own; in the browser they inherit
289
+ // the parent's (or opener's) origin.
290
+ const isBlankFrameUrl = (url) => url === '' || url === 'about:blank' || url === 'about:srcdoc';
291
+ // The browser-effective origin of the calling frame: blank frames inherit
292
+ // from the nearest non-blank ancestor, blank popups from their opener.
293
+ const resolveFrameOrigin = async (source) => {
294
+ let frame = source.frame;
295
+ while (frame && isBlankFrameUrl(frame.url())) {
296
+ frame = frame.parentFrame();
297
+ }
298
+ if (frame) {
299
+ return toOrigin(frame.url());
300
+ }
301
+ const opener = await source.page.opener().catch(() => null);
302
+ return opener ? toOrigin(opener.mainFrame().url()) : 'null';
303
+ };
40
304
  export class MockWalletController {
41
305
  page;
42
306
  rpcClient;
43
307
  accounts;
44
308
  chainId;
45
309
  connected;
310
+ unlocked;
46
311
  approveRequests;
312
+ hardwareWallet;
313
+ coinbase;
47
314
  rejectionQueue = [];
315
+ holdQueue = [];
316
+ approvalQueue = [];
317
+ knownChainIds = new Set();
318
+ chainBackends = new Map();
319
+ trustDappRpcUrls;
320
+ allowedOrigins;
321
+ providerEventListeners = new Set();
322
+ // Promise-chain mutex: forwarded sends (and, later, batch execution inside
323
+ // a snapshot window) must not interleave — exposeBinding handlers run
324
+ // concurrently.
325
+ sendQueue = Promise.resolve();
326
+ nodeAccountsCache;
327
+ eip5792;
328
+ atomicStatus;
329
+ upgradeRejectionArmed = false;
330
+ callBatches = new Map();
331
+ /** Every accepted wallet_sendCalls batch, for test assertions. */
332
+ sentCallBatches = [];
333
+ /** Ids the page passed to wallet_showCallsStatus (a headless no-op). */
334
+ shownCallsStatusIds = [];
335
+ sentTransactions = [];
336
+ sentTransactionRequests = [];
337
+ watchedAssets = [];
48
338
  constructor(page, rpcClient, options) {
49
339
  this.page = page;
50
340
  this.rpcClient = rpcClient;
51
341
  this.accounts = [...options.accounts];
52
342
  this.chainId = normalizeChainId(options.chainId);
53
343
  this.connected = options.connected ?? true;
344
+ this.unlocked = options.unlocked ?? true;
54
345
  this.approveRequests = options.autoApprove ?? true;
346
+ this.hardwareWallet = normalizeHardwareWalletSimulation(options.hardwareWallet);
347
+ this.trustDappRpcUrls = options.trustDappRpcUrls ?? false;
348
+ const eip5792 = typeof options.eip5792 === 'boolean' ? { enabled: options.eip5792 } : (options.eip5792 ?? {});
349
+ this.eip5792 = {
350
+ enabled: eip5792.enabled ?? true,
351
+ maxCallsPerBatch: eip5792.maxCallsPerBatch ?? 100,
352
+ atomic: eip5792.atomic,
353
+ capabilities: eip5792.capabilities,
354
+ };
355
+ this.atomicStatus = this.eip5792.atomic ?? 'supported';
356
+ this.knownChainIds.add(this.chainId);
357
+ this.chainBackends.set(this.chainId, rpcClient);
358
+ for (const [key, backend] of Object.entries(options.chains ?? {})) {
359
+ const id = normalizeChainId(key);
360
+ if (id === this.chainId) {
361
+ throw new Error(`chains must not list the default chainId ${id} — the constructor's rpcClient is its backend.`);
362
+ }
363
+ this.chainBackends.set(id, typeof backend === 'string' ? httpRpcClient(backend) : backend);
364
+ this.knownChainIds.add(id);
365
+ }
366
+ this.allowedOrigins = options.allowedOrigins?.map((entry) => {
367
+ // Strictly http(s): a scheme-less "localhost:3000" parses as protocol
368
+ // "localhost:" with origin "null", which would silently allowlist every
369
+ // null-origin frame and block the intended host.
370
+ let origin;
371
+ try {
372
+ const url = new URL(entry);
373
+ origin = /^https?:$/.test(url.protocol) ? url.origin : undefined;
374
+ }
375
+ catch {
376
+ origin = undefined;
377
+ }
378
+ if (!origin || origin === 'null') {
379
+ throw new Error(`allowedOrigins entry "${entry}" is not an http(s) URL or origin (expected e.g. "https://app.example.com").`);
380
+ }
381
+ return origin;
382
+ });
55
383
  if (this.accounts.length === 0) {
56
384
  throw new Error('MockWalletController requires at least one account.');
57
385
  }
58
- const primaryProviderInfo = {
59
- ...DEFAULT_PROVIDER_INFO,
386
+ const primaryProviderInfo = createWalletPersona({
387
+ ...mockWalletPersona(),
388
+ ...options.persona,
60
389
  ...options.providerInfo,
61
- };
390
+ });
62
391
  this.providerInfos = [
63
392
  primaryProviderInfo,
64
- ...(options.additionalProviders ?? []).map((provider, index) => ({
393
+ ...(options.additionalProviders ?? []).map((provider, index) => createWalletPersona({
65
394
  ...defaultAdditionalProviderInfo(index),
66
395
  ...provider,
67
396
  })),
397
+ ...(options.additionalPersonas ?? []).map((provider, index) => createWalletPersona({
398
+ ...defaultAdditionalProviderInfo(index + (options.additionalProviders?.length ?? 0)),
399
+ ...provider,
400
+ })),
68
401
  ];
69
402
  this.providerInfo = primaryProviderInfo;
403
+ this.coinbase = normalizeCoinbaseWalletSimulation(options.coinbase, this.providerInfos.some((provider) => provider.flags?.isCoinbaseWallet === true), this.chainId, this.primaryAccount);
70
404
  }
71
405
  providerInfo;
72
406
  providerInfos;
73
407
  get primaryAccount() {
74
408
  return this.accounts[0];
75
409
  }
410
+ /** Current account list; index 0 is the selected account. */
411
+ get currentAccounts() {
412
+ return [...this.accounts];
413
+ }
76
414
  get currentChainId() {
77
415
  return this.chainId;
78
416
  }
79
- async injectMockProvider() {
80
- await this.page.exposeFunction(rpcBridgeName, async (request) => {
81
- try {
82
- const result = await this.handleRpcRequest(request);
83
- return { ok: true, result };
417
+ /** Chain ids that currently have an RPC backend, canonical hex. */
418
+ get backedChainIds() {
419
+ return [...this.chainBackends.keys()];
420
+ }
421
+ get coinbasePermissions() {
422
+ return this.coinbase.permissions.map(cloneCoinbasePermission);
423
+ }
424
+ get coinbaseSubAccounts() {
425
+ return this.coinbase.subAccounts.map((subAccount) => ({
426
+ ...cloneCoinbaseSubAccount(subAccount),
427
+ chainId: subAccount.chainId,
428
+ account: subAccount.account,
429
+ ...(subAccount.domain ? { domain: subAccount.domain } : {}),
430
+ }));
431
+ }
432
+ get solanaAccounts() {
433
+ if (!this.connected || !this.unlocked) {
434
+ return [];
435
+ }
436
+ const seen = new Set();
437
+ const accounts = [];
438
+ for (const provider of this.providerInfos) {
439
+ if (!provider.solana) {
440
+ continue;
84
441
  }
85
- catch (error) {
86
- return { ok: false, error: serializeRpcError(error) };
442
+ const publicKey = provider.solana.publicKey ?? DEFAULT_SOLANA_PUBLIC_KEY;
443
+ if (seen.has(publicKey)) {
444
+ continue;
87
445
  }
88
- });
446
+ seen.add(publicKey);
447
+ accounts.push({ publicKey, pubkey: publicKey, address: publicKey });
448
+ }
449
+ return accounts;
450
+ }
451
+ /**
452
+ * Test-side chain registration (Synpress addNetwork analogue): registers
453
+ * the backend and marks the chain known — no approval gate, no probe, and
454
+ * re-registration overwrites (tests may rewire).
455
+ */
456
+ addChain(chainId, backend) {
457
+ const id = normalizeChainId(chainId);
458
+ this.chainBackends.set(id, typeof backend === 'string' ? httpRpcClient(backend) : backend);
459
+ this.knownChainIds.add(id);
460
+ }
461
+ /**
462
+ * Dispatch a request arriving from a non-injected transport (e.g. a
463
+ * WalletConnect session). Approval gating applies exactly as for injected
464
+ * requests. When allowedOrigins is configured, `context.origin` is
465
+ * enforced; an absent origin counts as "null" and is refused.
466
+ * `bypassOriginCheck` is the deliberate opt-out for transports that cannot
467
+ * attest origins — approval gating still applies.
468
+ */
469
+ async handleExternalRequest(request, context = {}) {
470
+ if (this.allowedOrigins && !context.bypassOriginCheck) {
471
+ this.assertOriginAllowed(context.origin !== undefined ? toOrigin(context.origin) : 'null');
472
+ }
473
+ return this.handleRpcRequest(request);
474
+ }
475
+ /**
476
+ * Observe provider events (chainChanged, accountsChanged, connect,
477
+ * disconnect) node-side — the hook non-injected transports use to push
478
+ * session events. Dispatch is fire-and-forget; listener errors are
479
+ * swallowed. Returns an unsubscribe function.
480
+ */
481
+ onProviderEvent(listener) {
482
+ this.providerEventListeners.add(listener);
483
+ return () => {
484
+ this.providerEventListeners.delete(listener);
485
+ };
486
+ }
487
+ async injectMockProvider() {
488
+ // Fail fast on misconfigured accounts before any page plumbing exists —
489
+ // a bad fixture config surfaces here with a readable message instead of
490
+ // dying later inside the dapp with anvil's opaque -32602.
491
+ await this.assertAccountsKnownToNode(this.accounts, 'injectMockProvider');
492
+ // Context-level injection so pages the dapp opens itself (window.open,
493
+ // target=_blank flows) get the provider too.
494
+ const context = this.page.context();
495
+ try {
496
+ // exposeBinding rather than exposeFunction so the handler can see which
497
+ // frame is calling and enforce allowedOrigins.
498
+ await context.exposeBinding(rpcBridgeName, async (source, request) => {
499
+ try {
500
+ if (this.allowedOrigins) {
501
+ this.assertOriginAllowed(await resolveFrameOrigin(source));
502
+ }
503
+ const result = await this.handleRpcRequest(request);
504
+ return { ok: true, result };
505
+ }
506
+ catch (error) {
507
+ return { ok: false, error: serializeRpcError(error) };
508
+ }
509
+ });
510
+ }
511
+ catch (error) {
512
+ if (error instanceof Error && /already registered/i.test(error.message)) {
513
+ throw new Error('A mock wallet provider is already injected into this browser context. Create one MockWalletController per context.', { cause: error });
514
+ }
515
+ throw error;
516
+ }
89
517
  const config = {
90
518
  accounts: this.accounts,
91
519
  autoApprove: this.approveRequests,
92
520
  chainId: this.chainId,
93
521
  connected: this.connected,
522
+ unlocked: this.unlocked,
94
523
  providers: this.providerInfos,
524
+ allowedOrigins: this.allowedOrigins,
95
525
  };
96
526
  const providerScript = buildInjectedProviderScript(config);
97
- await this.page.addInitScript(providerScript);
98
- await this.page.evaluate(providerScript);
527
+ await context.addInitScript(providerScript);
528
+ await Promise.all(context.pages().map((page) => page.evaluate(providerScript).catch(() => undefined)));
99
529
  }
100
530
  autoApprove(enabled = true) {
101
531
  this.approveRequests = enabled;
102
532
  }
103
- async simulateRejection(methods = [...SIGNING_METHODS], message = 'User rejected the request.') {
533
+ configureHardwareWallet(options) {
534
+ this.hardwareWallet = normalizeHardwareWalletSimulation(options);
535
+ }
536
+ configureCoinbaseWallet(options) {
537
+ this.coinbase = normalizeCoinbaseWalletSimulation(options, this.coinbase.enabled, this.chainId, this.primaryAccount);
538
+ }
539
+ setHardwareWalletState(state) {
540
+ this.hardwareWallet = {
541
+ ...this.hardwareWallet,
542
+ enabled: true,
543
+ deviceState: state,
544
+ };
545
+ }
546
+ setHardwareWalletApprovalDelay(approvalDelayMs) {
547
+ this.hardwareWallet = normalizeHardwareWalletSimulation({
548
+ ...this.hardwareWallet,
549
+ approvalDelayMs,
550
+ });
551
+ }
552
+ /**
553
+ * One-shot: the next atomicRequired wallet_sendCalls while the atomic
554
+ * capability is 'ready' throws 5750 (user rejected the EOA upgrade)
555
+ * instead of upgrading to 'supported'.
556
+ */
557
+ simulateAtomicUpgradeRejection() {
558
+ this.upgradeRejectionArmed = true;
559
+ }
560
+ /**
561
+ * Arms approval for the next matching request while autoApprove is off —
562
+ * the explicit per-call grant for real-key (live) wallets. Queued
563
+ * rejections and holds still take precedence.
564
+ *
565
+ * A grant without `match` approves whatever matching request arrives first
566
+ * and never expires, so any page script (including a third-party include on
567
+ * an allowed origin) can race the dapp for it. Pass `match` to bind the
568
+ * grant to the expected payload, or use holdNextRequest() to inspect the
569
+ * request before deciding.
570
+ */
571
+ approveNext(methods, match) {
572
+ this.approvalQueue.push({
573
+ methods: typeof methods === 'string' ? [methods] : methods,
574
+ match,
575
+ });
576
+ }
577
+ async simulateRejection(methods = [...APPROVAL_GATED_METHODS], message = 'User rejected the request.') {
104
578
  this.rejectionQueue.push({
105
579
  methods: typeof methods === 'string' ? [methods] : methods,
106
580
  message,
107
581
  });
108
582
  }
109
- async setAccounts(accounts) {
583
+ /**
584
+ * Intercepts the next matching request and keeps it pending until the test
585
+ * approves or rejects it — for asserting "confirm in your wallet" UI states.
586
+ * Resolves once the page actually issues the request.
587
+ */
588
+ holdNextRequest(methods) {
589
+ return new Promise((resolveHeld) => {
590
+ this.holdQueue.push({
591
+ methods: typeof methods === 'string' ? [methods] : methods,
592
+ intercept: (method, params) => new Promise((approve, rejectGate) => {
593
+ resolveHeld({
594
+ method,
595
+ params,
596
+ approve: () => approve(),
597
+ reject: (message = 'User rejected the request.') => rejectGate(providerError(4001, message)),
598
+ });
599
+ }),
600
+ });
601
+ });
602
+ }
603
+ /**
604
+ * Resolves with the hash of the next transaction the page submits after
605
+ * this call. Invoke before triggering the dapp action, then await it.
606
+ */
607
+ async waitForNextTransaction(options = {}) {
608
+ const timeoutMs = options.timeoutMs ?? 15_000;
609
+ const baseline = this.sentTransactions.length;
610
+ const deadline = Date.now() + timeoutMs;
611
+ while (Date.now() < deadline) {
612
+ if (this.sentTransactions.length > baseline) {
613
+ return this.sentTransactions[baseline];
614
+ }
615
+ await new Promise((resolve) => setTimeout(resolve, 50));
616
+ }
617
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for the page to submit a transaction.`);
618
+ }
619
+ /**
620
+ * Resolves with the next approved wallet_watchAsset request after this
621
+ * call. Invoke before triggering the dapp action, then await it.
622
+ */
623
+ async waitForNextWatchedAsset(options = {}) {
624
+ const timeoutMs = options.timeoutMs ?? 15_000;
625
+ const baseline = this.watchedAssets.length;
626
+ const deadline = Date.now() + timeoutMs;
627
+ while (Date.now() < deadline) {
628
+ if (this.watchedAssets.length > baseline) {
629
+ return this.watchedAssets[baseline];
630
+ }
631
+ await new Promise((resolve) => setTimeout(resolve, 50));
632
+ }
633
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for the page to watch an asset.`);
634
+ }
635
+ /**
636
+ * Replaces the account set (and reconnects a disconnected wallet — unlike
637
+ * switchAccount, which only reorders). Accounts are validated against the
638
+ * backing node's eth_accounts; pass { allowUnknownAccounts: true } only
639
+ * for custom RpcClients whose account list the probe cannot see.
640
+ */
641
+ async setAccounts(accounts, options = {}) {
110
642
  if (accounts.length === 0) {
111
643
  throw new Error('setAccounts requires at least one account. Use disconnect() to expose no accounts.');
112
644
  }
645
+ if (!options.allowUnknownAccounts) {
646
+ await this.assertAccountsKnownToNode(accounts, 'setAccounts');
647
+ }
113
648
  this.accounts = [...accounts];
114
649
  this.connected = true;
115
- await this.emit('accountsChanged', this.accounts);
650
+ await this.emit('accountsChanged', this.unlocked ? this.accounts : []);
651
+ }
652
+ /**
653
+ * Re-selects one of the wallet's existing accounts: moves it to index 0
654
+ * (MetaMask orders eth_accounts most-recently-selected first) and emits
655
+ * accountsChanged with the reordered array. No event when it is already
656
+ * selected, and — unlike setAccounts — no reconnect while disconnected:
657
+ * the reorder stays internal until the wallet reconnects.
658
+ */
659
+ async switchAccount(address) {
660
+ const index = this.accounts.findIndex((account) => account.toLowerCase() === address.toLowerCase());
661
+ if (index === -1) {
662
+ throw new Error(`switchAccount: "${address}" is not one of the wallet's accounts. Use setAccounts() to change the set.`);
663
+ }
664
+ if (index === 0) {
665
+ return;
666
+ }
667
+ const [selected] = this.accounts.splice(index, 1);
668
+ this.accounts.unshift(selected);
669
+ if (this.connected && this.unlocked) {
670
+ await this.emit('accountsChanged', this.accounts);
671
+ }
672
+ }
673
+ get isUnlocked() {
674
+ return this.unlocked;
675
+ }
676
+ async setUnlocked(unlocked) {
677
+ if (this.unlocked === unlocked) {
678
+ return;
679
+ }
680
+ this.unlocked = unlocked;
681
+ if (this.connected) {
682
+ await this.emit('accountsChanged', unlocked ? this.accounts : []);
683
+ }
684
+ }
685
+ async lock() {
686
+ await this.setUnlocked(false);
687
+ }
688
+ async unlock() {
689
+ await this.setUnlocked(true);
116
690
  }
117
691
  async disconnect() {
118
692
  this.connected = false;
@@ -122,102 +696,914 @@ export class MockWalletController {
122
696
  async reconnect() {
123
697
  this.connected = true;
124
698
  await this.emit('connect', { chainId: this.chainId });
125
- await this.emit('accountsChanged', this.accounts);
699
+ await this.emit('accountsChanged', this.unlocked ? this.accounts : []);
126
700
  }
127
701
  async switchNetwork(chainId) {
128
- this.chainId = normalizeChainId(chainId);
702
+ const normalized = normalizeChainId(chainId);
703
+ // A test-driven switch counts as the user adding/approving the chain.
704
+ this.knownChainIds.add(normalized);
705
+ if (normalized === this.chainId) {
706
+ // MetaMask emits no chainChanged for a same-chain switch.
707
+ return;
708
+ }
709
+ this.chainId = normalized;
129
710
  await this.emit('chainChanged', this.chainId);
130
711
  }
131
712
  async emit(event, payload) {
132
- await this.page.evaluate(({ emitter, eventName, eventPayload }) => {
713
+ for (const listener of this.providerEventListeners) {
714
+ queueMicrotask(() => {
715
+ try {
716
+ listener(event, payload);
717
+ }
718
+ catch {
719
+ // Listener errors must never break wallet state transitions.
720
+ }
721
+ });
722
+ }
723
+ await Promise.all(this.page.context().pages().map((page) => page
724
+ .evaluate(({ emitter, eventName, eventPayload }) => {
133
725
  const maybeEmitter = window[emitter];
134
726
  if (typeof maybeEmitter === 'function') {
135
727
  maybeEmitter(eventName, eventPayload);
136
728
  }
137
- }, { emitter: emitterName, eventName: event, eventPayload: payload });
729
+ }, { emitter: emitterName, eventName: event, eventPayload: payload })
730
+ .catch(() => undefined)));
731
+ }
732
+ // The single seam every forwarded request routes through: a known-but-
733
+ // unbacked chain fails loudly here (EIP-1193 4901 "Chain Disconnected")
734
+ // instead of silently hitting the wrong node.
735
+ clientForChain(chainId) {
736
+ const client = this.chainBackends.get(chainId);
737
+ if (!client) {
738
+ throw providerError(4901, `The wallet is not connected to chain "${chainId}". It was added without an RPC backend — ` +
739
+ 'pass it in MockWalletControllerOptions.chains, call wallet.addChain(chainId, clientOrUrl), ' +
740
+ 'or enable trustDappRpcUrls.');
741
+ }
742
+ return client;
743
+ }
744
+ get activeRpcClient() {
745
+ return this.clientForChain(this.chainId);
746
+ }
747
+ enqueueSend(task) {
748
+ const run = this.sendQueue.then(task, task);
749
+ this.sendQueue = run.then(() => undefined, () => undefined);
750
+ return run;
751
+ }
752
+ assertEip5792Enabled(method) {
753
+ if (!this.eip5792.enabled) {
754
+ // Legacy-wallet posture: identical to the unknown wallet_* default.
755
+ throw providerError(4200, `The mock wallet does not support the method "${method}".`);
756
+ }
757
+ }
758
+ batchForId(id) {
759
+ const key = typeof id === 'string' ? id : undefined;
760
+ const record = key ? this.callBatches.get(key) : undefined;
761
+ if (!record) {
762
+ throw providerError(5730, `Unknown bundle id "${String(id)}".`);
763
+ }
764
+ return record;
138
765
  }
139
- consumeRejection(method) {
140
- const index = this.rejectionQueue.findIndex((rule) => !rule.methods || rule.methods.includes(method));
766
+ async handleSendCalls(params) {
767
+ const request = params[0];
768
+ // Validation order per spec + MetaMask: params (-32602) -> account
769
+ // (4100) -> chain (5710) -> size (5740) -> id (5720) -> capabilities
770
+ // (5700) -> atomicity (5760/5750) -> approval (4001) -> execution.
771
+ if (!request || typeof request !== 'object') {
772
+ throw providerError(-32602, 'wallet_sendCalls requires a request object.');
773
+ }
774
+ if (request.version !== '2.0.0') {
775
+ // The spec assigns no code; MetaMask requires 2.0.0.
776
+ throw providerError(-32602, 'wallet_sendCalls requires version "2.0.0".');
777
+ }
778
+ if (typeof request.atomicRequired !== 'boolean') {
779
+ throw providerError(-32602, 'wallet_sendCalls requires a boolean atomicRequired.');
780
+ }
781
+ const calls = request.calls;
782
+ if (!Array.isArray(calls) || calls.length === 0 || calls.some((call) => !call || typeof call !== 'object')) {
783
+ throw providerError(-32602, 'wallet_sendCalls requires a non-empty calls array.');
784
+ }
785
+ const chainId = parseDappChainId(request.chainId);
786
+ const from = request.from ?? this.primaryAccount;
787
+ if (!this.accounts.some((account) => account.toLowerCase() === String(from).toLowerCase())) {
788
+ throw providerError(4100, `The requested account ${String(from)} has not been authorized by the user.`);
789
+ }
790
+ // MetaMask-faithful: the batch must target the active, backed network.
791
+ if (chainId !== this.chainId || !this.chainBackends.has(chainId)) {
792
+ throw providerError(5710, `Chain ${chainId} is not the wallet's active chain (${this.chainId}).`);
793
+ }
794
+ if (calls.length > this.eip5792.maxCallsPerBatch) {
795
+ throw providerError(5740, `Batch of ${calls.length} calls exceeds the limit of ${this.eip5792.maxCallsPerBatch}.`);
796
+ }
797
+ let id;
798
+ if (request.id !== undefined) {
799
+ if (typeof request.id !== 'string' ||
800
+ !/^0x[0-9a-fA-F]*$/.test(request.id) ||
801
+ request.id.length > 8194) {
802
+ throw providerError(-32602, 'wallet_sendCalls id must be a 0x-hex string of at most 4096 bytes.');
803
+ }
804
+ id = request.id;
805
+ if (this.callBatches.has(id)) {
806
+ throw providerError(5720, `Duplicate bundle id "${id}".`);
807
+ }
808
+ }
809
+ else {
810
+ id = `0x${randomBytes(32).toString('hex')}`;
811
+ }
812
+ const advertised = new Set([
813
+ 'atomic',
814
+ ...Object.keys(this.eip5792.capabilities?.[chainId] ?? {}),
815
+ ...Object.keys(this.eip5792.capabilities?.['0x0'] ?? {}),
816
+ ]);
817
+ const capabilityEntries = [
818
+ ...Object.entries(request.capabilities ?? {}),
819
+ ...calls.flatMap((call) => Object.entries(call.capabilities ?? {})),
820
+ ];
821
+ for (const [name, value] of capabilityEntries) {
822
+ const optional = value?.optional === true;
823
+ if (!advertised.has(name) && !optional) {
824
+ throw providerError(5700, `Capability "${name}" is not supported on chain ${chainId}.`);
825
+ }
826
+ }
827
+ if (request.atomicRequired && this.atomicStatus === 'unsupported') {
828
+ throw providerError(5760, 'This wallet cannot execute the batch atomically.');
829
+ }
830
+ if (request.atomicRequired && this.atomicStatus === 'ready' && this.upgradeRejectionArmed) {
831
+ this.upgradeRejectionArmed = false;
832
+ throw providerError(5750, 'The user rejected the account upgrade required for atomic execution.');
833
+ }
834
+ // ONE approval gates the whole batch — a single approveNext arms all N
835
+ // calls (in live mode that is N real transactions; see docs).
836
+ await this.assertUserApproved('wallet_sendCalls', params);
837
+ const atomic = request.atomicRequired || this.atomicStatus !== 'unsupported';
838
+ const client = this.clientForChain(chainId);
839
+ const batchCalls = calls.map((call) => ({
840
+ to: call.to,
841
+ data: call.data,
842
+ value: call.value,
843
+ }));
844
+ const record = {
845
+ id,
846
+ chainId,
847
+ from: from,
848
+ version: '2.0.0',
849
+ atomic,
850
+ atomicRequired: request.atomicRequired,
851
+ capabilities: request.capabilities,
852
+ calls: batchCalls,
853
+ txHashes: [],
854
+ failure: undefined,
855
+ };
856
+ // The whole batch executes inside the send mutex so a concurrent
857
+ // page-initiated transaction can never be swallowed by the batch's
858
+ // snapshot/revert window.
859
+ await this.enqueueSend(() => this.executeBatch(record, client));
860
+ this.callBatches.set(id, record);
861
+ this.sentCallBatches.push(record);
862
+ if (atomic &&
863
+ record.failure === undefined &&
864
+ request.atomicRequired &&
865
+ this.atomicStatus === 'ready') {
866
+ // Emulates MetaMask's EOA -> smart-account upgrade on first use.
867
+ this.atomicStatus = 'supported';
868
+ }
869
+ return { id };
870
+ }
871
+ // Receipt-status-checked execution: anvil MINES reverting transactions
872
+ // with status 0x0 instead of erroring (no submission failure to catch), so
873
+ // each call's receipt is fetched synchronously under automine and a 0x0
874
+ // status triggers the rollback. With blockTime > 0 receipts are not
875
+ // synchronously available and atomic mode only rolls back submission-time
876
+ // failures — documented limitation.
877
+ async executeBatch(record, client) {
878
+ const txHashes = [];
879
+ let landed = 0;
880
+ let failed = false;
881
+ let snapshotId;
882
+ if (record.atomic) {
883
+ try {
884
+ snapshotId = await client.request({ method: 'evm_snapshot', params: [] });
885
+ }
886
+ catch {
887
+ throw providerError(-32603, 'Atomic execution needs an anvil-backed chain (evm_snapshot failed). ' +
888
+ "Configure eip5792: { atomic: 'unsupported' } for live chains.");
889
+ }
890
+ }
891
+ for (const call of record.calls) {
892
+ const transaction = { from: record.from };
893
+ if (call.to !== undefined)
894
+ transaction.to = call.to;
895
+ if (call.data !== undefined)
896
+ transaction.data = call.data;
897
+ if (call.value !== undefined)
898
+ transaction.value = call.value;
899
+ let hash;
900
+ try {
901
+ hash = (await client.request({
902
+ method: 'eth_sendTransaction',
903
+ params: [transaction],
904
+ }));
905
+ }
906
+ catch {
907
+ failed = true;
908
+ break;
909
+ }
910
+ txHashes.push(hash);
911
+ this.sentTransactions.push(hash);
912
+ this.sentTransactionRequests.push({
913
+ hash,
914
+ chainId: record.chainId,
915
+ from: record.from,
916
+ to: call.to,
917
+ data: call.data,
918
+ value: call.value !== undefined ? String(call.value) : undefined,
919
+ });
920
+ const receipt = (await client
921
+ .request({ method: 'eth_getTransactionReceipt', params: [hash] })
922
+ .catch(() => null));
923
+ if (receipt) {
924
+ landed += 1;
925
+ if (receipt.status === '0x0') {
926
+ failed = true;
927
+ break;
928
+ }
929
+ }
930
+ }
931
+ record.txHashes = txHashes;
932
+ if (failed && record.atomic) {
933
+ await client
934
+ .request({ method: 'evm_revert', params: [snapshotId] })
935
+ .catch(() => undefined);
936
+ record.failure = landed > 0 ? 'atomic-rollback' : 'nothing-landed';
937
+ }
938
+ else if (failed && landed === 0) {
939
+ record.failure = 'nothing-landed';
940
+ }
941
+ // Non-atomic with something landed: the status is computed from receipts
942
+ // (600 mixed / 500 all-reverted) in wallet_getCallsStatus.
943
+ }
944
+ async buildCallsStatus(record) {
945
+ const base = {
946
+ version: '2.0.0',
947
+ id: record.id,
948
+ chainId: record.chainId,
949
+ atomic: record.atomic,
950
+ };
951
+ if (record.failure === 'atomic-rollback') {
952
+ // The rolled-back transactions no longer exist on-chain; receipts are
953
+ // deliberately omitted (divergence from real MetaMask, which would
954
+ // return one reverted 7702 receipt — documented).
955
+ return { ...base, status: 500 };
956
+ }
957
+ if (record.failure === 'nothing-landed') {
958
+ return { ...base, status: 400, receipts: [] };
959
+ }
960
+ const client = this.clientForChain(record.chainId);
961
+ const receipts = await Promise.all(record.txHashes.map((hash) => client
962
+ .request({ method: 'eth_getTransactionReceipt', params: [hash] })
963
+ .catch(() => null)));
964
+ if (receipts.some((receipt) => receipt === null)) {
965
+ return { ...base, status: 100 };
966
+ }
967
+ const projected = receipts.map((receipt) => ({
968
+ logs: (receipt.logs ?? []).map((log) => ({
969
+ address: log.address,
970
+ data: log.data,
971
+ topics: log.topics,
972
+ })),
973
+ status: receipt.status,
974
+ blockHash: receipt.blockHash,
975
+ blockNumber: receipt.blockNumber,
976
+ gasUsed: receipt.gasUsed,
977
+ transactionHash: receipt.transactionHash,
978
+ }));
979
+ const reverted = projected.filter((receipt) => receipt.status === '0x0').length;
980
+ const status = reverted === 0 ? 200 : reverted === projected.length ? 500 : 600;
981
+ return { ...base, status, receipts: projected };
982
+ }
983
+ consumeRule(queue, method) {
984
+ const index = queue.findIndex((rule) => !rule.methods || rule.methods.includes(method));
141
985
  if (index === -1) {
142
986
  return undefined;
143
987
  }
144
- const [rule] = this.rejectionQueue.splice(index, 1);
988
+ const [rule] = queue.splice(index, 1);
145
989
  return rule;
146
990
  }
147
- assertUserApproved(method) {
148
- const forcedRejection = this.consumeRejection(method);
991
+ // Bounded probe of the backing node's account list. Returns undefined when
992
+ // the node cannot answer (throw, timeout, non-array, empty) — validation
993
+ // then fails open, which is what keeps live RPC endpoints and custom
994
+ // RpcClients usable.
995
+ async fetchNodeAccounts() {
996
+ const TIMED_OUT = Symbol('probe-timeout');
997
+ try {
998
+ const result = await Promise.race([
999
+ this.rpcClient.request({ method: 'eth_accounts', params: [] }),
1000
+ new Promise((resolve) => setTimeout(() => resolve(TIMED_OUT), 2_500)),
1001
+ ]);
1002
+ if (result === TIMED_OUT || !Array.isArray(result) || result.length === 0) {
1003
+ return undefined;
1004
+ }
1005
+ return new Set(result.map((account) => String(account).toLowerCase()));
1006
+ }
1007
+ catch {
1008
+ return undefined;
1009
+ }
1010
+ }
1011
+ // Membership in the node's eth_accounts means the node will ACCEPT sends
1012
+ // from the account — not that it can sign messages for it: anvil lists
1013
+ // impersonated accounts too (their personal_sign still fails node-side
1014
+ // with -32602). Re-probes on a miss so accounts impersonated after the
1015
+ // first probe validate without any escape hatch. Best-effort under
1016
+ // anvil --auto-impersonate, where every address is accepted.
1017
+ async assertAccountsKnownToNode(accounts, operation) {
1018
+ let known = this.nodeAccountsCache ?? (await this.fetchNodeAccounts());
1019
+ if (!known) {
1020
+ return;
1021
+ }
1022
+ this.nodeAccountsCache = known;
1023
+ const unknownIn = (set) => accounts.filter((account) => !set.has(account.toLowerCase()));
1024
+ if (unknownIn(known).length > 0) {
1025
+ known = await this.fetchNodeAccounts();
1026
+ if (!known) {
1027
+ return;
1028
+ }
1029
+ this.nodeAccountsCache = known;
1030
+ }
1031
+ const unknown = unknownIn(known);
1032
+ if (unknown.length > 0) {
1033
+ throw new Error(`${operation}: account(s) ${unknown.join(', ')} are not known to the backing node ` +
1034
+ `(${known.size} node accounts). Use addresses from chain.accounts(), or ` +
1035
+ 'chain.impersonateAccount(address) for send-only flows (impersonated accounts can send ' +
1036
+ 'transactions, but personal_sign/typed-data still fail node-side with -32602). For custom ' +
1037
+ 'RpcClients that cannot answer eth_accounts, pass { allowUnknownAccounts: true } to setAccounts.');
1038
+ }
1039
+ }
1040
+ assertOriginAllowed(origin) {
1041
+ if (!this.allowedOrigins) {
1042
+ return;
1043
+ }
1044
+ if (!this.allowedOrigins.includes(origin)) {
1045
+ throw providerError(4100, `The wallet is not available to origin "${origin}" (allowedOrigins: ${this.allowedOrigins.join(', ')}).`);
1046
+ }
1047
+ }
1048
+ async assertHardwareWalletReady(method) {
1049
+ if (!this.hardwareWallet.enabled || !this.hardwareWallet.methods.includes(method)) {
1050
+ return;
1051
+ }
1052
+ switch (this.hardwareWallet.deviceState) {
1053
+ case 'ready':
1054
+ if (this.hardwareWallet.approvalDelayMs > 0) {
1055
+ await wait(this.hardwareWallet.approvalDelayMs);
1056
+ }
1057
+ return;
1058
+ case 'locked':
1059
+ throw providerError(4001, 'Hardware wallet is locked. Unlock the device and try again.');
1060
+ case 'wrong-app':
1061
+ throw providerError(4001, `Open the ${hardwareWalletRequiredApp(method, this.hardwareWallet)} app on your hardware wallet and try again.`);
1062
+ case 'blind-signing-disabled':
1063
+ throw providerError(4001, 'Blind signing is disabled on your hardware wallet.');
1064
+ case 'disconnected':
1065
+ throw providerError(4900, 'Hardware wallet disconnected.');
1066
+ }
1067
+ }
1068
+ async assertUserApproved(method, params) {
1069
+ const hold = this.consumeRule(this.holdQueue, method);
1070
+ if (hold) {
1071
+ // The test's explicit approve()/reject() decision is authoritative;
1072
+ // it bypasses autoApprove and queued rejection rules.
1073
+ await hold.intercept(method, params);
1074
+ return;
1075
+ }
1076
+ const forcedRejection = this.consumeRule(this.rejectionQueue, method);
149
1077
  if (forcedRejection) {
150
1078
  throw providerError(4001, forcedRejection.message ?? 'User rejected the request.');
151
1079
  }
152
- if (!this.approveRequests && (SIGNING_METHODS.has(method) || method === 'eth_requestAccounts')) {
1080
+ const armedIndex = this.approvalQueue.findIndex((rule) => (!rule.methods || rule.methods.includes(method)) &&
1081
+ (!rule.match || rule.match(method, params)));
1082
+ if (armedIndex !== -1) {
1083
+ this.approvalQueue.splice(armedIndex, 1);
1084
+ }
1085
+ else if (!this.approveRequests && APPROVAL_GATED_METHODS.has(method)) {
153
1086
  throw providerError(4001, 'User rejected the request.');
154
1087
  }
1088
+ await this.assertHardwareWalletReady(method);
1089
+ }
1090
+ permissionResponse() {
1091
+ return [
1092
+ {
1093
+ parentCapability: 'eth_accounts',
1094
+ caveats: [{ type: 'restrictReturnedAccounts', value: [...this.accounts] }],
1095
+ },
1096
+ ];
1097
+ }
1098
+ requestedPermissionKeys(params, method) {
1099
+ if (params.length === 0 || params[0] === undefined) {
1100
+ return ['eth_accounts'];
1101
+ }
1102
+ const request = assertRpcObject(params[0], method);
1103
+ const keys = Object.keys(request);
1104
+ if (keys.length === 0) {
1105
+ throw providerError(-32602, `${method} requires at least one permission.`);
1106
+ }
1107
+ return keys;
1108
+ }
1109
+ assertSupportedPermissions(params, method) {
1110
+ const unsupported = this.requestedPermissionKeys(params, method).filter((permission) => permission !== 'eth_accounts');
1111
+ if (unsupported.length > 0) {
1112
+ throw providerError(4200, `The mock wallet does not support permission "${unsupported[0]}".`);
1113
+ }
1114
+ }
1115
+ async handleRequestPermissions(params) {
1116
+ this.assertSupportedPermissions(params, 'wallet_requestPermissions');
1117
+ await this.assertUserApproved('wallet_requestPermissions', params);
1118
+ const wasConnected = this.connected;
1119
+ this.connected = true;
1120
+ if (!wasConnected) {
1121
+ await this.emit('connect', { chainId: this.chainId });
1122
+ }
1123
+ await this.emit('accountsChanged', this.accounts);
1124
+ return this.permissionResponse();
1125
+ }
1126
+ async handleRevokePermissions(params) {
1127
+ this.assertSupportedPermissions(params, 'wallet_revokePermissions');
1128
+ this.connected = false;
1129
+ await this.emit('accountsChanged', []);
1130
+ return null;
1131
+ }
1132
+ assertCoinbaseEnabled(method) {
1133
+ if (!this.coinbase.enabled) {
1134
+ throw providerError(4200, `The mock wallet does not support the method "${method}".`);
1135
+ }
1136
+ }
1137
+ assertCoinbaseConnected() {
1138
+ if (!this.connected) {
1139
+ throw providerError(4100, 'The requested account and/or method has not been authorized by the user.');
1140
+ }
1141
+ }
1142
+ assertAuthorizedAccount(account) {
1143
+ if (!this.accounts.some((authorized) => sameAddress(authorized, account))) {
1144
+ throw providerError(4100, `The requested account ${account} has not been authorized by the user.`);
1145
+ }
1146
+ }
1147
+ async buildCoinbaseSiweCapability(capability) {
1148
+ const config = assertRpcObject(capability, 'wallet_connect signInWithEthereum');
1149
+ const nonce = config.nonce;
1150
+ if (typeof nonce !== 'string' || nonce.length === 0) {
1151
+ throw providerError(-32602, 'signInWithEthereum.nonce must be a non-empty string.');
1152
+ }
1153
+ const chainId = parseCoinbaseChainId(config.chainId, 'signInWithEthereum.chainId');
1154
+ if (chainId !== this.chainId) {
1155
+ throw providerError(-32602, `signInWithEthereum.chainId ${chainId} does not match the active chain ${this.chainId}.`);
1156
+ }
1157
+ const domain = typeof config.domain === 'string' && config.domain.length > 0
1158
+ ? config.domain
1159
+ : 'web3-tester.local';
1160
+ const uri = typeof config.uri === 'string' && config.uri.length > 0
1161
+ ? config.uri
1162
+ : `https://${domain}`;
1163
+ const statement = typeof config.statement === 'string' && config.statement.length > 0
1164
+ ? config.statement
1165
+ : 'Sign in with Coinbase Wallet.';
1166
+ const resources = Array.isArray(config.resources)
1167
+ ? config.resources.filter((resource) => typeof resource === 'string')
1168
+ : [];
1169
+ const messageLines = [
1170
+ `${domain} wants you to sign in with your Ethereum account:`,
1171
+ this.primaryAccount,
1172
+ '',
1173
+ statement,
1174
+ '',
1175
+ `URI: ${uri}`,
1176
+ 'Version: 1',
1177
+ `Chain ID: ${Number(BigInt(chainId))}`,
1178
+ `Nonce: ${nonce}`,
1179
+ 'Issued At: 1970-01-01T00:00:00.000Z',
1180
+ ];
1181
+ if (resources.length > 0) {
1182
+ messageLines.push('Resources:', ...resources.map((resource) => `- ${resource}`));
1183
+ }
1184
+ const message = messageLines.join('\n');
1185
+ const signature = (await this.activeRpcClient.request({
1186
+ method: 'personal_sign',
1187
+ params: [toHex(message), this.primaryAccount],
1188
+ }));
1189
+ return { message, signature };
1190
+ }
1191
+ async handleWalletConnect(params) {
1192
+ this.assertCoinbaseEnabled('wallet_connect');
1193
+ const request = params[0] === undefined ? {} : assertRpcObject(params[0], 'wallet_connect');
1194
+ await this.assertUserApproved('wallet_connect', params);
1195
+ if (!this.connected) {
1196
+ this.connected = true;
1197
+ await this.emit('connect', { chainId: this.chainId });
1198
+ await this.emit('accountsChanged', this.accounts);
1199
+ }
1200
+ const capabilities = isRecord(request.capabilities) ? request.capabilities : undefined;
1201
+ const result = {
1202
+ accounts: this.accounts.map((address) => ({ address })),
1203
+ chainId: this.chainId,
1204
+ isConnected: true,
1205
+ };
1206
+ if (capabilities?.signInWithEthereum !== undefined) {
1207
+ result.capabilities = {
1208
+ signInWithEthereum: await this.buildCoinbaseSiweCapability(capabilities.signInWithEthereum),
1209
+ };
1210
+ }
1211
+ return result;
1212
+ }
1213
+ handleWalletGetSubAccounts(params) {
1214
+ this.assertCoinbaseEnabled('wallet_getSubAccounts');
1215
+ this.assertCoinbaseConnected();
1216
+ const request = assertRpcObject(params[0], 'wallet_getSubAccounts');
1217
+ const account = assertRpcAddress(request.account, 'wallet_getSubAccounts.account');
1218
+ this.assertAuthorizedAccount(account);
1219
+ if (typeof request.domain !== 'string' || request.domain.length === 0) {
1220
+ throw providerError(-32602, 'wallet_getSubAccounts.domain must be a non-empty string.');
1221
+ }
1222
+ const subAccounts = this.coinbase.subAccounts
1223
+ .filter((subAccount) => subAccount.chainId === this.chainId &&
1224
+ sameAddress(subAccount.account, account) &&
1225
+ subAccount.domain === request.domain)
1226
+ .map(cloneCoinbaseSubAccount);
1227
+ return { subAccounts };
1228
+ }
1229
+ async handleWalletAddSubAccount(params) {
1230
+ this.assertCoinbaseEnabled('wallet_addSubAccount');
1231
+ this.assertCoinbaseConnected();
1232
+ const request = assertRpcObject(params[0], 'wallet_addSubAccount');
1233
+ const accountConfig = assertRpcObject(request.account, 'wallet_addSubAccount.account');
1234
+ const type = accountConfig.type;
1235
+ if (type !== 'create' && type !== 'deployed') {
1236
+ throw providerError(-32602, 'wallet_addSubAccount.account.type must be "create" or "deployed".');
1237
+ }
1238
+ if (request.domain !== undefined && typeof request.domain !== 'string') {
1239
+ throw providerError(-32602, 'wallet_addSubAccount.domain must be a string when provided.');
1240
+ }
1241
+ let chainId = this.chainId;
1242
+ let address;
1243
+ if (type === 'deployed') {
1244
+ address = assertRpcAddress(accountConfig.address, 'wallet_addSubAccount.account.address');
1245
+ chainId = parseCoinbaseChainId(accountConfig.chainId, 'wallet_addSubAccount.account.chainId');
1246
+ }
1247
+ else {
1248
+ if (!Array.isArray(accountConfig.keys) || accountConfig.keys.length === 0) {
1249
+ throw providerError(-32602, 'wallet_addSubAccount.account.keys must be a non-empty array.');
1250
+ }
1251
+ for (const key of accountConfig.keys) {
1252
+ if (!isRecord(key) || typeof key.type !== 'string' || typeof key.publicKey !== 'string') {
1253
+ throw providerError(-32602, 'wallet_addSubAccount.account.keys entries require type and publicKey strings.');
1254
+ }
1255
+ }
1256
+ address =
1257
+ typeof accountConfig.address === 'string' && isAddress(accountConfig.address)
1258
+ ? accountConfig.address
1259
+ : generatedSubAccountAddress(this.primaryAccount, chainId, this.coinbase.subAccounts.length, accountConfig);
1260
+ }
1261
+ const factory = request.factory !== undefined
1262
+ ? assertRpcAddress(request.factory, 'wallet_addSubAccount.factory')
1263
+ : accountConfig.factory !== undefined
1264
+ ? assertRpcAddress(accountConfig.factory, 'wallet_addSubAccount.account.factory')
1265
+ : this.coinbase.factory;
1266
+ const factoryData = request.factoryData !== undefined
1267
+ ? assertRpcHex(request.factoryData, 'wallet_addSubAccount.factoryData', { allowEmpty: true })
1268
+ : accountConfig.factoryData !== undefined
1269
+ ? assertRpcHex(accountConfig.factoryData, 'wallet_addSubAccount.account.factoryData', {
1270
+ allowEmpty: true,
1271
+ })
1272
+ : this.coinbase.factoryData;
1273
+ await this.assertUserApproved('wallet_addSubAccount', params);
1274
+ const subAccount = {
1275
+ address,
1276
+ chainId,
1277
+ account: this.primaryAccount,
1278
+ ...(typeof request.domain === 'string' ? { domain: request.domain } : {}),
1279
+ ...(factory ? { factory } : {}),
1280
+ ...(factoryData ? { factoryData } : {}),
1281
+ };
1282
+ this.coinbase.subAccounts.push(subAccount);
1283
+ return {
1284
+ address,
1285
+ chainId,
1286
+ ...(factory ? { factory } : {}),
1287
+ ...(factoryData ? { factoryData } : {}),
1288
+ };
1289
+ }
1290
+ async handleCoinbaseFetchPermissions(params) {
1291
+ this.assertCoinbaseEnabled('coinbase_fetchPermissions');
1292
+ this.assertCoinbaseConnected();
1293
+ const request = assertRpcObject(params[0], 'coinbase_fetchPermissions');
1294
+ const spender = assertRpcAddress(request.spender, 'coinbase_fetchPermissions.spender');
1295
+ const chainId = parseDappChainId(request.chainId);
1296
+ const account = request.account === undefined
1297
+ ? undefined
1298
+ : assertRpcAddress(request.account, 'coinbase_fetchPermissions.account');
1299
+ if (account) {
1300
+ this.assertAuthorizedAccount(account);
1301
+ }
1302
+ const pageOptions = request.pageOptions === undefined
1303
+ ? {}
1304
+ : assertRpcObject(request.pageOptions, 'coinbase_fetchPermissions.pageOptions');
1305
+ const pageSize = typeof pageOptions.pageSize === 'number' && Number.isSafeInteger(pageOptions.pageSize)
1306
+ ? pageOptions.pageSize
1307
+ : 50;
1308
+ if (pageSize <= 0) {
1309
+ throw providerError(-32602, 'coinbase_fetchPermissions.pageOptions.pageSize must be positive.');
1310
+ }
1311
+ const cursor = pageOptions.cursor === undefined
1312
+ ? 0
1313
+ : typeof pageOptions.cursor === 'string' && /^\d+$/.test(pageOptions.cursor)
1314
+ ? Number(pageOptions.cursor)
1315
+ : (() => {
1316
+ throw providerError(-32602, 'coinbase_fetchPermissions.pageOptions.cursor must be a decimal string.');
1317
+ })();
1318
+ await this.assertUserApproved('coinbase_fetchPermissions', params);
1319
+ const nowSeconds = Math.floor(Date.now() / 1000);
1320
+ const matching = this.coinbase.permissions
1321
+ .filter((permission) => permission.chainId === chainId &&
1322
+ sameAddress(permission.spendPermission.spender, spender) &&
1323
+ (account === undefined || sameAddress(permission.spendPermission.account, account)) &&
1324
+ (permission.spendPermission.end === 0 || permission.spendPermission.end > nowSeconds))
1325
+ .sort((left, right) => left.createdAt - right.createdAt);
1326
+ const page = matching.slice(cursor, cursor + pageSize);
1327
+ const nextCursor = cursor + page.length < matching.length ? String(cursor + page.length) : undefined;
1328
+ return {
1329
+ permissions: page.map(cloneCoinbasePermission),
1330
+ pageDescription: {
1331
+ pageSize: page.length,
1332
+ ...(nextCursor ? { nextCursor } : {}),
1333
+ },
1334
+ };
1335
+ }
1336
+ handleCoinbaseFetchPermission(params) {
1337
+ this.assertCoinbaseEnabled('coinbase_fetchPermission');
1338
+ const request = assertRpcObject(params[0], 'coinbase_fetchPermission');
1339
+ const permissionHash = assertRpcHash(request.permissionHash, 'coinbase_fetchPermission.permissionHash');
1340
+ const permission = this.coinbase.permissions.find((candidate) => candidate.permissionHash.toLowerCase() === permissionHash.toLowerCase());
1341
+ if (!permission) {
1342
+ throw providerError(-32603, `No Coinbase spend permission found for ${permissionHash}.`);
1343
+ }
1344
+ return { permission: cloneCoinbasePermission(permission) };
1345
+ }
1346
+ async handleWatchAsset(params) {
1347
+ const request = assertRpcObject(params[0], 'wallet_watchAsset');
1348
+ if (typeof request.type !== 'string' || request.type.length === 0) {
1349
+ throw providerError(-32602, 'wallet_watchAsset.type must be a non-empty string.');
1350
+ }
1351
+ const options = assertRpcObject(request.options, 'wallet_watchAsset.options');
1352
+ if (request.type === 'ERC20' && !isAddress(String(options.address ?? ''))) {
1353
+ throw providerError(-32602, 'wallet_watchAsset.options.address must be a valid address.');
1354
+ }
1355
+ await this.assertUserApproved('wallet_watchAsset', params);
1356
+ this.watchedAssets.push({
1357
+ chainId: this.chainId,
1358
+ type: request.type,
1359
+ options: { ...options },
1360
+ request,
1361
+ });
1362
+ return true;
155
1363
  }
156
1364
  async handleRpcRequest(request) {
157
1365
  const { method } = request;
158
1366
  const params = normalizeParams(request.params);
1367
+ if (!this.unlocked && UNLOCK_REQUIRED_METHODS.has(method)) {
1368
+ throw providerError(4100, 'The wallet is locked. Unlock the wallet and try again.');
1369
+ }
1370
+ if (!this.connected && SIGNING_METHODS.has(method)) {
1371
+ throw providerError(4100, 'The requested account and/or method has not been authorized by the user.');
1372
+ }
159
1373
  switch (method) {
160
1374
  case 'eth_accounts':
161
- return this.connected ? this.accounts : [];
1375
+ return this.connected && this.unlocked ? this.accounts : [];
1376
+ case 'solana_getAccounts':
1377
+ return this.solanaAccounts;
162
1378
  case 'eth_requestAccounts':
163
- this.assertUserApproved(method);
1379
+ await this.assertUserApproved(method, params);
164
1380
  if (!this.connected) {
165
1381
  this.connected = true;
166
1382
  await this.emit('connect', { chainId: this.chainId });
167
1383
  await this.emit('accountsChanged', this.accounts);
168
1384
  }
169
1385
  return this.accounts;
1386
+ case 'wallet_connect':
1387
+ return this.handleWalletConnect(params);
1388
+ case 'solana_requestAccounts':
1389
+ await this.assertUserApproved(method, params);
1390
+ if (!this.connected) {
1391
+ this.connected = true;
1392
+ await this.emit('connect', { chainId: this.chainId });
1393
+ await this.emit('accountsChanged', this.accounts);
1394
+ }
1395
+ return this.solanaAccounts;
170
1396
  case 'eth_chainId':
171
1397
  return this.chainId;
172
1398
  case 'net_version':
173
1399
  return String(Number(BigInt(this.chainId)));
1400
+ case 'eth_subscribe':
1401
+ case 'eth_unsubscribe':
1402
+ throw providerError(4200, `The mock wallet does not support the method "${method}".`);
174
1403
  case 'wallet_getPermissions':
175
- return this.connected ? permissionResponse : [];
1404
+ return this.connected && this.unlocked ? this.permissionResponse() : [];
176
1405
  case 'wallet_requestPermissions':
177
- this.assertUserApproved(method);
178
- this.connected = true;
179
- await this.emit('accountsChanged', this.accounts);
180
- return permissionResponse;
1406
+ return this.handleRequestPermissions(params);
1407
+ case 'wallet_revokePermissions':
1408
+ return this.handleRevokePermissions(params);
1409
+ case 'wallet_addSubAccount':
1410
+ return this.handleWalletAddSubAccount(params);
1411
+ case 'wallet_getSubAccounts':
1412
+ return this.handleWalletGetSubAccounts(params);
1413
+ case 'coinbase_fetchPermissions':
1414
+ return this.handleCoinbaseFetchPermissions(params);
1415
+ case 'coinbase_fetchPermission':
1416
+ return this.handleCoinbaseFetchPermission(params);
1417
+ // Per-handler order, library-wide: param validation (-32602) -> state
1418
+ // checks (4902) -> approval -> execution. Real MetaMask returns 4902
1419
+ // for an unknown chain without ever showing a prompt.
181
1420
  case 'wallet_switchEthereumChain': {
182
- this.assertUserApproved(method);
183
1421
  const requestedChain = params[0];
184
1422
  if (!requestedChain?.chainId) {
185
1423
  throw providerError(-32602, 'wallet_switchEthereumChain requires a chainId.');
186
1424
  }
187
- await this.switchNetwork(requestedChain.chainId);
1425
+ const normalized = parseDappChainId(requestedChain.chainId);
1426
+ if (!this.knownChainIds.has(normalized)) {
1427
+ throw providerError(4902, `Unrecognized chain ID "${normalized}". Try adding the chain using wallet_addEthereumChain first.`);
1428
+ }
1429
+ await this.assertUserApproved(method, params);
1430
+ await this.switchNetwork(normalized);
188
1431
  return null;
189
1432
  }
190
- case 'wallet_addEthereumChain':
191
- this.assertUserApproved(method);
1433
+ case 'wallet_addEthereumChain': {
1434
+ const definition = params[0];
1435
+ if (typeof definition?.chainId !== 'string' || !definition.chainId.startsWith('0x')) {
1436
+ throw providerError(-32602, 'wallet_addEthereumChain requires a 0x-prefixed chainId.');
1437
+ }
1438
+ const normalized = parseDappChainId(definition.chainId);
1439
+ // EIP-3085: the wallet MUST reject when rpcUrls is missing, empty, or
1440
+ // contains invalid URLs (MetaMask does too; wagmi always sends them).
1441
+ const rpcUrls = definition.rpcUrls;
1442
+ const urlsValid = Array.isArray(rpcUrls) &&
1443
+ rpcUrls.length > 0 &&
1444
+ rpcUrls.every((url) => {
1445
+ if (typeof url !== 'string') {
1446
+ return false;
1447
+ }
1448
+ try {
1449
+ new URL(url);
1450
+ return true;
1451
+ }
1452
+ catch {
1453
+ return false;
1454
+ }
1455
+ });
1456
+ if (!urlsValid) {
1457
+ throw providerError(-32602, 'wallet_addEthereumChain requires rpcUrls: a non-empty array of valid URLs.');
1458
+ }
1459
+ await this.assertUserApproved(method, params);
1460
+ // First registration wins for dapp adds: re-adding a backed chain is
1461
+ // a no-op switch, like MetaMask.
1462
+ if (this.trustDappRpcUrls && !this.chainBackends.has(normalized)) {
1463
+ const [url] = rpcUrls;
1464
+ // http(s) only; we deliberately allow http: (localhost Anvil is the
1465
+ // dominant test case), deviating from EIP-3085's https-only rule.
1466
+ if (!/^https?:$/.test(new URL(url).protocol)) {
1467
+ throw providerError(-32602, `rpcUrls[0] "${url}" is not an http(s) URL.`);
1468
+ }
1469
+ let reported;
1470
+ try {
1471
+ reported = await probeChainId(url);
1472
+ }
1473
+ catch {
1474
+ throw providerError(-32602, `rpcUrls[0] "${url}" is unreachable or did not answer eth_chainId.`);
1475
+ }
1476
+ if (reported !== normalized) {
1477
+ // EIP-3085: reject when the URL's eth_chainId does not match.
1478
+ throw providerError(-32602, `rpcUrls[0] reports chain id ${reported} but ${normalized} was requested.`);
1479
+ }
1480
+ this.chainBackends.set(normalized, httpRpcClient(url));
1481
+ }
1482
+ this.knownChainIds.add(normalized);
1483
+ // MetaMask offers to switch after adding; the mock approves that too
1484
+ // (wagmi verifies eth_chainId === target after an add and hard-fails
1485
+ // if the wallet did not switch). switchNetwork skips the chainChanged
1486
+ // emit when the chain is already active.
1487
+ await this.switchNetwork(normalized);
192
1488
  return null;
1489
+ }
193
1490
  case 'wallet_watchAsset':
194
- this.assertUserApproved(method);
195
- return true;
1491
+ return this.handleWatchAsset(params);
1492
+ case 'wallet_getCapabilities': {
1493
+ this.assertEip5792Enabled(method);
1494
+ // Spec privacy rule: only answer for the connected wallet's accounts.
1495
+ const [address, chainIdFilter] = params;
1496
+ const requested = typeof address === 'string' ? address.toLowerCase() : '';
1497
+ if (!this.connected ||
1498
+ !this.unlocked ||
1499
+ !this.accounts.some((account) => account.toLowerCase() === requested)) {
1500
+ throw providerError(4100, 'The requested account and/or method has not been authorized by the user.');
1501
+ }
1502
+ const response = {};
1503
+ for (const chainId of this.chainBackends.keys()) {
1504
+ response[chainId] = { atomic: { status: this.atomicStatus } };
1505
+ }
1506
+ for (const [key, value] of Object.entries(this.eip5792.capabilities ?? {})) {
1507
+ const chainId = key === '0x0' ? '0x0' : normalizeChainId(key);
1508
+ response[chainId] = { ...response[chainId], ...value };
1509
+ }
1510
+ if (Array.isArray(chainIdFilter)) {
1511
+ const wanted = new Set(chainIdFilter.map((id) => parseDappChainId(id)));
1512
+ for (const key of Object.keys(response)) {
1513
+ if (key !== '0x0' && !wanted.has(key)) {
1514
+ delete response[key];
1515
+ }
1516
+ }
1517
+ }
1518
+ return response;
1519
+ }
1520
+ case 'wallet_sendCalls': {
1521
+ this.assertEip5792Enabled(method);
1522
+ return this.handleSendCalls(params);
1523
+ }
1524
+ case 'wallet_getCallsStatus': {
1525
+ this.assertEip5792Enabled(method);
1526
+ const record = this.batchForId(params[0]);
1527
+ return this.buildCallsStatus(record);
1528
+ }
1529
+ case 'wallet_showCallsStatus': {
1530
+ this.assertEip5792Enabled(method);
1531
+ const record = this.batchForId(params[0]);
1532
+ this.shownCallsStatusIds.push(record.id);
1533
+ return null;
1534
+ }
196
1535
  case 'metamask_getProviderState':
197
1536
  return {
198
- accounts: this.connected ? this.accounts : [],
1537
+ accounts: this.connected && this.unlocked ? this.accounts : [],
199
1538
  chainId: this.chainId,
200
- isUnlocked: true,
1539
+ isUnlocked: this.unlocked,
201
1540
  networkVersion: String(Number(BigInt(this.chainId))),
202
1541
  };
203
1542
  case 'eth_sendTransaction': {
204
- this.assertUserApproved(method);
205
1543
  const transaction = { ...params[0] };
206
1544
  transaction.from ??= this.primaryAccount;
207
- return this.rpcClient.request({
208
- method,
209
- params: [transaction],
1545
+ // MetaMask rejects sends from accounts the dapp is not authorized
1546
+ // for; anvil would happily sign with ANY unlocked dev account.
1547
+ const from = String(transaction.from).toLowerCase();
1548
+ if (!this.accounts.some((account) => account.toLowerCase() === from)) {
1549
+ throw providerError(4100, `The requested account ${String(transaction.from)} has not been authorized by the user.`);
1550
+ }
1551
+ await this.assertUserApproved(method, params);
1552
+ // Capture the routed chain at approval time so a concurrent switch
1553
+ // cannot redirect a queued send.
1554
+ const chainId = this.chainId;
1555
+ const client = this.activeRpcClient;
1556
+ const hash = (await this.enqueueSend(() => client.request({ method, params: [transaction] })));
1557
+ this.sentTransactions.push(hash);
1558
+ this.sentTransactionRequests.push({
1559
+ hash,
1560
+ chainId,
1561
+ from: transaction.from,
1562
+ to: transaction.to,
1563
+ data: transaction.data,
1564
+ value: transaction.value !== undefined ? String(transaction.value) : undefined,
210
1565
  });
1566
+ return hash;
1567
+ }
1568
+ case 'eth_sendRawTransaction': {
1569
+ await this.assertUserApproved(method, params);
1570
+ const chainId = this.chainId;
1571
+ const client = this.activeRpcClient;
1572
+ const hash = (await this.enqueueSend(() => client.request({ method, params })));
1573
+ this.sentTransactions.push(hash);
1574
+ this.sentTransactionRequests.push({ hash, chainId });
1575
+ return hash;
211
1576
  }
212
- case 'eth_sign':
213
1577
  case 'eth_signTypedData':
1578
+ throw providerError(4200, 'eth_signTypedData (legacy v1) is not supported by the mock wallet. Use eth_signTypedData_v4.');
214
1579
  case 'eth_signTypedData_v3':
1580
+ // Anvil only implements v4; v3 payloads (no arrays or recursive
1581
+ // structs) hash identically under v4 rules.
1582
+ await this.assertUserApproved(method, params);
1583
+ return this.activeRpcClient.request({ method: 'eth_signTypedData_v4', params });
1584
+ case 'eth_sign':
215
1585
  case 'eth_signTypedData_v4':
216
1586
  case 'personal_sign':
217
- this.assertUserApproved(method);
218
- return this.rpcClient.request({ method, params });
1587
+ await this.assertUserApproved(method, params);
1588
+ return this.activeRpcClient.request({ method, params });
1589
+ case 'solana_signAllTransactions':
1590
+ case 'solana_signAndSendAllTransactions':
1591
+ case 'solana_signAndSendTransaction':
1592
+ case 'solana_signIn':
1593
+ case 'solana_signMessage':
1594
+ case 'solana_signTransaction':
1595
+ await this.assertUserApproved(method, params);
1596
+ return null;
219
1597
  default:
220
- return this.rpcClient.request({ method, params });
1598
+ // Unknown wallet-namespace methods are the wallet's responsibility;
1599
+ // forwarding them to the node would leak a confusing -32601.
1600
+ if (method.startsWith('wallet_')) {
1601
+ throw providerError(4200, `The mock wallet does not support the method "${method}".`);
1602
+ }
1603
+ if (method.startsWith('coinbase_')) {
1604
+ throw providerError(4200, `The mock wallet does not support the method "${method}".`);
1605
+ }
1606
+ return this.activeRpcClient.request({ method, params });
221
1607
  }
222
1608
  }
223
1609
  }