@marigoldlabs/web3-tester 0.1.2 → 0.4.1

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 (75) hide show
  1. package/.env.example +26 -17
  2. package/LICENSE +21 -0
  3. package/README.md +167 -41
  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/contracts/test-erc20.d.ts +227 -0
  9. package/dist/contracts/test-erc20.d.ts.map +1 -0
  10. package/dist/contracts/test-erc20.js +8 -0
  11. package/dist/contracts/test-erc20.js.map +1 -0
  12. package/dist/erc20.d.ts +38 -0
  13. package/dist/erc20.d.ts.map +1 -0
  14. package/dist/erc20.js +229 -0
  15. package/dist/erc20.js.map +1 -0
  16. package/dist/fixtures.d.ts +44 -2
  17. package/dist/fixtures.d.ts.map +1 -1
  18. package/dist/fixtures.js +162 -17
  19. package/dist/fixtures.js.map +1 -1
  20. package/dist/index.d.ts +17 -5
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +7 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/injected-provider.d.ts.map +1 -1
  25. package/dist/injected-provider.js +142 -79
  26. package/dist/injected-provider.js.map +1 -1
  27. package/dist/live-fixtures.d.ts +32 -3
  28. package/dist/live-fixtures.d.ts.map +1 -1
  29. package/dist/live-fixtures.js +64 -27
  30. package/dist/live-fixtures.js.map +1 -1
  31. package/dist/matchers.d.ts +90 -0
  32. package/dist/matchers.d.ts.map +1 -0
  33. package/dist/matchers.js +268 -0
  34. package/dist/matchers.js.map +1 -0
  35. package/dist/metamask-extension.d.ts +34 -0
  36. package/dist/metamask-extension.d.ts.map +1 -0
  37. package/dist/metamask-extension.js +97 -0
  38. package/dist/metamask-extension.js.map +1 -0
  39. package/dist/mock-wallet-controller.d.ts +205 -3
  40. package/dist/mock-wallet-controller.d.ts.map +1 -1
  41. package/dist/mock-wallet-controller.js +843 -46
  42. package/dist/mock-wallet-controller.js.map +1 -1
  43. package/dist/private-key-rpc-client.d.ts +1730 -0
  44. package/dist/private-key-rpc-client.d.ts.map +1 -1
  45. package/dist/private-key-rpc-client.js +105 -12
  46. package/dist/private-key-rpc-client.js.map +1 -1
  47. package/dist/real-wallet-cache.d.ts +65 -0
  48. package/dist/real-wallet-cache.d.ts.map +1 -0
  49. package/dist/real-wallet-cache.js +245 -0
  50. package/dist/real-wallet-cache.js.map +1 -0
  51. package/dist/real-wallet-fixtures.d.ts +52 -0
  52. package/dist/real-wallet-fixtures.d.ts.map +1 -0
  53. package/dist/real-wallet-fixtures.js +73 -0
  54. package/dist/real-wallet-fixtures.js.map +1 -0
  55. package/dist/real-wallet.d.ts +123 -14
  56. package/dist/real-wallet.d.ts.map +1 -1
  57. package/dist/real-wallet.js +1336 -57
  58. package/dist/real-wallet.js.map +1 -1
  59. package/dist/transactions.d.ts +118 -0
  60. package/dist/transactions.d.ts.map +1 -0
  61. package/dist/transactions.js +207 -0
  62. package/dist/transactions.js.map +1 -0
  63. package/dist/types.d.ts +1 -0
  64. package/dist/types.d.ts.map +1 -1
  65. package/dist/walletconnect.d.ts +206 -0
  66. package/dist/walletconnect.d.ts.map +1 -0
  67. package/dist/walletconnect.js +359 -0
  68. package/dist/walletconnect.js.map +1 -0
  69. package/examples/live-sepolia.spec.ts +20 -1
  70. package/package.json +62 -6
  71. package/docs/API.md +0 -223
  72. package/docs/ARCHITECTURE.md +0 -81
  73. package/docs/CONSUMING_FROM_FJORD.md +0 -123
  74. package/docs/FJORD_LIVE_QA.md +0 -87
  75. package/docs/RELEASE_CHECKLIST.md +0 -55
@@ -1,6 +1,23 @@
1
- import { toHex } from 'viem';
1
+ import { randomBytes } from 'node:crypto';
2
+ import { http, toHex } from 'viem';
2
3
  import { providerError, serializeRpcError } from './errors.js';
3
4
  import { buildInjectedProviderScript, emitterName, rpcBridgeName, } from './injected-provider.js';
