@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.
- package/.env.example +26 -17
- package/LICENSE +21 -0
- package/README.md +167 -41
- package/dist/anvil.d.ts +90 -2
- package/dist/anvil.d.ts.map +1 -1
- package/dist/anvil.js +215 -13
- package/dist/anvil.js.map +1 -1
- package/dist/contracts/test-erc20.d.ts +227 -0
- package/dist/contracts/test-erc20.d.ts.map +1 -0
- package/dist/contracts/test-erc20.js +8 -0
- package/dist/contracts/test-erc20.js.map +1 -0
- package/dist/erc20.d.ts +38 -0
- package/dist/erc20.d.ts.map +1 -0
- package/dist/erc20.js +229 -0
- package/dist/erc20.js.map +1 -0
- package/dist/fixtures.d.ts +44 -2
- package/dist/fixtures.d.ts.map +1 -1
- package/dist/fixtures.js +162 -17
- package/dist/fixtures.js.map +1 -1
- package/dist/index.d.ts +17 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/injected-provider.d.ts.map +1 -1
- package/dist/injected-provider.js +142 -79
- package/dist/injected-provider.js.map +1 -1
- package/dist/live-fixtures.d.ts +32 -3
- package/dist/live-fixtures.d.ts.map +1 -1
- package/dist/live-fixtures.js +64 -27
- package/dist/live-fixtures.js.map +1 -1
- package/dist/matchers.d.ts +90 -0
- package/dist/matchers.d.ts.map +1 -0
- package/dist/matchers.js +268 -0
- package/dist/matchers.js.map +1 -0
- package/dist/metamask-extension.d.ts +34 -0
- package/dist/metamask-extension.d.ts.map +1 -0
- package/dist/metamask-extension.js +97 -0
- package/dist/metamask-extension.js.map +1 -0
- package/dist/mock-wallet-controller.d.ts +205 -3
- package/dist/mock-wallet-controller.d.ts.map +1 -1
- package/dist/mock-wallet-controller.js +843 -46
- package/dist/mock-wallet-controller.js.map +1 -1
- package/dist/private-key-rpc-client.d.ts +1730 -0
- package/dist/private-key-rpc-client.d.ts.map +1 -1
- package/dist/private-key-rpc-client.js +105 -12
- package/dist/private-key-rpc-client.js.map +1 -1
- package/dist/real-wallet-cache.d.ts +65 -0
- package/dist/real-wallet-cache.d.ts.map +1 -0
- package/dist/real-wallet-cache.js +245 -0
- package/dist/real-wallet-cache.js.map +1 -0
- package/dist/real-wallet-fixtures.d.ts +52 -0
- package/dist/real-wallet-fixtures.d.ts.map +1 -0
- package/dist/real-wallet-fixtures.js +73 -0
- package/dist/real-wallet-fixtures.js.map +1 -0
- package/dist/real-wallet.d.ts +123 -14
- package/dist/real-wallet.d.ts.map +1 -1
- package/dist/real-wallet.js +1336 -57
- package/dist/real-wallet.js.map +1 -1
- package/dist/transactions.d.ts +118 -0
- package/dist/transactions.d.ts.map +1 -0
- package/dist/transactions.js +207 -0
- package/dist/transactions.js.map +1 -0
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/walletconnect.d.ts +206 -0
- package/dist/walletconnect.d.ts.map +1 -0
- package/dist/walletconnect.js +359 -0
- package/dist/walletconnect.js.map +1 -0
- package/examples/live-sepolia.spec.ts +20 -1
- package/package.json +62 -6
- package/docs/API.md +0 -223
- package/docs/ARCHITECTURE.md +0 -81
- package/docs/CONSUMING_FROM_FJORD.md +0 -123
- package/docs/FJORD_LIVE_QA.md +0 -87
- package/docs/RELEASE_CHECKLIST.md +0 -55
|
@@ -1,6 +1,23 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
98
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
const
|
|
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] =
|
|
717
|
+
const [rule] = queue.splice(index, 1);
|
|
145
718
|
return rule;
|
|
146
719
|
}
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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.
|
|
1009
|
+
await this.assertUserApproved(method, params);
|
|
1010
|
+
return this.activeRpcClient.request({ method, params });
|
|
219
1011
|
default:
|
|
220
|
-
|
|
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
|
}
|