@marigoldlabs/web3-tester 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +317 -11
  2. package/dist/anvil.d.ts.map +1 -1
  3. package/dist/anvil.js.map +1 -1
  4. package/dist/benchmark.d.ts +55 -0
  5. package/dist/benchmark.d.ts.map +1 -0
  6. package/dist/benchmark.js +168 -0
  7. package/dist/benchmark.js.map +1 -0
  8. package/dist/fixtures.js +2 -2
  9. package/dist/fixtures.js.map +1 -1
  10. package/dist/index.d.ts +13 -4
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +5 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/injected-provider.d.ts.map +1 -1
  15. package/dist/injected-provider.js +748 -25
  16. package/dist/injected-provider.js.map +1 -1
  17. package/dist/mock-wallet-controller.d.ts +150 -2
  18. package/dist/mock-wallet-controller.d.ts.map +1 -1
  19. package/dist/mock-wallet-controller.js +613 -24
  20. package/dist/mock-wallet-controller.js.map +1 -1
  21. package/dist/private-key-rpc-client.d.ts +144 -132
  22. package/dist/private-key-rpc-client.d.ts.map +1 -1
  23. package/dist/private-key-rpc-client.js +142 -20
  24. package/dist/private-key-rpc-client.js.map +1 -1
  25. package/dist/real-wallet-cache.d.ts +38 -0
  26. package/dist/real-wallet-cache.d.ts.map +1 -1
  27. package/dist/real-wallet-cache.js +143 -57
  28. package/dist/real-wallet-cache.js.map +1 -1
  29. package/dist/real-wallet-extension-fixtures.d.ts +35 -0
  30. package/dist/real-wallet-extension-fixtures.d.ts.map +1 -0
  31. package/dist/real-wallet-extension-fixtures.js +96 -0
  32. package/dist/real-wallet-extension-fixtures.js.map +1 -0
  33. package/dist/real-wallet-extension.d.ts +86 -0
  34. package/dist/real-wallet-extension.d.ts.map +1 -0
  35. package/dist/real-wallet-extension.js +245 -0
  36. package/dist/real-wallet-extension.js.map +1 -0
  37. package/dist/real-wallet-fixtures.d.ts.map +1 -1
  38. package/dist/real-wallet-fixtures.js +46 -31
  39. package/dist/real-wallet-fixtures.js.map +1 -1
  40. package/dist/real-wallet.d.ts +5 -14
  41. package/dist/real-wallet.d.ts.map +1 -1
  42. package/dist/real-wallet.js +668 -435
  43. package/dist/real-wallet.js.map +1 -1
  44. package/dist/safe.d.ts +311 -0
  45. package/dist/safe.d.ts.map +1 -0
  46. package/dist/safe.js +743 -0
  47. package/dist/safe.js.map +1 -0
  48. package/dist/types.d.ts +35 -1
  49. package/dist/types.d.ts.map +1 -1
  50. package/dist/wallet-personas.d.ts +99 -0
  51. package/dist/wallet-personas.d.ts.map +1 -0
  52. package/dist/wallet-personas.js +666 -0
  53. package/dist/wallet-personas.js.map +1 -0
  54. package/dist/walletconnect.d.ts +176 -9
  55. package/dist/walletconnect.d.ts.map +1 -1
  56. package/dist/walletconnect.js +514 -74
  57. package/dist/walletconnect.js.map +1 -1
  58. package/examples/playwright.config.ts +8 -0
  59. package/package.json +29 -3
@@ -1,7 +1,8 @@
1
1
  import { randomBytes } from 'node:crypto';
2
- import { http, toHex } from 'viem';
2
+ import { http, isAddress, keccak256, toHex } from 'viem';
3
3
  import { providerError, serializeRpcError } from './errors.js';
4
4
  import { buildInjectedProviderScript, emitterName, rpcBridgeName, } from './injected-provider.js';