5
+ /**
6
+ * Adapter: EIP-1193 RpcClient over a plain JSON-RPC URL (viem http
7
+ * transport). URL-backed chains serve reads, eth_sendRawTransaction, and
8
+ * dapp-side flows; node-side signing (personal_sign, eth_sendTransaction)
9
+ * needs a node that signs — back those chains with Anvil or a
10
+ * PrivateKeyRpcClient instead.
11
+ */
12
+ export function httpRpcClient(url, options = {}) {
13
+ const transport = http(url, {
14
+ retryCount: options.retryCount ?? 0,
15
+ timeout: options.timeout,
16
+ })({});
17
+ return {
18
+ request: (request) => transport.request(request),
19
+ };
20
+ }
4
21
  const DEFAULT_PROVIDER_INFO = {
5
22
  uuid: '00000000-0000-4000-8000-000000000001',
6
23
  name: 'Mock Wallet',
@@ -20,13 +37,25 @@ const SIGNING_METHODS = new Set([
20
37
  'eth_signTypedData_v3',
21
38
  'eth_signTypedData_v4',
22
39
  'personal_sign',
40
+ // A batch is a spend: 4100 while disconnected, approval-gated, covered by
41
+ // the default simulateRejection() set.
42
+ 'wallet_sendCalls',
43
+ ]);
44
+ // Methods that open a wallet prompt in a real wallet but do not sign.
45
+ const PROMPT_METHODS = new Set([
46
+ 'wallet_addEthereumChain',
47
+ 'wallet_requestPermissions',
48
+ 'wallet_switchEthereumChain',
49
+ 'wallet_watchAsset',
50
+ ]);
51
+ const APPROVAL_GATED_METHODS = new Set([
52
+ ...SIGNING_METHODS,
53
+ ...PROMPT_METHODS,
54
+ 'eth_requestAccounts',
55
+ // Broadcasts someone else's signed bytes — still a spend the user must
56
+ // approve, and it must not slip through the unguarded default forward.
57
+ 'eth_sendRawTransaction',
23
58
  ]);
24
- const permissionResponse = [
25
- {
26
- parentCapability: 'eth_accounts',
27
- caveats: [],
28
- },
29
- ];
30
59
  const normalizeParams = (params) => {
31
60
  if (params === undefined) {
32
61
  return [];
@@ -36,7 +65,67 @@ const normalizeParams = (params) => {
36
65
  }
37
66
  return [params];
38
67
  };
39
- const normalizeChainId = (chainId) => typeof chainId === 'number' ? toHex(chainId) : chainId;
68
+ // Canonical (lowercase, minimal) hex so '0xAA36A7', '0x0aa36a7', and
69
+ // 11155111 all map to one chain-registry key. Config/test-side input throws
70
+ // a plain Error on garbage; dapp params go through parseDappChainId instead.
71
+ const normalizeChainId = (chainId) => {
72
+ try {
73
+ return toHex(typeof chainId === 'number' ? chainId : BigInt(chainId));
74
+ }
75
+ catch {
76
+ throw new Error(`Invalid chain id "${String(chainId)}".`);
77
+ }
78
+ };
79
+ const parseDappChainId = (chainId) => {
80
+ if (typeof chainId !== 'string' || !chainId.startsWith('0x')) {
81
+ throw providerError(-32602, 'Expected a 0x-prefixed chainId.');
82
+ }
83
+ try {
84
+ return toHex(BigInt(chainId));
85
+ }
86
+ catch {
87
+ throw providerError(-32602, `Invalid chainId "${chainId}".`);
88
+ }
89
+ };
90
+ // Bounded eth_chainId probe used when trustDappRpcUrls wires up a
91
+ // dapp-supplied RPC URL — a hung endpoint must not stall the dapp's promise.
92
+ const probeChainId = async (url, timeoutMs = 5_000) => {
93
+ const response = await fetch(url, {
94
+ method: 'POST',
95
+ headers: { 'content-type': 'application/json' },
96
+ body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'eth_chainId', params: [] }),
97
+ signal: AbortSignal.timeout(timeoutMs),
98
+ });
99
+ const body = (await response.json());
100
+ if (typeof body.result !== 'string') {
101
+ throw new Error(`No eth_chainId result from ${url}.`);
102
+ }
103
+ return toHex(BigInt(body.result));
104
+ };
105
+ const toOrigin = (url) => {
106
+ try {
107
+ return new URL(url).origin;
108
+ }
109
+ catch {
110
+ return 'null';
111
+ }
112
+ };
113
+ // Frames whose URL carries no origin of its own; in the browser they inherit
114
+ // the parent's (or opener's) origin.
115
+ const isBlankFrameUrl = (url) => url === '' || url === 'about:blank' || url === 'about:srcdoc';
116
+ // The browser-effective origin of the calling frame: blank frames inherit
117
+ // from the nearest non-blank ancestor, blank popups from their opener.
118
+ const resolveFrameOrigin = async (source) => {
119
+ let frame = source.frame;
120
+ while (frame && isBlankFrameUrl(frame.url())) {
121
+ frame = frame.parentFrame();
122
+ }
123
+ if (frame) {
124
+ return toOrigin(frame.url());
125
+ }
126
+ const opener = await source.page.opener().catch(() => null);
127
+ return opener ? toOrigin(opener.mainFrame().url()) : 'null';
128
+ };
40
129
  export class MockWalletController {
41
130
  page;
42
131
  rpcClient;
@@ -45,6 +134,28 @@ export class MockWalletController {
45
134
  connected;
46
135
  approveRequests;
47
136
  rejectionQueue = [];
137
+ holdQueue = [];
138
+ approvalQueue = [];
139
+ knownChainIds = new Set();
140
+ chainBackends = new Map();
141
+ trustDappRpcUrls;
142
+ allowedOrigins;
143
+ providerEventListeners = new Set();
144
+ // Promise-chain mutex: forwarded sends (and, later, batch execution inside
145
+ // a snapshot window) must not interleave — exposeBinding handlers run
146
+ // concurrently.
147
+ sendQueue = Promise.resolve();
148
+ nodeAccountsCache;
149
+ eip5792;
150
+ atomicStatus;
151
+ upgradeRejectionArmed = false;
152
+ callBatches = new Map();
153
+ /** Every accepted wallet_sendCalls batch, for test assertions. */
154
+ sentCallBatches = [];
155
+ /** Ids the page passed to wallet_showCallsStatus (a headless no-op). */
156
+ shownCallsStatusIds = [];
157
+ sentTransactions = [];
158
+ sentTransactionRequests = [];
48
159
  constructor(page, rpcClient, options) {
49
160
  this.page = page;
50
161
  this.rpcClient = rpcClient;
@@ -52,6 +163,42 @@ export class MockWalletController {
52
163
  this.chainId = normalizeChainId(options.chainId);
53
164
  this.connected = options.connected ?? true;
54
165
  this.approveRequests = options.autoApprove ?? true;
166
+ this.trustDappRpcUrls = options.trustDappRpcUrls ?? false;
167
+ const eip5792 = typeof options.eip5792 === 'boolean' ? { enabled: options.eip5792 } : (options.eip5792 ?? {});
168
+ this.eip5792 = {
169
+ enabled: eip5792.enabled ?? true,
170
+ maxCallsPerBatch: eip5792.maxCallsPerBatch ?? 100,
171
+ atomic: eip5792.atomic,
172
+ capabilities: eip5792.capabilities,
173
+ };
174
+ this.atomicStatus = this.eip5792.atomic ?? 'supported';
175
+ this.knownChainIds.add(this.chainId);
176
+ this.chainBackends.set(this.chainId, rpcClient);
177
+ for (const [key, backend] of Object.entries(options.chains ?? {})) {
178
+ const id = normalizeChainId(key);
179
+ if (id === this.chainId) {
180
+ throw new Error(`chains must not list the default chainId ${id} — the constructor's rpcClient is its backend.`);
181
+ }
182
+ this.chainBackends.set(id, typeof backend === 'string' ? httpRpcClient(backend) : backend);
183
+ this.knownChainIds.add(id);
184
+ }
185
+ this.allowedOrigins = options.allowedOrigins?.map((entry) => {
186
+ // Strictly http(s): a scheme-less "localhost:3000" parses as protocol
187
+ // "localhost:" with origin "null", which would silently allowlist every
188
+ // null-origin frame and block the intended host.
189
+ let origin;
190
+ try {
191
+ const url = new URL(entry);
192
+ origin = /^https?:$/.test(url.protocol) ? url.origin : undefined;
193
+ }
194
+ catch {
195
+ origin = undefined;
196
+ }
197
+ if (!origin || origin === 'null') {
198
+ throw new Error(`allowedOrigins entry "${entry}" is not an http(s) URL or origin (expected e.g. "https://app.example.com").`);
199
+ }
200
+ return origin;
201
+ });
55
202
  if (this.accounts.length === 0) {
56
203
  throw new Error('MockWalletController requires at least one account.');
57
204
  }
@@ -73,47 +220,203 @@ export class MockWalletController {
73
220
  get primaryAccount() {
74
221
  return this.accounts[0];
75
222
  }
223
+ /** Current account list; index 0 is the selected account. */
224
+ get currentAccounts() {
225
+ return [...this.accounts];
226
+ }
76
227
  get currentChainId() {
77
228
  return this.chainId;
78
229
  }
230
+ /** Chain ids that currently have an RPC backend, canonical hex. */
231
+ get backedChainIds() {
232
+ return [...this.chainBackends.keys()];
233
+ }
234
+ /**
235
+ * Test-side chain registration (Synpress addNetwork analogue): registers
236
+ * the backend and marks the chain known — no approval gate, no probe, and
237
+ * re-registration overwrites (tests may rewire).
238
+ */
239
+ addChain(chainId, backend) {
240
+ const id = normalizeChainId(chainId);
241
+ this.chainBackends.set(id, typeof backend === 'string' ? httpRpcClient(backend) : backend);
242
+ this.knownChainIds.add(id);
243
+ }
244
+ /**
245
+ * Dispatch a request arriving from a non-injected transport (e.g. a
246
+ * WalletConnect session). Approval gating applies exactly as for injected
247
+ * requests. When allowedOrigins is configured, `context.origin` is
248
+ * enforced; an absent origin counts as "null" and is refused.
249
+ * `bypassOriginCheck` is the deliberate opt-out for transports that cannot
250
+ * attest origins — approval gating still applies.
251
+ */
252
+ async handleExternalRequest(request, context = {}) {
253
+ if (this.allowedOrigins && !context.bypassOriginCheck) {
254
+ this.assertOriginAllowed(context.origin !== undefined ? toOrigin(context.origin) : 'null');
255
+ }
256
+ return this.handleRpcRequest(request);
257
+ }
258
+ /**
259
+ * Observe provider events (chainChanged, accountsChanged, connect,
260
+ * disconnect) node-side — the hook non-injected transports use to push
261
+ * session events. Dispatch is fire-and-forget; listener errors are
262
+ * swallowed. Returns an unsubscribe function.
263
+ */
264
+ onProviderEvent(listener) {
265
+ this.providerEventListeners.add(listener);
266
+ return () => {
267
+ this.providerEventListeners.delete(listener);
268
+ };
269
+ }
79
270
  async injectMockProvider() {
80
- await this.page.exposeFunction(rpcBridgeName, async (request) => {
81
- try {
82
- const result = await this.handleRpcRequest(request);
83
- return { ok: true, result };
84
- }
85
- catch (error) {
86
- return { ok: false, error: serializeRpcError(error) };
271
+ // Fail fast on misconfigured accounts before any page plumbing exists
272
+ // a bad fixture config surfaces here with a readable message instead of
273
+ // dying later inside the dapp with anvil's opaque -32602.
274
+ await this.assertAccountsKnownToNode(this.accounts, 'injectMockProvider');
275
+ // Context-level injection so pages the dapp opens itself (window.open,
276
+ // target=_blank flows) get the provider too.
277
+ const context = this.page.context();
278
+ try {
279
+ // exposeBinding rather than exposeFunction so the handler can see which
280
+ // frame is calling and enforce allowedOrigins.
281
+ await context.exposeBinding(rpcBridgeName, async (source, request) => {
282
+ try {
283
+ if (this.allowedOrigins) {
284
+ this.assertOriginAllowed(await resolveFrameOrigin(source));
285
+ }
286
+ const result = await this.handleRpcRequest(request);
287
+ return { ok: true, result };
288
+ }
289
+ catch (error) {
290
+ return { ok: false, error: serializeRpcError(error) };
291
+ }
292
+ });
293
+ }
294
+ catch (error) {
295
+ if (error instanceof Error && /already registered/i.test(error.message)) {
296
+ throw new Error('A mock wallet provider is already injected into this browser context. Create one MockWalletController per context.', { cause: error });
87
297
  }
88
- });
298
+ throw error;
299
+ }
89
300
  const config = {
90
301
  accounts: this.accounts,
91
302
  autoApprove: this.approveRequests,
92
303
  chainId: this.chainId,
93
304
  connected: this.connected,
94
305
  providers: this.providerInfos,
306
+ allowedOrigins: this.allowedOrigins,
95
307
  };
96
308
  const providerScript = buildInjectedProviderScript(config);
97
- await this.page.addInitScript(providerScript);
98
- await this.page.evaluate(providerScript);
309
+ await context.addInitScript(providerScript);
310
+ await Promise.all(context.pages().map((page) => page.evaluate(providerScript).catch(() => undefined)));
99
311
  }
100
312
  autoApprove(enabled = true) {
101
313
  this.approveRequests = enabled;
102
314
  }
103
- async simulateRejection(methods = [...SIGNING_METHODS], message = 'User rejected the request.') {
315
+ /**
316
+ * One-shot: the next atomicRequired wallet_sendCalls while the atomic
317
+ * capability is 'ready' throws 5750 (user rejected the EOA upgrade)
318
+ * instead of upgrading to 'supported'.
319
+ */
320
+ simulateAtomicUpgradeRejection() {
321
+ this.upgradeRejectionArmed = true;
322
+ }
323
+ /**
324
+ * Arms approval for the next matching request while autoApprove is off —
325
+ * the explicit per-call grant for real-key (live) wallets. Queued
326
+ * rejections and holds still take precedence.
327
+ *
328
+ * A grant without `match` approves whatever matching request arrives first
329
+ * and never expires, so any page script (including a third-party include on
330
+ * an allowed origin) can race the dapp for it. Pass `match` to bind the
331
+ * grant to the expected payload, or use holdNextRequest() to inspect the
332
+ * request before deciding.
333
+ */
334
+ approveNext(methods, match) {
335
+ this.approvalQueue.push({
336
+ methods: typeof methods === 'string' ? [methods] : methods,
337
+ match,
338
+ });
339
+ }
340
+ async simulateRejection(methods = [...APPROVAL_GATED_METHODS], message = 'User rejected the request.') {
104
341
  this.rejectionQueue.push({
105
342
  methods: typeof methods === 'string' ? [methods] : methods,
106
343
  message,
107
344
  });
108
345
  }
109
- async setAccounts(accounts) {
346
+ /**
347
+ * Intercepts the next matching request and keeps it pending until the test
348
+ * approves or rejects it — for asserting "confirm in your wallet" UI states.
349
+ * Resolves once the page actually issues the request.
350
+ */
351
+ holdNextRequest(methods) {
352
+ return new Promise((resolveHeld) => {
353
+ this.holdQueue.push({
354
+ methods: typeof methods === 'string' ? [methods] : methods,
355
+ intercept: (method, params) => new Promise((approve, rejectGate) => {
356
+ resolveHeld({
357
+ method,
358
+ params,
359
+ approve: () => approve(),
360
+ reject: (message = 'User rejected the request.') => rejectGate(providerError(4001, message)),
361
+ });
362
+ }),
363
+ });
364
+ });
365
+ }
366
+ /**
367
+ * Resolves with the hash of the next transaction the page submits after
368
+ * this call. Invoke before triggering the dapp action, then await it.
369
+ */
370
+ async waitForNextTransaction(options = {}) {
371
+ const timeoutMs = options.timeoutMs ?? 15_000;
372
+ const baseline = this.sentTransactions.length;
373
+ const deadline = Date.now() + timeoutMs;
374
+ while (Date.now() < deadline) {
375
+ if (this.sentTransactions.length > baseline) {
376
+ return this.sentTransactions[baseline];
377
+ }
378
+ await new Promise((resolve) => setTimeout(resolve, 50));
379
+ }
380
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for the page to submit a transaction.`);
381
+ }
382
+ /**
383
+ * Replaces the account set (and reconnects a disconnected wallet — unlike
384
+ * switchAccount, which only reorders). Accounts are validated against the
385
+ * backing node's eth_accounts; pass { allowUnknownAccounts: true } only
386
+ * for custom RpcClients whose account list the probe cannot see.
387
+ */
388
+ async setAccounts(accounts, options = {}) {
110
389
  if (accounts.length === 0) {
111
390
  throw new Error('setAccounts requires at least one account. Use disconnect() to expose no accounts.');
112
391
  }
392
+ if (!options.allowUnknownAccounts) {
393
+ await this.assertAccountsKnownToNode(accounts, 'setAccounts');
394
+ }
113
395
  this.accounts = [...accounts];
114
396
  this.connected = true;
115
397
  await this.emit('accountsChanged', this.accounts);
116
398
  }
399
+ /**
400
+ * Re-selects one of the wallet's existing accounts: moves it to index 0
401
+ * (MetaMask orders eth_accounts most-recently-selected first) and emits
402
+ * accountsChanged with the reordered array. No event when it is already
403
+ * selected, and — unlike setAccounts — no reconnect while disconnected:
404
+ * the reorder stays internal until the wallet reconnects.
405
+ */
406
+ async switchAccount(address) {
407
+ const index = this.accounts.findIndex((account) => account.toLowerCase() === address.toLowerCase());
408
+ if (index === -1) {
409
+ throw new Error(`switchAccount: "${address}" is not one of the wallet's accounts. Use setAccounts() to change the set.`);
410
+ }
411
+ if (index === 0) {
412
+ return;
413
+ }
414
+ const [selected] = this.accounts.splice(index, 1);
415
+ this.accounts.unshift(selected);
416
+ if (this.connected) {
417
+ await this.emit('accountsChanged', this.accounts);
418
+ }
419
+ }
117
420
  async disconnect() {
118
421
  this.connected = false;
119
422
  await this.emit('accountsChanged', []);
@@ -125,42 +428,393 @@ export class MockWalletController {
125
428
  await this.emit('accountsChanged', this.accounts);
126
429
  }
127
430
  async switchNetwork(chainId) {
128
- this.chainId = normalizeChainId(chainId);
431
+ const normalized = normalizeChainId(chainId);
432
+ // A test-driven switch counts as the user adding/approving the chain.
433
+ this.knownChainIds.add(normalized);
434
+ if (normalized === this.chainId) {
435
+ // MetaMask emits no chainChanged for a same-chain switch.
436
+ return;
437
+ }
438
+ this.chainId = normalized;
129
439
  await this.emit('chainChanged', this.chainId);
130
440
  }
131
441
  async emit(event, payload) {
132
- await this.page.evaluate(({ emitter, eventName, eventPayload }) => {
442
+ for (const listener of this.providerEventListeners) {
443
+ queueMicrotask(() => {
444
+ try {
445
+ listener(event, payload);
446
+ }
447
+ catch {
448
+ // Listener errors must never break wallet state transitions.
449
+ }
450
+ });
451
+ }
452
+ await Promise.all(this.page.context().pages().map((page) => page
453
+ .evaluate(({ emitter, eventName, eventPayload }) => {
133
454
  const maybeEmitter = window[emitter];
134
455
  if (typeof maybeEmitter === 'function') {
135
456
  maybeEmitter(eventName, eventPayload);
136
457
  }
137
- }, { emitter: emitterName, eventName: event, eventPayload: payload });
458
+ }, { emitter: emitterName, eventName: event, eventPayload: payload })
459
+ .catch(() => undefined)));
460
+ }
461
+ // The single seam every forwarded request routes through: a known-but-
462
+ // unbacked chain fails loudly here (EIP-1193 4901 "Chain Disconnected")
463
+ // instead of silently hitting the wrong node.
464
+ clientForChain(chainId) {
465
+ const client = this.chainBackends.get(chainId);
466
+ if (!client) {
467
+ throw providerError(4901, `The wallet is not connected to chain "${chainId}". It was added without an RPC backend — ` +
468
+ 'pass it in MockWalletControllerOptions.chains, call wallet.addChain(chainId, clientOrUrl), ' +
469
+ 'or enable trustDappRpcUrls.');
470
+ }
471
+ return client;
472
+ }
473
+ get activeRpcClient() {
474
+ return this.clientForChain(this.chainId);
475
+ }
476
+ enqueueSend(task) {
477
+ const run = this.sendQueue.then(task, task);
478
+ this.sendQueue = run.then(() => undefined, () => undefined);
479
+ return run;
480
+ }
481
+ assertEip5792Enabled(method) {
482
+ if (!this.eip5792.enabled) {
483
+ // Legacy-wallet posture: identical to the unknown wallet_* default.
484
+ throw providerError(4200, `The mock wallet does not support the method "${method}".`);
485
+ }
486
+ }
487
+ batchForId(id) {
488
+ const key = typeof id === 'string' ? id : undefined;
489
+ const record = key ? this.callBatches.get(key) : undefined;
490
+ if (!record) {
491
+ throw providerError(5730, `Unknown bundle id "${String(id)}".`);
492
+ }
493
+ return record;
138
494
  }
139
- consumeRejection(method) {
140
- const index = this.rejectionQueue.findIndex((rule) => !rule.methods || rule.methods.includes(method));
495
+ async handleSendCalls(params) {
496
+ const request = params[0];
497
+ // Validation order per spec + MetaMask: params (-32602) -> account
498
+ // (4100) -> chain (5710) -> size (5740) -> id (5720) -> capabilities
499
+ // (5700) -> atomicity (5760/5750) -> approval (4001) -> execution.
500
+ if (!request || typeof request !== 'object') {
501
+ throw providerError(-32602, 'wallet_sendCalls requires a request object.');
502
+ }
503
+ if (request.version !== '2.0.0') {
504
+ // The spec assigns no code; MetaMask requires 2.0.0.
505
+ throw providerError(-32602, 'wallet_sendCalls requires version "2.0.0".');
506
+ }
507
+ if (typeof request.atomicRequired !== 'boolean') {
508
+ throw providerError(-32602, 'wallet_sendCalls requires a boolean atomicRequired.');
509
+ }
510
+ const calls = request.calls;
511
+ if (!Array.isArray(calls) || calls.length === 0 || calls.some((call) => !call || typeof call !== 'object')) {
512
+ throw providerError(-32602, 'wallet_sendCalls requires a non-empty calls array.');
513
+ }
514
+ const chainId = parseDappChainId(request.chainId);
515
+ const from = request.from ?? this.primaryAccount;
516
+ if (!this.accounts.some((account) => account.toLowerCase() === String(from).toLowerCase())) {
517
+ throw providerError(4100, `The requested account ${String(from)} has not been authorized by the user.`);
518
+ }
519
+ // MetaMask-faithful: the batch must target the active, backed network.
520
+ if (chainId !== this.chainId || !this.chainBackends.has(chainId)) {
521
+ throw providerError(5710, `Chain ${chainId} is not the wallet's active chain (${this.chainId}).`);
522
+ }
523
+ if (calls.length > this.eip5792.maxCallsPerBatch) {
524
+ throw providerError(5740, `Batch of ${calls.length} calls exceeds the limit of ${this.eip5792.maxCallsPerBatch}.`);
525
+ }
526
+ let id;
527
+ if (request.id !== undefined) {
528
+ if (typeof request.id !== 'string' ||
529
+ !/^0x[0-9a-fA-F]*$/.test(request.id) ||
530
+ request.id.length > 8194) {
531
+ throw providerError(-32602, 'wallet_sendCalls id must be a 0x-hex string of at most 4096 bytes.');
532
+ }
533
+ id = request.id;
534
+ if (this.callBatches.has(id)) {
535
+ throw providerError(5720, `Duplicate bundle id "${id}".`);
536
+ }
537
+ }
538
+ else {
539
+ id = `0x${randomBytes(32).toString('hex')}`;
540
+ }
541
+ const advertised = new Set([
542
+ 'atomic',
543
+ ...Object.keys(this.eip5792.capabilities?.[chainId] ?? {}),
544
+ ...Object.keys(this.eip5792.capabilities?.['0x0'] ?? {}),
545
+ ]);
546
+ const capabilityEntries = [
547
+ ...Object.entries(request.capabilities ?? {}),
548
+ ...calls.flatMap((call) => Object.entries(call.capabilities ?? {})),
549
+ ];
550
+ for (const [name, value] of capabilityEntries) {
551
+ const optional = value?.optional === true;
552
+ if (!advertised.has(name) && !optional) {
553
+ throw providerError(5700, `Capability "${name}" is not supported on chain ${chainId}.`);
554
+ }
555
+ }
556
+ if (request.atomicRequired && this.atomicStatus === 'unsupported') {
557
+ throw providerError(5760, 'This wallet cannot execute the batch atomically.');
558
+ }
559
+ if (request.atomicRequired && this.atomicStatus === 'ready' && this.upgradeRejectionArmed) {
560
+ this.upgradeRejectionArmed = false;
561
+ throw providerError(5750, 'The user rejected the account upgrade required for atomic execution.');
562
+ }
563
+ // ONE approval gates the whole batch — a single approveNext arms all N
564
+ // calls (in live mode that is N real transactions; see docs).
565
+ await this.assertUserApproved('wallet_sendCalls', params);
566
+ const atomic = request.atomicRequired || this.atomicStatus !== 'unsupported';
567
+ const client = this.clientForChain(chainId);
568
+ const batchCalls = calls.map((call) => ({
569
+ to: call.to,
570
+ data: call.data,
571
+ value: call.value,
572
+ }));
573
+ const record = {
574
+ id,
575
+ chainId,
576
+ from: from,
577
+ version: '2.0.0',
578
+ atomic,
579
+ atomicRequired: request.atomicRequired,
580
+ capabilities: request.capabilities,
581
+ calls: batchCalls,
582
+ txHashes: [],
583
+ failure: undefined,
584
+ };
585
+ // The whole batch executes inside the send mutex so a concurrent
586
+ // page-initiated transaction can never be swallowed by the batch's
587
+ // snapshot/revert window.
588
+ await this.enqueueSend(() => this.executeBatch(record, client));
589
+ this.callBatches.set(id, record);
590
+ this.sentCallBatches.push(record);
591
+ if (atomic &&
592
+ record.failure === undefined &&
593
+ request.atomicRequired &&
594
+ this.atomicStatus === 'ready') {
595
+ // Emulates MetaMask's EOA -> smart-account upgrade on first use.
596
+ this.atomicStatus = 'supported';
597
+ }
598
+ return { id };
599
+ }
600
+ // Receipt-status-checked execution: anvil MINES reverting transactions
601
+ // with status 0x0 instead of erroring (no submission failure to catch), so
602
+ // each call's receipt is fetched synchronously under automine and a 0x0
603
+ // status triggers the rollback. With blockTime > 0 receipts are not
604
+ // synchronously available and atomic mode only rolls back submission-time
605
+ // failures — documented limitation.
606
+ async executeBatch(record, client) {
607
+ const txHashes = [];
608
+ let landed = 0;
609
+ let failed = false;
610
+ let snapshotId;
611
+ if (record.atomic) {
612
+ try {
613
+ snapshotId = await client.request({ method: 'evm_snapshot', params: [] });
614
+ }
615
+ catch {
616
+ throw providerError(-32603, 'Atomic execution needs an anvil-backed chain (evm_snapshot failed). ' +
617
+ "Configure eip5792: { atomic: 'unsupported' } for live chains.");
618
+ }
619
+ }
620
+ for (const call of record.calls) {
621
+ const transaction = { from: record.from };
622
+ if (call.to !== undefined)
623
+ transaction.to = call.to;
624
+ if (call.data !== undefined)
625
+ transaction.data = call.data;
626
+ if (call.value !== undefined)
627
+ transaction.value = call.value;
628
+ let hash;
629
+ try {
630
+ hash = (await client.request({
631
+ method: 'eth_sendTransaction',
632
+ params: [transaction],
633
+ }));
634
+ }
635
+ catch {
636
+ failed = true;
637
+ break;
638
+ }
639
+ txHashes.push(hash);
640
+ this.sentTransactions.push(hash);
641
+ this.sentTransactionRequests.push({
642
+ hash,
643
+ chainId: record.chainId,
644
+ from: record.from,
645
+ to: call.to,
646
+ data: call.data,
647
+ value: call.value !== undefined ? String(call.value) : undefined,
648
+ });
649
+ const receipt = (await client
650
+ .request({ method: 'eth_getTransactionReceipt', params: [hash] })
651
+ .catch(() => null));
652
+ if (receipt) {
653
+ landed += 1;
654
+ if (receipt.status === '0x0') {
655
+ failed = true;
656
+ break;
657
+ }
658
+ }
659
+ }
660
+ record.txHashes = txHashes;
661
+ if (failed && record.atomic) {
662
+ await client
663
+ .request({ method: 'evm_revert', params: [snapshotId] })
664
+ .catch(() => undefined);
665
+ record.failure = landed > 0 ? 'atomic-rollback' : 'nothing-landed';
666
+ }
667
+ else if (failed && landed === 0) {
668
+ record.failure = 'nothing-landed';
669
+ }
670
+ // Non-atomic with something landed: the status is computed from receipts
671
+ // (600 mixed / 500 all-reverted) in wallet_getCallsStatus.
672
+ }
673
+ async buildCallsStatus(record) {
674
+ const base = {
675
+ version: '2.0.0',
676
+ id: record.id,
677
+ chainId: record.chainId,
678
+ atomic: record.atomic,
679
+ };
680
+ if (record.failure === 'atomic-rollback') {
681
+ // The rolled-back transactions no longer exist on-chain; receipts are
682
+ // deliberately omitted (divergence from real MetaMask, which would
683
+ // return one reverted 7702 receipt — documented).
684
+ return { ...base, status: 500 };
685
+ }
686
+ if (record.failure === 'nothing-landed') {
687
+ return { ...base, status: 400, receipts: [] };
688
+ }
689
+ const client = this.clientForChain(record.chainId);
690
+ const receipts = await Promise.all(record.txHashes.map((hash) => client
691
+ .request({ method: 'eth_getTransactionReceipt', params: [hash] })
692
+ .catch(() => null)));
693
+ if (receipts.some((receipt) => receipt === null)) {
694
+ return { ...base, status: 100 };
695
+ }
696
+ const projected = receipts.map((receipt) => ({
697
+ logs: (receipt.logs ?? []).map((log) => ({
698
+ address: log.address,
699
+ data: log.data,
700
+ topics: log.topics,
701
+ })),
702
+ status: receipt.status,
703
+ blockHash: receipt.blockHash,
704
+ blockNumber: receipt.blockNumber,
705
+ gasUsed: receipt.gasUsed,
706
+ transactionHash: receipt.transactionHash,
707
+ }));
708
+ const reverted = projected.filter((receipt) => receipt.status === '0x0').length;
709
+ const status = reverted === 0 ? 200 : reverted === projected.length ? 500 : 600;
710
+ return { ...base, status, receipts: projected };
711
+ }
712
+ consumeRule(queue, method) {
713
+ const index = queue.findIndex((rule) => !rule.methods || rule.methods.includes(method));
141
714
  if (index === -1) {
142
715
  return undefined;
143
716
  }
144
- const [rule] = this.rejectionQueue.splice(index, 1);
717
+ const [rule] = queue.splice(index, 1);
145
718
  return rule;
146
719
  }
147
- assertUserApproved(method) {
148
- const forcedRejection = this.consumeRejection(method);
720
+ // Bounded probe of the backing node's account list. Returns undefined when
721
+ // the node cannot answer (throw, timeout, non-array, empty) — validation
722
+ // then fails open, which is what keeps live RPC endpoints and custom
723
+ // RpcClients usable.
724
+ async fetchNodeAccounts() {
725
+ const TIMED_OUT = Symbol('probe-timeout');
726
+ try {
727
+ const result = await Promise.race([
728
+ this.rpcClient.request({ method: 'eth_accounts', params: [] }),
729
+ new Promise((resolve) => setTimeout(() => resolve(TIMED_OUT), 2_500)),
730
+ ]);
731
+ if (result === TIMED_OUT || !Array.isArray(result) || result.length === 0) {
732
+ return undefined;
733
+ }
734
+ return new Set(result.map((account) => String(account).toLowerCase()));
735
+ }
736
+ catch {
737
+ return undefined;
738
+ }
739
+ }
740
+ // Membership in the node's eth_accounts means the node will ACCEPT sends
741
+ // from the account — not that it can sign messages for it: anvil lists
742
+ // impersonated accounts too (their personal_sign still fails node-side
743
+ // with -32602). Re-probes on a miss so accounts impersonated after the
744
+ // first probe validate without any escape hatch. Best-effort under
745
+ // anvil --auto-impersonate, where every address is accepted.
746
+ async assertAccountsKnownToNode(accounts, operation) {
747
+ let known = this.nodeAccountsCache ?? (await this.fetchNodeAccounts());
748
+ if (!known) {
749
+ return;
750
+ }
751
+ this.nodeAccountsCache = known;
752
+ const unknownIn = (set) => accounts.filter((account) => !set.has(account.toLowerCase()));
753
+ if (unknownIn(known).length > 0) {
754
+ known = await this.fetchNodeAccounts();
755
+ if (!known) {
756
+ return;
757
+ }
758
+ this.nodeAccountsCache = known;
759
+ }
760
+ const unknown = unknownIn(known);
761
+ if (unknown.length > 0) {
762
+ throw new Error(`${operation}: account(s) ${unknown.join(', ')} are not known to the backing node ` +
763
+ `(${known.size} node accounts). Use addresses from chain.accounts(), or ` +
764
+ 'chain.impersonateAccount(address) for send-only flows (impersonated accounts can send ' +
765
+ 'transactions, but personal_sign/typed-data still fail node-side with -32602). For custom ' +
766
+ 'RpcClients that cannot answer eth_accounts, pass { allowUnknownAccounts: true } to setAccounts.');
767
+ }
768
+ }
769
+ assertOriginAllowed(origin) {
770
+ if (!this.allowedOrigins) {
771
+ return;
772
+ }
773
+ if (!this.allowedOrigins.includes(origin)) {
774
+ throw providerError(4100, `The wallet is not available to origin "${origin}" (allowedOrigins: ${this.allowedOrigins.join(', ')}).`);
775
+ }
776
+ }
777
+ async assertUserApproved(method, params) {
778
+ const hold = this.consumeRule(this.holdQueue, method);
779
+ if (hold) {
780
+ // The test's explicit approve()/reject() decision is authoritative;
781
+ // it bypasses autoApprove and queued rejection rules.
782
+ await hold.intercept(method, params);
783
+ return;
784
+ }
785
+ const forcedRejection = this.consumeRule(this.rejectionQueue, method);
149
786
  if (forcedRejection) {
150
787
  throw providerError(4001, forcedRejection.message ?? 'User rejected the request.');
151
788
  }
152
- if (!this.approveRequests && (SIGNING_METHODS.has(method) || method === 'eth_requestAccounts')) {
789
+ const armedIndex = this.approvalQueue.findIndex((rule) => (!rule.methods || rule.methods.includes(method)) &&
790
+ (!rule.match || rule.match(method, params)));
791
+ if (armedIndex !== -1) {
792
+ this.approvalQueue.splice(armedIndex, 1);
793
+ return;
794
+ }
795
+ if (!this.approveRequests && APPROVAL_GATED_METHODS.has(method)) {
153
796
  throw providerError(4001, 'User rejected the request.');
154
797
  }
155
798
  }
799
+ permissionResponse() {
800
+ return [
801
+ {
802
+ parentCapability: 'eth_accounts',
803
+ caveats: [{ type: 'restrictReturnedAccounts', value: [...this.accounts] }],
804
+ },
805
+ ];
806
+ }
156
807
  async handleRpcRequest(request) {
157
808
  const { method } = request;
158
809
  const params = normalizeParams(request.params);
810
+ if (!this.connected && SIGNING_METHODS.has(method)) {
811
+ throw providerError(4100, 'The requested account and/or method has not been authorized by the user.');
812
+ }
159
813
  switch (method) {
160
814
  case 'eth_accounts':
161
815
  return this.connected ? this.accounts : [];
162
816
  case 'eth_requestAccounts':
163
- this.assertUserApproved(method);
817
+ await this.assertUserApproved(method, params);
164
818
  if (!this.connected) {
165
819
  this.connected = true;
166
820
  await this.emit('connect', { chainId: this.chainId });
@@ -172,27 +826,134 @@ export class MockWalletController {
172
826
  case 'net_version':
173
827
  return String(Number(BigInt(this.chainId)));
174
828
  case 'wallet_getPermissions':
175
- return this.connected ? permissionResponse : [];
829
+ return this.connected ? this.permissionResponse() : [];
176
830
  case 'wallet_requestPermissions':
177
- this.assertUserApproved(method);
831
+ await this.assertUserApproved(method, params);
178
832
  this.connected = true;
179
833
  await this.emit('accountsChanged', this.accounts);
180
- return permissionResponse;
834
+ return this.permissionResponse();
835
+ case 'wallet_revokePermissions':
836
+ this.connected = false;
837
+ await this.emit('accountsChanged', []);
838
+ return null;
839
+ // Per-handler order, library-wide: param validation (-32602) -> state
840
+ // checks (4902) -> approval -> execution. Real MetaMask returns 4902
841
+ // for an unknown chain without ever showing a prompt.
181
842
  case 'wallet_switchEthereumChain': {
182
- this.assertUserApproved(method);
183
843
  const requestedChain = params[0];
184
844
  if (!requestedChain?.chainId) {
185
845
  throw providerError(-32602, 'wallet_switchEthereumChain requires a chainId.');
186
846
  }
187
- await this.switchNetwork(requestedChain.chainId);
847
+ const normalized = parseDappChainId(requestedChain.chainId);
848
+ if (!this.knownChainIds.has(normalized)) {
849
+ throw providerError(4902, `Unrecognized chain ID "${normalized}". Try adding the chain using wallet_addEthereumChain first.`);
850
+ }
851
+ await this.assertUserApproved(method, params);
852
+ await this.switchNetwork(normalized);
188
853
  return null;
189
854
  }
190
- case 'wallet_addEthereumChain':
191
- this.assertUserApproved(method);
855
+ case 'wallet_addEthereumChain': {
856
+ const definition = params[0];
857
+ if (typeof definition?.chainId !== 'string' || !definition.chainId.startsWith('0x')) {
858
+ throw providerError(-32602, 'wallet_addEthereumChain requires a 0x-prefixed chainId.');
859
+ }
860
+ const normalized = parseDappChainId(definition.chainId);
861
+ // EIP-3085: the wallet MUST reject when rpcUrls is missing, empty, or
862
+ // contains invalid URLs (MetaMask does too; wagmi always sends them).
863
+ const rpcUrls = definition.rpcUrls;
864
+ const urlsValid = Array.isArray(rpcUrls) &&
865
+ rpcUrls.length > 0 &&
866
+ rpcUrls.every((url) => {
867
+ if (typeof url !== 'string') {
868
+ return false;
869
+ }
870
+ try {
871
+ new URL(url);
872
+ return true;
873
+ }
874
+ catch {
875
+ return false;
876
+ }
877
+ });
878
+ if (!urlsValid) {
879
+ throw providerError(-32602, 'wallet_addEthereumChain requires rpcUrls: a non-empty array of valid URLs.');
880
+ }
881
+ await this.assertUserApproved(method, params);
882
+ // First registration wins for dapp adds: re-adding a backed chain is
883
+ // a no-op switch, like MetaMask.
884
+ if (this.trustDappRpcUrls && !this.chainBackends.has(normalized)) {
885
+ const [url] = rpcUrls;
886
+ // http(s) only; we deliberately allow http: (localhost Anvil is the
887
+ // dominant test case), deviating from EIP-3085's https-only rule.
888
+ if (!/^https?:$/.test(new URL(url).protocol)) {
889
+ throw providerError(-32602, `rpcUrls[0] "${url}" is not an http(s) URL.`);
890
+ }
891
+ let reported;
892
+ try {
893
+ reported = await probeChainId(url);
894
+ }
895
+ catch {
896
+ throw providerError(-32602, `rpcUrls[0] "${url}" is unreachable or did not answer eth_chainId.`);
897
+ }
898
+ if (reported !== normalized) {
899
+ // EIP-3085: reject when the URL's eth_chainId does not match.
900
+ throw providerError(-32602, `rpcUrls[0] reports chain id ${reported} but ${normalized} was requested.`);
901
+ }
902
+ this.chainBackends.set(normalized, httpRpcClient(url));
903
+ }
904
+ this.knownChainIds.add(normalized);
905
+ // MetaMask offers to switch after adding; the mock approves that too
906
+ // (wagmi verifies eth_chainId === target after an add and hard-fails
907
+ // if the wallet did not switch). switchNetwork skips the chainChanged
908
+ // emit when the chain is already active.
909
+ await this.switchNetwork(normalized);
192
910
  return null;
911
+ }
193
912
  case 'wallet_watchAsset':
194
- this.assertUserApproved(method);
913
+ await this.assertUserApproved(method, params);
195
914
  return true;
915
+ case 'wallet_getCapabilities': {
916
+ this.assertEip5792Enabled(method);
917
+ // Spec privacy rule: only answer for the connected wallet's accounts.
918
+ const [address, chainIdFilter] = params;
919
+ const requested = typeof address === 'string' ? address.toLowerCase() : '';
920
+ if (!this.connected ||
921
+ !this.accounts.some((account) => account.toLowerCase() === requested)) {
922
+ throw providerError(4100, 'The requested account and/or method has not been authorized by the user.');
923
+ }
924
+ const response = {};
925
+ for (const chainId of this.chainBackends.keys()) {
926
+ response[chainId] = { atomic: { status: this.atomicStatus } };
927
+ }
928
+ for (const [key, value] of Object.entries(this.eip5792.capabilities ?? {})) {
929
+ const chainId = key === '0x0' ? '0x0' : normalizeChainId(key);
930
+ response[chainId] = { ...response[chainId], ...value };
931
+ }
932
+ if (Array.isArray(chainIdFilter)) {
933
+ const wanted = new Set(chainIdFilter.map((id) => parseDappChainId(id)));
934
+ for (const key of Object.keys(response)) {
935
+ if (key !== '0x0' && !wanted.has(key)) {
936
+ delete response[key];
937
+ }
938
+ }
939
+ }
940
+ return response;
941
+ }
942
+ case 'wallet_sendCalls': {
943
+ this.assertEip5792Enabled(method);
944
+ return this.handleSendCalls(params);
945
+ }
946
+ case 'wallet_getCallsStatus': {
947
+ this.assertEip5792Enabled(method);
948
+ const record = this.batchForId(params[0]);
949
+ return this.buildCallsStatus(record);
950
+ }
951
+ case 'wallet_showCallsStatus': {
952
+ this.assertEip5792Enabled(method);
953
+ const record = this.batchForId(params[0]);
954
+ this.shownCallsStatusIds.push(record.id);
955
+ return null;
956
+ }
196
957
  case 'metamask_getProviderState':
197
958
  return {
198
959
  accounts: this.connected ? this.accounts : [],
@@ -201,23 +962,59 @@ export class MockWalletController {
201
962
  networkVersion: String(Number(BigInt(this.chainId))),
202
963
  };
203
964
  case 'eth_sendTransaction': {
204
- this.assertUserApproved(method);
205
965
  const transaction = { ...params[0] };
206
966
  transaction.from ??= this.primaryAccount;
207
- return this.rpcClient.request({
208
- method,
209
- params: [transaction],
967
+ // MetaMask rejects sends from accounts the dapp is not authorized
968
+ // for; anvil would happily sign with ANY unlocked dev account.
969
+ const from = String(transaction.from).toLowerCase();
970
+ if (!this.accounts.some((account) => account.toLowerCase() === from)) {
971
+ throw providerError(4100, `The requested account ${String(transaction.from)} has not been authorized by the user.`);
972
+ }
973
+ await this.assertUserApproved(method, params);
974
+ // Capture the routed chain at approval time so a concurrent switch
975
+ // cannot redirect a queued send.
976
+ const chainId = this.chainId;
977
+ const client = this.activeRpcClient;
978
+ const hash = (await this.enqueueSend(() => client.request({ method, params: [transaction] })));
979
+ this.sentTransactions.push(hash);
980
+ this.sentTransactionRequests.push({
981
+ hash,
982
+ chainId,
983
+ from: transaction.from,
984
+ to: transaction.to,
985
+ data: transaction.data,
986
+ value: transaction.value !== undefined ? String(transaction.value) : undefined,
210
987
  });
988
+ return hash;
989
+ }
990
+ case 'eth_sendRawTransaction': {
991
+ await this.assertUserApproved(method, params);
992
+ const chainId = this.chainId;
993
+ const client = this.activeRpcClient;
994
+ const hash = (await this.enqueueSend(() => client.request({ method, params })));
995
+ this.sentTransactions.push(hash);
996
+ this.sentTransactionRequests.push({ hash, chainId });
997
+ return hash;
211
998
  }
212
- case 'eth_sign':
213
999
  case 'eth_signTypedData':
1000
+ throw providerError(4200, 'eth_signTypedData (legacy v1) is not supported by the mock wallet. Use eth_signTypedData_v4.');
214
1001
  case 'eth_signTypedData_v3':
1002
+ // Anvil only implements v4; v3 payloads (no arrays or recursive
1003
+ // structs) hash identically under v4 rules.
1004
+ await this.assertUserApproved(method, params);
1005
+ return this.activeRpcClient.request({ method: 'eth_signTypedData_v4', params });
1006
+ case 'eth_sign':
215
1007
  case 'eth_signTypedData_v4':
216
1008
  case 'personal_sign':
217
- this.assertUserApproved(method);
218
- return this.rpcClient.request({ method, params });
1009
+ await this.assertUserApproved(method, params);
1010
+ return this.activeRpcClient.request({ method, params });
219
1011
  default:
220
- return this.rpcClient.request({ method, params });
1012
+ // Unknown wallet-namespace methods are the wallet's responsibility;
1013
+ // forwarding them to the node would leak a confusing -32601.
1014
+ if (method.startsWith('wallet_')) {
1015
+ throw providerError(4200, `The mock wallet does not support the method "${method}".`);
1016
+ }
1017
+ return this.activeRpcClient.request({ method, params });
221
1018
  }
222
1019
  }
223
1020
  }