5
+ import { createWalletPersona, mockWalletPersona, } from './wallet-personas.js';
5
6
  /**
6
7
  * Adapter: EIP-1193 RpcClient over a plain JSON-RPC URL (viem http
7
8
  * transport). URL-backed chains serve reads, eth_sendRawTransaction, and
@@ -14,8 +15,9 @@ export function httpRpcClient(url, options = {}) {
14
15
  retryCount: options.retryCount ?? 0,
15
16
  timeout: options.timeout,
16
17
  })({});
18
+ const rpcTransport = transport;
17
19
  return {
18
- request: (request) => transport.request(request),
20
+ request: (request) => rpcTransport.request(request),
19
21
  };
20
22
  }
21
23
  const DEFAULT_PROVIDER_INFO = {
@@ -24,11 +26,13 @@ const DEFAULT_PROVIDER_INFO = {
24
26
  icon: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="%23111827"/><path d="M17 34h30v14H17z" fill="%2338bdf8"/><path d="M20 18h24v16H20z" fill="%23f59e0b"/><circle cx="43" cy="41" r="3" fill="%23111827"/></svg>',
25
27
  rdns: 'dev.invisible-wallet.mock',
26
28
  };
29
+ const DEFAULT_SOLANA_PUBLIC_KEY = '26qv4GCcx98RihuK3c4T6ozB3J7L6VwCuFVc7Ta2A3Uo';
27
30
  const defaultAdditionalProviderInfo = (index) => ({
28
31
  uuid: `00000000-0000-4000-8000-${String(index + 2).padStart(12, '0')}`,
29
32
  name: `Mock Wallet ${index + 2}`,
30
33
  icon: DEFAULT_PROVIDER_INFO.icon,
31
34
  rdns: `dev.invisible-wallet.mock.${index + 2}`,
35
+ flags: { isMetaMask: true, isMock: true },
32
36
  });
33
37
  const SIGNING_METHODS = new Set([
34
38
  'eth_sendTransaction',
@@ -37,16 +41,26 @@ const SIGNING_METHODS = new Set([
37
41
  'eth_signTypedData_v3',
38
42
  'eth_signTypedData_v4',
39
43
  'personal_sign',
44
+ 'solana_signIn',
45
+ 'solana_signAllTransactions',
46
+ 'solana_signAndSendAllTransactions',
47
+ 'solana_signAndSendTransaction',
48
+ 'solana_signMessage',
49
+ 'solana_signTransaction',
40
50
  // A batch is a spend: 4100 while disconnected, approval-gated, covered by
41
51
  // the default simulateRejection() set.
42
52
  'wallet_sendCalls',
43
53
  ]);
44
54
  // Methods that open a wallet prompt in a real wallet but do not sign.
45
55
  const PROMPT_METHODS = new Set([
56
+ 'coinbase_fetchPermissions',
57
+ 'wallet_addSubAccount',
46
58
  'wallet_addEthereumChain',
59
+ 'wallet_connect',
47
60
  'wallet_requestPermissions',
48
61
  'wallet_switchEthereumChain',
49
62
  'wallet_watchAsset',
63
+ 'solana_requestAccounts',
50
64
  ]);
51
65
  const APPROVAL_GATED_METHODS = new Set([
52
66
  ...SIGNING_METHODS,
@@ -56,6 +70,37 @@ const APPROVAL_GATED_METHODS = new Set([
56
70
  // approve, and it must not slip through the unguarded default forward.
57
71
  'eth_sendRawTransaction',
58
72
  ]);
73
+ const UNLOCK_REQUIRED_METHODS = new Set([
74
+ ...APPROVAL_GATED_METHODS,
75
+ 'coinbase_fetchPermission',
76
+ 'wallet_getSubAccounts',
77
+ 'wallet_getCapabilities',
78
+ ]);
79
+ const HARDWARE_WALLET_DEFAULT_DELAY_MS = 750;
80
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
81
+ const inferHardwareWalletRequiredApp = (method) => method.startsWith('solana_') ? 'Solana' : 'Ethereum';
82
+ const hardwareWalletRequiredApp = (method, hardwareWallet) => hardwareWallet.requiredApps[method] ??
83
+ hardwareWallet.requiredApp ??
84
+ inferHardwareWalletRequiredApp(method);
85
+ const normalizeHardwareWalletSimulation = (options) => {
86
+ const input = options === true
87
+ ? { enabled: true }
88
+ : options === false || options === undefined
89
+ ? { enabled: false }
90
+ : options;
91
+ const approvalDelayMs = input.approvalDelayMs ?? HARDWARE_WALLET_DEFAULT_DELAY_MS;
92
+ if (!Number.isFinite(approvalDelayMs) || approvalDelayMs < 0) {
93
+ throw new Error('hardwareWallet.approvalDelayMs must be a non-negative finite number.');
94
+ }
95
+ return {
96
+ enabled: input.enabled ?? (options !== undefined),
97
+ approvalDelayMs,
98
+ deviceState: input.deviceState ?? 'ready',
99
+ methods: [...(input.methods ?? SIGNING_METHODS)],
100
+ requiredApp: input.requiredApp,
101
+ requiredApps: { ...(input.requiredApps ?? {}) },
102
+ };
103
+ };
59
104
  const normalizeParams = (params) => {
60
105
  if (params === undefined) {
61
106
  return [];
@@ -87,6 +132,136 @@ const parseDappChainId = (chainId) => {
87
132
  throw providerError(-32602, `Invalid chainId "${chainId}".`);
88
133
  }
89
134
  };
135
+ const parseCoinbaseChainId = (chainId, label = 'chainId') => {
136
+ if (typeof chainId === 'number' && Number.isSafeInteger(chainId) && chainId >= 0) {
137
+ return toHex(chainId);
138
+ }
139
+ if (typeof chainId === 'string' && chainId.startsWith('0x')) {
140
+ return parseDappChainId(chainId);
141
+ }
142
+ throw providerError(-32602, `${label} must be a hexadecimal string or non-negative integer.`);
143
+ };
144
+ const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
145
+ const assertRpcAddress = (value, label) => {
146
+ if (typeof value === 'string' && isAddress(value)) {
147
+ return value;
148
+ }
149
+ throw providerError(-32602, `${label} must be a valid address.`);
150
+ };
151
+ const assertRpcHex = (value, label, options = {}) => {
152
+ const pattern = options.allowEmpty ? /^0x[0-9a-fA-F]*$/ : /^0x[0-9a-fA-F]+$/;
153
+ if (typeof value === 'string' && pattern.test(value)) {
154
+ return value;
155
+ }
156
+ throw providerError(-32602, `${label} must be a 0x-prefixed hex string.`);
157
+ };
158
+ const assertRpcHash = (value, label) => {
159
+ if (typeof value === 'string' && /^0x[0-9a-fA-F]{64}$/.test(value)) {
160
+ return value;
161
+ }
162
+ throw providerError(-32602, `${label} must be a 32-byte 0x-prefixed hex string.`);
163
+ };
164
+ const assertRpcObject = (value, method) => {
165
+ if (isRecord(value)) {
166
+ return value;
167
+ }
168
+ throw providerError(-32602, `${method} requires an object parameter.`);
169
+ };
170
+ const requireConfigAddress = (value, label) => {
171
+ if (typeof value === 'string' && isAddress(value)) {
172
+ return value;
173
+ }
174
+ throw new Error(`${label} must be a valid address.`);
175
+ };
176
+ const requireConfigHex = (value, label, options = {}) => {
177
+ const pattern = options.hash
178
+ ? /^0x[0-9a-fA-F]{64}$/
179
+ : options.allowEmpty
180
+ ? /^0x[0-9a-fA-F]*$/
181
+ : /^0x[0-9a-fA-F]+$/;
182
+ if (typeof value === 'string' && pattern.test(value)) {
183
+ return value;
184
+ }
185
+ throw new Error(`${label} must be a ${options.hash ? '32-byte ' : ''}0x-prefixed hex string.`);
186
+ };
187
+ const requireFiniteNumber = (value, label) => {
188
+ if (typeof value === 'number' && Number.isFinite(value)) {
189
+ return value;
190
+ }
191
+ throw new Error(`${label} must be a finite number.`);
192
+ };
193
+ const requireConfigString = (value, label) => {
194
+ if (typeof value === 'string') {
195
+ return value;
196
+ }
197
+ throw new Error(`${label} must be a string.`);
198
+ };
199
+ const sameAddress = (left, right) => left.toLowerCase() === right.toLowerCase();
200
+ const cloneCoinbasePermission = (permission) => ({
201
+ createdAt: permission.createdAt,
202
+ permissionHash: permission.permissionHash,
203
+ signature: permission.signature,
204
+ spendPermission: { ...permission.spendPermission },
205
+ });
206
+ const cloneCoinbaseSubAccount = (subAccount) => ({
207
+ address: subAccount.address,
208
+ ...(subAccount.factory ? { factory: subAccount.factory } : {}),
209
+ ...(subAccount.factoryData ? { factoryData: subAccount.factoryData } : {}),
210
+ });
211
+ const normalizeCoinbasePermission = (permission, defaultChainId) => ({
212
+ createdAt: requireFiniteNumber(permission.createdAt, 'coinbase.permissions[].createdAt'),
213
+ permissionHash: requireConfigHex(permission.permissionHash, 'coinbase.permissions[].permissionHash', { hash: true }),
214
+ signature: requireConfigHex(permission.signature, 'coinbase.permissions[].signature'),
215
+ chainId: permission.chainId === undefined ? defaultChainId : normalizeChainId(permission.chainId),
216
+ spendPermission: {
217
+ account: requireConfigAddress(permission.spendPermission?.account, 'coinbase.permissions[].spendPermission.account'),
218
+ spender: requireConfigAddress(permission.spendPermission?.spender, 'coinbase.permissions[].spendPermission.spender'),
219
+ token: requireConfigAddress(permission.spendPermission?.token, 'coinbase.permissions[].spendPermission.token'),
220
+ allowance: requireConfigString(permission.spendPermission?.allowance, 'coinbase.permissions[].spendPermission.allowance'),
221
+ period: requireFiniteNumber(permission.spendPermission?.period, 'coinbase.permissions[].spendPermission.period'),
222
+ start: requireFiniteNumber(permission.spendPermission?.start, 'coinbase.permissions[].spendPermission.start'),
223
+ end: requireFiniteNumber(permission.spendPermission?.end, 'coinbase.permissions[].spendPermission.end'),
224
+ salt: requireConfigString(permission.spendPermission?.salt, 'coinbase.permissions[].spendPermission.salt'),
225
+ extraData: requireConfigHex(permission.spendPermission?.extraData, 'coinbase.permissions[].spendPermission.extraData', { allowEmpty: true }),
226
+ },
227
+ });
228
+ const normalizeCoinbaseSubAccount = (subAccount, defaultChainId, defaultAccount) => ({
229
+ address: requireConfigAddress(subAccount.address, 'coinbase.subAccounts[].address'),
230
+ chainId: subAccount.chainId === undefined ? defaultChainId : normalizeChainId(subAccount.chainId),
231
+ account: subAccount.account === undefined
232
+ ? defaultAccount
233
+ : requireConfigAddress(subAccount.account, 'coinbase.subAccounts[].account'),
234
+ ...(subAccount.domain ? { domain: subAccount.domain } : {}),
235
+ ...(subAccount.factory
236
+ ? { factory: requireConfigAddress(subAccount.factory, 'coinbase.subAccounts[].factory') }
237
+ : {}),
238
+ ...(subAccount.factoryData
239
+ ? {
240
+ factoryData: requireConfigHex(subAccount.factoryData, 'coinbase.subAccounts[].factoryData', { allowEmpty: true }),
241
+ }
242
+ : {}),
243
+ });
244
+ const normalizeCoinbaseWalletSimulation = (options, defaultEnabled, defaultChainId, defaultAccount) => {
245
+ if (options === false) {
246
+ return { enabled: false, permissions: [], subAccounts: [] };
247
+ }
248
+ const input = options === true ? { enabled: true } : (options ?? {});
249
+ return {
250
+ enabled: input.enabled ?? (options === undefined ? defaultEnabled : true),
251
+ permissions: [...(input.permissions ?? [])].map((permission) => normalizeCoinbasePermission(permission, defaultChainId)),
252
+ subAccounts: [...(input.subAccounts ?? [])].map((subAccount) => normalizeCoinbaseSubAccount(subAccount, defaultChainId, defaultAccount)),
253
+ ...(input.factory
254
+ ? { factory: requireConfigAddress(input.factory, 'coinbase.factory') }
255
+ : {}),
256
+ ...(input.factoryData
257
+ ? { factoryData: requireConfigHex(input.factoryData, 'coinbase.factoryData', { allowEmpty: true }) }
258
+ : {}),
259
+ };
260
+ };
261
+ const generatedSubAccountAddress = (owner, chainId, index, accountConfig) => {
262
+ const seed = JSON.stringify({ owner: owner.toLowerCase(), chainId, index, accountConfig });
263
+ return `0x${keccak256(toHex(seed)).slice(-40)}`;
264
+ };
90
265
  // Bounded eth_chainId probe used when trustDappRpcUrls wires up a
91
266
  // dapp-supplied RPC URL — a hung endpoint must not stall the dapp's promise.
92
267
  const probeChainId = async (url, timeoutMs = 5_000) => {
@@ -132,7 +307,10 @@ export class MockWalletController {
132
307
  accounts;
133
308
  chainId;
134
309
  connected;
310
+ unlocked;
135
311
  approveRequests;
312
+ hardwareWallet;
313
+ coinbase;
136
314
  rejectionQueue = [];
137
315
  holdQueue = [];
138
316
  approvalQueue = [];
@@ -156,13 +334,16 @@ export class MockWalletController {
156
334
  shownCallsStatusIds = [];
157
335
  sentTransactions = [];
158
336
  sentTransactionRequests = [];
337
+ watchedAssets = [];
159
338
  constructor(page, rpcClient, options) {
160
339
  this.page = page;
161
340
  this.rpcClient = rpcClient;
162
341
  this.accounts = [...options.accounts];
163
342
  this.chainId = normalizeChainId(options.chainId);
164
343
  this.connected = options.connected ?? true;
344
+ this.unlocked = options.unlocked ?? true;
165
345
  this.approveRequests = options.autoApprove ?? true;
346
+ this.hardwareWallet = normalizeHardwareWalletSimulation(options.hardwareWallet);
166
347
  this.trustDappRpcUrls = options.trustDappRpcUrls ?? false;
167
348
  const eip5792 = typeof options.eip5792 === 'boolean' ? { enabled: options.eip5792 } : (options.eip5792 ?? {});
168
349
  this.eip5792 = {
@@ -202,18 +383,24 @@ export class MockWalletController {
202
383
  if (this.accounts.length === 0) {
203
384
  throw new Error('MockWalletController requires at least one account.');
204
385
  }
205
- const primaryProviderInfo = {
206
- ...DEFAULT_PROVIDER_INFO,
386
+ const primaryProviderInfo = createWalletPersona({
387
+ ...mockWalletPersona(),
388
+ ...options.persona,
207
389
  ...options.providerInfo,
208
- };
390
+ });
209
391
  this.providerInfos = [
210
392
  primaryProviderInfo,
211
- ...(options.additionalProviders ?? []).map((provider, index) => ({
393
+ ...(options.additionalProviders ?? []).map((provider, index) => createWalletPersona({
212
394
  ...defaultAdditionalProviderInfo(index),
213
395
  ...provider,
214
396
  })),
397
+ ...(options.additionalPersonas ?? []).map((provider, index) => createWalletPersona({
398
+ ...defaultAdditionalProviderInfo(index + (options.additionalProviders?.length ?? 0)),
399
+ ...provider,
400
+ })),
215
401
  ];
216
402
  this.providerInfo = primaryProviderInfo;
403
+ this.coinbase = normalizeCoinbaseWalletSimulation(options.coinbase, this.providerInfos.some((provider) => provider.flags?.isCoinbaseWallet === true), this.chainId, this.primaryAccount);
217
404
  }
218
405
  providerInfo;
219
406
  providerInfos;
@@ -231,6 +418,36 @@ export class MockWalletController {
231
418
  get backedChainIds() {
232
419
  return [...this.chainBackends.keys()];
233
420
  }
421
+ get coinbasePermissions() {
422
+ return this.coinbase.permissions.map(cloneCoinbasePermission);
423
+ }
424
+ get coinbaseSubAccounts() {
425
+ return this.coinbase.subAccounts.map((subAccount) => ({
426
+ ...cloneCoinbaseSubAccount(subAccount),
427
+ chainId: subAccount.chainId,
428
+ account: subAccount.account,
429
+ ...(subAccount.domain ? { domain: subAccount.domain } : {}),
430
+ }));
431
+ }
432
+ get solanaAccounts() {
433
+ if (!this.connected || !this.unlocked) {
434
+ return [];
435
+ }
436
+ const seen = new Set();
437
+ const accounts = [];
438
+ for (const provider of this.providerInfos) {
439
+ if (!provider.solana) {
440
+ continue;
441
+ }
442
+ const publicKey = provider.solana.publicKey ?? DEFAULT_SOLANA_PUBLIC_KEY;
443
+ if (seen.has(publicKey)) {
444
+ continue;
445
+ }
446
+ seen.add(publicKey);
447
+ accounts.push({ publicKey, pubkey: publicKey, address: publicKey });
448
+ }
449
+ return accounts;
450
+ }
234
451
  /**
235
452
  * Test-side chain registration (Synpress addNetwork analogue): registers
236
453
  * the backend and marks the chain known — no approval gate, no probe, and
@@ -302,6 +519,7 @@ export class MockWalletController {
302
519
  autoApprove: this.approveRequests,
303
520
  chainId: this.chainId,
304
521
  connected: this.connected,
522
+ unlocked: this.unlocked,
305
523
  providers: this.providerInfos,
306
524
  allowedOrigins: this.allowedOrigins,
307
525
  };
@@ -312,6 +530,25 @@ export class MockWalletController {
312
530
  autoApprove(enabled = true) {
313
531
  this.approveRequests = enabled;
314
532
  }
533
+ configureHardwareWallet(options) {
534
+ this.hardwareWallet = normalizeHardwareWalletSimulation(options);
535
+ }
536
+ configureCoinbaseWallet(options) {
537
+ this.coinbase = normalizeCoinbaseWalletSimulation(options, this.coinbase.enabled, this.chainId, this.primaryAccount);
538
+ }
539
+ setHardwareWalletState(state) {
540
+ this.hardwareWallet = {
541
+ ...this.hardwareWallet,
542
+ enabled: true,
543
+ deviceState: state,
544
+ };
545
+ }
546
+ setHardwareWalletApprovalDelay(approvalDelayMs) {
547
+ this.hardwareWallet = normalizeHardwareWalletSimulation({
548
+ ...this.hardwareWallet,
549
+ approvalDelayMs,
550
+ });
551
+ }
315
552
  /**
316
553
  * One-shot: the next atomicRequired wallet_sendCalls while the atomic
317
554
  * capability is 'ready' throws 5750 (user rejected the EOA upgrade)
@@ -379,6 +616,22 @@ export class MockWalletController {
379
616
  }
380
617
  throw new Error(`Timed out after ${timeoutMs}ms waiting for the page to submit a transaction.`);
381
618
  }
619
+ /**
620
+ * Resolves with the next approved wallet_watchAsset request after this
621
+ * call. Invoke before triggering the dapp action, then await it.
622
+ */
623
+ async waitForNextWatchedAsset(options = {}) {
624
+ const timeoutMs = options.timeoutMs ?? 15_000;
625
+ const baseline = this.watchedAssets.length;
626
+ const deadline = Date.now() + timeoutMs;
627
+ while (Date.now() < deadline) {
628
+ if (this.watchedAssets.length > baseline) {
629
+ return this.watchedAssets[baseline];
630
+ }
631
+ await new Promise((resolve) => setTimeout(resolve, 50));
632
+ }
633
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for the page to watch an asset.`);
634
+ }
382
635
  /**
383
636
  * Replaces the account set (and reconnects a disconnected wallet — unlike
384
637
  * switchAccount, which only reorders). Accounts are validated against the
@@ -394,7 +647,7 @@ export class MockWalletController {
394
647
  }
395
648
  this.accounts = [...accounts];
396
649
  this.connected = true;
397
- await this.emit('accountsChanged', this.accounts);
650
+ await this.emit('accountsChanged', this.unlocked ? this.accounts : []);
398
651
  }
399
652
  /**
400
653
  * Re-selects one of the wallet's existing accounts: moves it to index 0
@@ -413,10 +666,28 @@ export class MockWalletController {
413
666
  }
414
667
  const [selected] = this.accounts.splice(index, 1);
415
668
  this.accounts.unshift(selected);
416
- if (this.connected) {
669
+ if (this.connected && this.unlocked) {
417
670
  await this.emit('accountsChanged', this.accounts);
418
671
  }
419
672
  }
673
+ get isUnlocked() {
674
+ return this.unlocked;
675
+ }
676
+ async setUnlocked(unlocked) {
677
+ if (this.unlocked === unlocked) {
678
+ return;
679
+ }
680
+ this.unlocked = unlocked;
681
+ if (this.connected) {
682
+ await this.emit('accountsChanged', unlocked ? this.accounts : []);
683
+ }
684
+ }
685
+ async lock() {
686
+ await this.setUnlocked(false);
687
+ }
688
+ async unlock() {
689
+ await this.setUnlocked(true);
690
+ }
420
691
  async disconnect() {
421
692
  this.connected = false;
422
693
  await this.emit('accountsChanged', []);
@@ -425,7 +696,7 @@ export class MockWalletController {
425
696
  async reconnect() {
426
697
  this.connected = true;
427
698
  await this.emit('connect', { chainId: this.chainId });
428
- await this.emit('accountsChanged', this.accounts);
699
+ await this.emit('accountsChanged', this.unlocked ? this.accounts : []);
429
700
  }
430
701
  async switchNetwork(chainId) {
431
702
  const normalized = normalizeChainId(chainId);
@@ -774,6 +1045,26 @@ export class MockWalletController {
774
1045
  throw providerError(4100, `The wallet is not available to origin "${origin}" (allowedOrigins: ${this.allowedOrigins.join(', ')}).`);
775
1046
  }
776
1047
  }
1048
+ async assertHardwareWalletReady(method) {
1049
+ if (!this.hardwareWallet.enabled || !this.hardwareWallet.methods.includes(method)) {
1050
+ return;
1051
+ }
1052
+ switch (this.hardwareWallet.deviceState) {
1053
+ case 'ready':
1054
+ if (this.hardwareWallet.approvalDelayMs > 0) {
1055
+ await wait(this.hardwareWallet.approvalDelayMs);
1056
+ }
1057
+ return;
1058
+ case 'locked':
1059
+ throw providerError(4001, 'Hardware wallet is locked. Unlock the device and try again.');
1060
+ case 'wrong-app':
1061
+ throw providerError(4001, `Open the ${hardwareWalletRequiredApp(method, this.hardwareWallet)} app on your hardware wallet and try again.`);
1062
+ case 'blind-signing-disabled':
1063
+ throw providerError(4001, 'Blind signing is disabled on your hardware wallet.');
1064
+ case 'disconnected':
1065
+ throw providerError(4900, 'Hardware wallet disconnected.');
1066
+ }
1067
+ }
777
1068
  async assertUserApproved(method, params) {
778
1069
  const hold = this.consumeRule(this.holdQueue, method);
779
1070
  if (hold) {
@@ -790,11 +1081,11 @@ export class MockWalletController {
790
1081
  (!rule.match || rule.match(method, params)));
791
1082
  if (armedIndex !== -1) {
792
1083
  this.approvalQueue.splice(armedIndex, 1);
793
- return;
794
1084
  }
795
- if (!this.approveRequests && APPROVAL_GATED_METHODS.has(method)) {
1085
+ else if (!this.approveRequests && APPROVAL_GATED_METHODS.has(method)) {
796
1086
  throw providerError(4001, 'User rejected the request.');
797
1087
  }
1088
+ await this.assertHardwareWalletReady(method);
798
1089
  }
799
1090
  permissionResponse() {
800
1091
  return [
@@ -804,15 +1095,286 @@ export class MockWalletController {
804
1095
  },
805
1096
  ];
806
1097
  }
1098
+ requestedPermissionKeys(params, method) {
1099
+ if (params.length === 0 || params[0] === undefined) {
1100
+ return ['eth_accounts'];
1101
+ }
1102
+ const request = assertRpcObject(params[0], method);
1103
+ const keys = Object.keys(request);
1104
+ if (keys.length === 0) {
1105
+ throw providerError(-32602, `${method} requires at least one permission.`);
1106
+ }
1107
+ return keys;
1108
+ }
1109
+ assertSupportedPermissions(params, method) {
1110
+ const unsupported = this.requestedPermissionKeys(params, method).filter((permission) => permission !== 'eth_accounts');
1111
+ if (unsupported.length > 0) {
1112
+ throw providerError(4200, `The mock wallet does not support permission "${unsupported[0]}".`);
1113
+ }
1114
+ }
1115
+ async handleRequestPermissions(params) {
1116
+ this.assertSupportedPermissions(params, 'wallet_requestPermissions');
1117
+ await this.assertUserApproved('wallet_requestPermissions', params);
1118
+ const wasConnected = this.connected;
1119
+ this.connected = true;
1120
+ if (!wasConnected) {
1121
+ await this.emit('connect', { chainId: this.chainId });
1122
+ }
1123
+ await this.emit('accountsChanged', this.accounts);
1124
+ return this.permissionResponse();
1125
+ }
1126
+ async handleRevokePermissions(params) {
1127
+ this.assertSupportedPermissions(params, 'wallet_revokePermissions');
1128
+ this.connected = false;
1129
+ await this.emit('accountsChanged', []);
1130
+ return null;
1131
+ }
1132
+ assertCoinbaseEnabled(method) {
1133
+ if (!this.coinbase.enabled) {
1134
+ throw providerError(4200, `The mock wallet does not support the method "${method}".`);
1135
+ }
1136
+ }
1137
+ assertCoinbaseConnected() {
1138
+ if (!this.connected) {
1139
+ throw providerError(4100, 'The requested account and/or method has not been authorized by the user.');
1140
+ }
1141
+ }
1142
+ assertAuthorizedAccount(account) {
1143
+ if (!this.accounts.some((authorized) => sameAddress(authorized, account))) {
1144
+ throw providerError(4100, `The requested account ${account} has not been authorized by the user.`);
1145
+ }
1146
+ }
1147
+ async buildCoinbaseSiweCapability(capability) {
1148
+ const config = assertRpcObject(capability, 'wallet_connect signInWithEthereum');
1149
+ const nonce = config.nonce;
1150
+ if (typeof nonce !== 'string' || nonce.length === 0) {
1151
+ throw providerError(-32602, 'signInWithEthereum.nonce must be a non-empty string.');
1152
+ }
1153
+ const chainId = parseCoinbaseChainId(config.chainId, 'signInWithEthereum.chainId');
1154
+ if (chainId !== this.chainId) {
1155
+ throw providerError(-32602, `signInWithEthereum.chainId ${chainId} does not match the active chain ${this.chainId}.`);
1156
+ }
1157
+ const domain = typeof config.domain === 'string' && config.domain.length > 0
1158
+ ? config.domain
1159
+ : 'web3-tester.local';
1160
+ const uri = typeof config.uri === 'string' && config.uri.length > 0
1161
+ ? config.uri
1162
+ : `https://${domain}`;
1163
+ const statement = typeof config.statement === 'string' && config.statement.length > 0
1164
+ ? config.statement
1165
+ : 'Sign in with Coinbase Wallet.';
1166
+ const resources = Array.isArray(config.resources)
1167
+ ? config.resources.filter((resource) => typeof resource === 'string')
1168
+ : [];
1169
+ const messageLines = [
1170
+ `${domain} wants you to sign in with your Ethereum account:`,
1171
+ this.primaryAccount,
1172
+ '',
1173
+ statement,
1174
+ '',
1175
+ `URI: ${uri}`,
1176
+ 'Version: 1',
1177
+ `Chain ID: ${Number(BigInt(chainId))}`,
1178
+ `Nonce: ${nonce}`,
1179
+ 'Issued At: 1970-01-01T00:00:00.000Z',
1180
+ ];
1181
+ if (resources.length > 0) {
1182
+ messageLines.push('Resources:', ...resources.map((resource) => `- ${resource}`));
1183
+ }
1184
+ const message = messageLines.join('\n');
1185
+ const signature = (await this.activeRpcClient.request({
1186
+ method: 'personal_sign',
1187
+ params: [toHex(message), this.primaryAccount],
1188
+ }));
1189
+ return { message, signature };
1190
+ }
1191
+ async handleWalletConnect(params) {
1192
+ this.assertCoinbaseEnabled('wallet_connect');
1193
+ const request = params[0] === undefined ? {} : assertRpcObject(params[0], 'wallet_connect');
1194
+ await this.assertUserApproved('wallet_connect', params);
1195
+ if (!this.connected) {
1196
+ this.connected = true;
1197
+ await this.emit('connect', { chainId: this.chainId });
1198
+ await this.emit('accountsChanged', this.accounts);
1199
+ }
1200
+ const capabilities = isRecord(request.capabilities) ? request.capabilities : undefined;
1201
+ const result = {
1202
+ accounts: this.accounts.map((address) => ({ address })),
1203
+ chainId: this.chainId,
1204
+ isConnected: true,
1205
+ };
1206
+ if (capabilities?.signInWithEthereum !== undefined) {
1207
+ result.capabilities = {
1208
+ signInWithEthereum: await this.buildCoinbaseSiweCapability(capabilities.signInWithEthereum),
1209
+ };
1210
+ }
1211
+ return result;
1212
+ }
1213
+ handleWalletGetSubAccounts(params) {
1214
+ this.assertCoinbaseEnabled('wallet_getSubAccounts');
1215
+ this.assertCoinbaseConnected();
1216
+ const request = assertRpcObject(params[0], 'wallet_getSubAccounts');
1217
+ const account = assertRpcAddress(request.account, 'wallet_getSubAccounts.account');
1218
+ this.assertAuthorizedAccount(account);
1219
+ if (typeof request.domain !== 'string' || request.domain.length === 0) {
1220
+ throw providerError(-32602, 'wallet_getSubAccounts.domain must be a non-empty string.');
1221
+ }
1222
+ const subAccounts = this.coinbase.subAccounts
1223
+ .filter((subAccount) => subAccount.chainId === this.chainId &&
1224
+ sameAddress(subAccount.account, account) &&
1225
+ subAccount.domain === request.domain)
1226
+ .map(cloneCoinbaseSubAccount);
1227
+ return { subAccounts };
1228
+ }
1229
+ async handleWalletAddSubAccount(params) {
1230
+ this.assertCoinbaseEnabled('wallet_addSubAccount');
1231
+ this.assertCoinbaseConnected();
1232
+ const request = assertRpcObject(params[0], 'wallet_addSubAccount');
1233
+ const accountConfig = assertRpcObject(request.account, 'wallet_addSubAccount.account');
1234
+ const type = accountConfig.type;
1235
+ if (type !== 'create' && type !== 'deployed') {
1236
+ throw providerError(-32602, 'wallet_addSubAccount.account.type must be "create" or "deployed".');
1237
+ }
1238
+ if (request.domain !== undefined && typeof request.domain !== 'string') {
1239
+ throw providerError(-32602, 'wallet_addSubAccount.domain must be a string when provided.');
1240
+ }
1241
+ let chainId = this.chainId;
1242
+ let address;
1243
+ if (type === 'deployed') {
1244
+ address = assertRpcAddress(accountConfig.address, 'wallet_addSubAccount.account.address');
1245
+ chainId = parseCoinbaseChainId(accountConfig.chainId, 'wallet_addSubAccount.account.chainId');
1246
+ }
1247
+ else {
1248
+ if (!Array.isArray(accountConfig.keys) || accountConfig.keys.length === 0) {
1249
+ throw providerError(-32602, 'wallet_addSubAccount.account.keys must be a non-empty array.');
1250
+ }
1251
+ for (const key of accountConfig.keys) {
1252
+ if (!isRecord(key) || typeof key.type !== 'string' || typeof key.publicKey !== 'string') {
1253
+ throw providerError(-32602, 'wallet_addSubAccount.account.keys entries require type and publicKey strings.');
1254
+ }
1255
+ }
1256
+ address =
1257
+ typeof accountConfig.address === 'string' && isAddress(accountConfig.address)
1258
+ ? accountConfig.address
1259
+ : generatedSubAccountAddress(this.primaryAccount, chainId, this.coinbase.subAccounts.length, accountConfig);
1260
+ }
1261
+ const factory = request.factory !== undefined
1262
+ ? assertRpcAddress(request.factory, 'wallet_addSubAccount.factory')
1263
+ : accountConfig.factory !== undefined
1264
+ ? assertRpcAddress(accountConfig.factory, 'wallet_addSubAccount.account.factory')
1265
+ : this.coinbase.factory;
1266
+ const factoryData = request.factoryData !== undefined
1267
+ ? assertRpcHex(request.factoryData, 'wallet_addSubAccount.factoryData', { allowEmpty: true })
1268
+ : accountConfig.factoryData !== undefined
1269
+ ? assertRpcHex(accountConfig.factoryData, 'wallet_addSubAccount.account.factoryData', {
1270
+ allowEmpty: true,
1271
+ })
1272
+ : this.coinbase.factoryData;
1273
+ await this.assertUserApproved('wallet_addSubAccount', params);
1274
+ const subAccount = {
1275
+ address,
1276
+ chainId,
1277
+ account: this.primaryAccount,
1278
+ ...(typeof request.domain === 'string' ? { domain: request.domain } : {}),
1279
+ ...(factory ? { factory } : {}),
1280
+ ...(factoryData ? { factoryData } : {}),
1281
+ };
1282
+ this.coinbase.subAccounts.push(subAccount);
1283
+ return {
1284
+ address,
1285
+ chainId,
1286
+ ...(factory ? { factory } : {}),
1287
+ ...(factoryData ? { factoryData } : {}),
1288
+ };
1289
+ }
1290
+ async handleCoinbaseFetchPermissions(params) {
1291
+ this.assertCoinbaseEnabled('coinbase_fetchPermissions');
1292
+ this.assertCoinbaseConnected();
1293
+ const request = assertRpcObject(params[0], 'coinbase_fetchPermissions');
1294
+ const spender = assertRpcAddress(request.spender, 'coinbase_fetchPermissions.spender');
1295
+ const chainId = parseDappChainId(request.chainId);
1296
+ const account = request.account === undefined
1297
+ ? undefined
1298
+ : assertRpcAddress(request.account, 'coinbase_fetchPermissions.account');
1299
+ if (account) {
1300
+ this.assertAuthorizedAccount(account);
1301
+ }
1302
+ const pageOptions = request.pageOptions === undefined
1303
+ ? {}
1304
+ : assertRpcObject(request.pageOptions, 'coinbase_fetchPermissions.pageOptions');
1305
+ const pageSize = typeof pageOptions.pageSize === 'number' && Number.isSafeInteger(pageOptions.pageSize)
1306
+ ? pageOptions.pageSize
1307
+ : 50;
1308
+ if (pageSize <= 0) {
1309
+ throw providerError(-32602, 'coinbase_fetchPermissions.pageOptions.pageSize must be positive.');
1310
+ }
1311
+ const cursor = pageOptions.cursor === undefined
1312
+ ? 0
1313
+ : typeof pageOptions.cursor === 'string' && /^\d+$/.test(pageOptions.cursor)
1314
+ ? Number(pageOptions.cursor)
1315
+ : (() => {
1316
+ throw providerError(-32602, 'coinbase_fetchPermissions.pageOptions.cursor must be a decimal string.');
1317
+ })();
1318
+ await this.assertUserApproved('coinbase_fetchPermissions', params);
1319
+ const nowSeconds = Math.floor(Date.now() / 1000);
1320
+ const matching = this.coinbase.permissions
1321
+ .filter((permission) => permission.chainId === chainId &&
1322
+ sameAddress(permission.spendPermission.spender, spender) &&
1323
+ (account === undefined || sameAddress(permission.spendPermission.account, account)) &&
1324
+ (permission.spendPermission.end === 0 || permission.spendPermission.end > nowSeconds))
1325
+ .sort((left, right) => left.createdAt - right.createdAt);
1326
+ const page = matching.slice(cursor, cursor + pageSize);
1327
+ const nextCursor = cursor + page.length < matching.length ? String(cursor + page.length) : undefined;
1328
+ return {
1329
+ permissions: page.map(cloneCoinbasePermission),
1330
+ pageDescription: {
1331
+ pageSize: page.length,
1332
+ ...(nextCursor ? { nextCursor } : {}),
1333
+ },
1334
+ };
1335
+ }
1336
+ handleCoinbaseFetchPermission(params) {
1337
+ this.assertCoinbaseEnabled('coinbase_fetchPermission');
1338
+ const request = assertRpcObject(params[0], 'coinbase_fetchPermission');
1339
+ const permissionHash = assertRpcHash(request.permissionHash, 'coinbase_fetchPermission.permissionHash');
1340
+ const permission = this.coinbase.permissions.find((candidate) => candidate.permissionHash.toLowerCase() === permissionHash.toLowerCase());
1341
+ if (!permission) {
1342
+ throw providerError(-32603, `No Coinbase spend permission found for ${permissionHash}.`);
1343
+ }
1344
+ return { permission: cloneCoinbasePermission(permission) };
1345
+ }
1346
+ async handleWatchAsset(params) {
1347
+ const request = assertRpcObject(params[0], 'wallet_watchAsset');
1348
+ if (typeof request.type !== 'string' || request.type.length === 0) {
1349
+ throw providerError(-32602, 'wallet_watchAsset.type must be a non-empty string.');
1350
+ }
1351
+ const options = assertRpcObject(request.options, 'wallet_watchAsset.options');
1352
+ if (request.type === 'ERC20' && !isAddress(String(options.address ?? ''))) {
1353
+ throw providerError(-32602, 'wallet_watchAsset.options.address must be a valid address.');
1354
+ }
1355
+ await this.assertUserApproved('wallet_watchAsset', params);
1356
+ this.watchedAssets.push({
1357
+ chainId: this.chainId,
1358
+ type: request.type,
1359
+ options: { ...options },
1360
+ request,
1361
+ });
1362
+ return true;
1363
+ }
807
1364
  async handleRpcRequest(request) {
808
1365
  const { method } = request;
809
1366
  const params = normalizeParams(request.params);
1367
+ if (!this.unlocked && UNLOCK_REQUIRED_METHODS.has(method)) {
1368
+ throw providerError(4100, 'The wallet is locked. Unlock the wallet and try again.');
1369
+ }
810
1370
  if (!this.connected && SIGNING_METHODS.has(method)) {
811
1371
  throw providerError(4100, 'The requested account and/or method has not been authorized by the user.');
812
1372
  }
813
1373
  switch (method) {
814
1374
  case 'eth_accounts':
815
- return this.connected ? this.accounts : [];
1375
+ return this.connected && this.unlocked ? this.accounts : [];
1376
+ case 'solana_getAccounts':
1377
+ return this.solanaAccounts;
816
1378
  case 'eth_requestAccounts':
817
1379
  await this.assertUserApproved(method, params);
818
1380
  if (!this.connected) {
@@ -821,21 +1383,37 @@ export class MockWalletController {
821
1383
  await this.emit('accountsChanged', this.accounts);
822
1384
  }
823
1385
  return this.accounts;
1386
+ case 'wallet_connect':
1387
+ return this.handleWalletConnect(params);
1388
+ case 'solana_requestAccounts':
1389
+ await this.assertUserApproved(method, params);
1390
+ if (!this.connected) {
1391
+ this.connected = true;
1392
+ await this.emit('connect', { chainId: this.chainId });
1393
+ await this.emit('accountsChanged', this.accounts);
1394
+ }
1395
+ return this.solanaAccounts;
824
1396
  case 'eth_chainId':
825
1397
  return this.chainId;
826
1398
  case 'net_version':
827
1399
  return String(Number(BigInt(this.chainId)));
1400
+ case 'eth_subscribe':
1401
+ case 'eth_unsubscribe':
1402
+ throw providerError(4200, `The mock wallet does not support the method "${method}".`);
828
1403
  case 'wallet_getPermissions':
829
- return this.connected ? this.permissionResponse() : [];
1404
+ return this.connected && this.unlocked ? this.permissionResponse() : [];
830
1405
  case 'wallet_requestPermissions':
831
- await this.assertUserApproved(method, params);
832
- this.connected = true;
833
- await this.emit('accountsChanged', this.accounts);
834
- return this.permissionResponse();
1406
+ return this.handleRequestPermissions(params);
835
1407
  case 'wallet_revokePermissions':
836
- this.connected = false;
837
- await this.emit('accountsChanged', []);
838
- return null;
1408
+ return this.handleRevokePermissions(params);
1409
+ case 'wallet_addSubAccount':
1410
+ return this.handleWalletAddSubAccount(params);
1411
+ case 'wallet_getSubAccounts':
1412
+ return this.handleWalletGetSubAccounts(params);
1413
+ case 'coinbase_fetchPermissions':
1414
+ return this.handleCoinbaseFetchPermissions(params);
1415
+ case 'coinbase_fetchPermission':
1416
+ return this.handleCoinbaseFetchPermission(params);
839
1417
  // Per-handler order, library-wide: param validation (-32602) -> state
840
1418
  // checks (4902) -> approval -> execution. Real MetaMask returns 4902
841
1419
  // for an unknown chain without ever showing a prompt.
@@ -910,14 +1488,14 @@ export class MockWalletController {
910
1488
  return null;
911
1489
  }
912
1490
  case 'wallet_watchAsset':
913
- await this.assertUserApproved(method, params);
914
- return true;
1491
+ return this.handleWatchAsset(params);
915
1492
  case 'wallet_getCapabilities': {
916
1493
  this.assertEip5792Enabled(method);
917
1494
  // Spec privacy rule: only answer for the connected wallet's accounts.
918
1495
  const [address, chainIdFilter] = params;
919
1496
  const requested = typeof address === 'string' ? address.toLowerCase() : '';
920
1497
  if (!this.connected ||
1498
+ !this.unlocked ||
921
1499
  !this.accounts.some((account) => account.toLowerCase() === requested)) {
922
1500
  throw providerError(4100, 'The requested account and/or method has not been authorized by the user.');
923
1501
  }
@@ -956,9 +1534,9 @@ export class MockWalletController {
956
1534
  }
957
1535
  case 'metamask_getProviderState':
958
1536
  return {
959
- accounts: this.connected ? this.accounts : [],
1537
+ accounts: this.connected && this.unlocked ? this.accounts : [],
960
1538
  chainId: this.chainId,
961
- isUnlocked: true,
1539
+ isUnlocked: this.unlocked,
962
1540
  networkVersion: String(Number(BigInt(this.chainId))),
963
1541
  };
964
1542
  case 'eth_sendTransaction': {
@@ -1008,12 +1586,23 @@ export class MockWalletController {
1008
1586
  case 'personal_sign':
1009
1587
  await this.assertUserApproved(method, params);
1010
1588
  return this.activeRpcClient.request({ method, params });
1589
+ case 'solana_signAllTransactions':
1590
+ case 'solana_signAndSendAllTransactions':
1591
+ case 'solana_signAndSendTransaction':
1592
+ case 'solana_signIn':
1593
+ case 'solana_signMessage':
1594
+ case 'solana_signTransaction':
1595
+ await this.assertUserApproved(method, params);
1596
+ return null;
1011
1597
  default:
1012
1598
  // Unknown wallet-namespace methods are the wallet's responsibility;
1013
1599
  // forwarding them to the node would leak a confusing -32601.
1014
1600
  if (method.startsWith('wallet_')) {
1015
1601
  throw providerError(4200, `The mock wallet does not support the method "${method}".`);
1016
1602
  }
1603
+ if (method.startsWith('coinbase_')) {
1604
+ throw providerError(4200, `The mock wallet does not support the method "${method}".`);
1605
+ }
1017
1606
  return this.activeRpcClient.request({ method, params });
1018
1607
  }
1019
1608
  }