@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,5 +1,13 @@
1
1
  import { toHex } from 'viem';
2
2
  import { serializeRpcError } from './errors.js';
3
+ import { createWalletPersona, walletConnectMetadataForPersona, } from './wallet-personas.js';
4
+ const removeWalletConnectListener = (client, event, handler) => {
5
+ if (client.off) {
6
+ client.off(event, handler);
7
+ return;
8
+ }
9
+ client.removeListener?.(event, handler);
10
+ };
3
11
  export const DEFAULT_WALLETCONNECT_METHODS = [
4
12
  'eth_sendTransaction',
5
13
  'eth_sendRawTransaction',
@@ -16,11 +24,39 @@ export const DEFAULT_WALLETCONNECT_METHODS = [
16
24
  'wallet_getPermissions',
17
25
  'wallet_requestPermissions',
18
26
  'wallet_watchAsset',
27
+ 'wallet_getCapabilities',
28
+ 'wallet_sendCalls',
29
+ 'wallet_getCallsStatus',
30
+ 'wallet_showCallsStatus',
31
+ ];
32
+ export const DEFAULT_WALLETCONNECT_COINBASE_METHODS = [
33
+ 'wallet_connect',
34
+ 'wallet_addSubAccount',
35
+ 'wallet_getSubAccounts',
36
+ 'coinbase_fetchPermissions',
37
+ 'coinbase_fetchPermission',
19
38
  ];
20
39
  const DEFAULT_EVENTS = ['chainChanged', 'accountsChanged'];
40
+ export const DEFAULT_WALLETCONNECT_SOLANA_METHODS = [
41
+ 'solana_getAccounts',
42
+ 'solana_requestAccounts',
43
+ 'solana_signIn',
44
+ 'solana_signAllTransactions',
45
+ 'solana_signAndSendAllTransactions',
46
+ 'solana_signAndSendTransaction',
47
+ 'solana_signMessage',
48
+ 'solana_signTransaction',
49
+ ];
50
+ const DEFAULT_SOLANA_EVENTS = ['accountsChanged'];
51
+ const DEFAULT_SOLANA_CHAINS = [
52
+ 'solana:mainnet',
53
+ 'solana:devnet',
54
+ 'solana:testnet',
55
+ ];
21
56
  // Injectable so the missing-optional-peer hint is hermetically testable
22
57
  // (the peers are devDependencies here, so they always resolve in-repo).
23
58
  let loadModule = (specifier) => import(specifier);
59
+ let walletConnectStoragePrefixCounter = 0;
24
60
  /** @internal Test hook: stub the dynamic importer; call with no args to restore. */
25
61
  export const __setWalletConnectModuleLoader = (loader) => {
26
62
  loadModule = loader ?? ((specifier) => import(specifier));
@@ -29,7 +65,206 @@ const parseCaipChainId = (caip) => {
29
65
  const match = typeof caip === 'string' ? /^eip155:(\d+)$/.exec(caip) : null;
30
66
  return match ? toHex(BigInt(match[1])) : undefined;
31
67
  };
68
+ const parseSolanaCaipChainId = (caip) => typeof caip === 'string' && /^solana:[a-z0-9-]+$/i.test(caip) ? caip : undefined;
32
69
  const toCaipChainId = (chainId) => `eip155:${Number(BigInt(chainId))}`;
70
+ const isAddressString = (value) => typeof value === 'string' && /^0x[0-9a-fA-F]{40}$/.test(value);
71
+ const eip155AccountsFor = (chains, accounts) => chains.flatMap((chainId) => accounts.map((account) => `${chainId}:${account}`));
72
+ const arraysEqual = (left = [], right = []) => left.length === right.length && left.every((value, index) => value === right[index]);
73
+ const bytesFrom = (input) => {
74
+ if (input instanceof Uint8Array)
75
+ return [...input];
76
+ if (input instanceof ArrayBuffer)
77
+ return [...new Uint8Array(input)];
78
+ if (ArrayBuffer.isView(input)) {
79
+ return [...new Uint8Array(input.buffer, input.byteOffset, input.byteLength)];
80
+ }
81
+ if (Array.isArray(input))
82
+ return input.map((value) => Number(value) & 255);
83
+ if (typeof input === 'string')
84
+ return [...new TextEncoder().encode(input)];
85
+ return [...new TextEncoder().encode(JSON.stringify(input ?? null))];
86
+ };
87
+ const deterministicSignature = (publicKey, payload) => {
88
+ const seed = [...new TextEncoder().encode(publicKey), ...bytesFrom(payload)];
89
+ const signature = new Uint8Array(64);
90
+ for (let index = 0; index < signature.length; index += 1) {
91
+ const a = seed[index % seed.length] ?? 0;
92
+ const b = seed[(index * 7 + 13) % seed.length] ?? 0;
93
+ signature[index] = (a + b + index * 17) & 255;
94
+ }
95
+ return signature;
96
+ };
97
+ const base58Encode = (input) => {
98
+ const alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
99
+ const digits = [0];
100
+ for (const byte of input) {
101
+ let carry = byte;
102
+ for (let index = 0; index < digits.length; index += 1) {
103
+ const next = digits[index] * 256 + carry;
104
+ digits[index] = next % 58;
105
+ carry = Math.floor(next / 58);
106
+ }
107
+ while (carry > 0) {
108
+ digits.push(carry % 58);
109
+ carry = Math.floor(carry / 58);
110
+ }
111
+ }
112
+ for (const byte of input) {
113
+ if (byte !== 0)
114
+ break;
115
+ digits.push(0);
116
+ }
117
+ return digits
118
+ .reverse()
119
+ .map((digit) => alphabet[digit])
120
+ .join('');
121
+ };
122
+ const signatureFor = (publicKey, payload) => base58Encode(deterministicSignature(publicKey, payload));
123
+ const resolveSolanaOptions = (option, persona) => {
124
+ if (option === false) {
125
+ return undefined;
126
+ }
127
+ const input = typeof option === 'object' ? option : {};
128
+ const personaSolana = persona?.solana;
129
+ if (!personaSolana && option !== true && option === undefined) {
130
+ return undefined;
131
+ }
132
+ return {
133
+ chains: input.chains ?? personaSolana?.chains ?? DEFAULT_SOLANA_CHAINS,
134
+ publicKey: input.publicKey ??
135
+ personaSolana?.publicKey ??
136
+ '26qv4GCcx98RihuK3c4T6ozB3J7L6VwCuFVc7Ta2A3Uo',
137
+ methods: input.methods ?? DEFAULT_WALLETCONNECT_SOLANA_METHODS,
138
+ events: input.events ?? DEFAULT_SOLANA_EVENTS,
139
+ };
140
+ };
141
+ const rpcError = (code, message) => {
142
+ const error = new Error(message);
143
+ error.code = code;
144
+ return error;
145
+ };
146
+ const normalizeObjectParams = (params) => {
147
+ const candidate = Array.isArray(params) ? params[0] : params;
148
+ return candidate && typeof candidate === 'object' ? candidate : {};
149
+ };
150
+ const normalizeJsonRpcParams = (params) => {
151
+ if (params === undefined || Array.isArray(params))
152
+ return params;
153
+ if (params && typeof params === 'object')
154
+ return params;
155
+ return [params];
156
+ };
157
+ const assertSolanaPubkey = (params, publicKey) => {
158
+ const requested = params.pubkey;
159
+ if (requested !== undefined && requested !== publicKey) {
160
+ throw rpcError(4100, `The requested Solana account ${String(requested)} is not authorized.`);
161
+ }
162
+ };
163
+ const solanaAccountsResult = (solana) => [
164
+ { pubkey: solana.publicKey },
165
+ ];
166
+ const buildSolanaSignInMessage = (publicKey, input = {}) => {
167
+ const address = input.address ?? publicKey;
168
+ const domain = input.domain ?? 'localhost';
169
+ const lines = [
170
+ `${domain} wants you to sign in with your Solana account:`,
171
+ String(address),
172
+ ];
173
+ if (input.statement !== undefined) {
174
+ lines.push('', String(input.statement));
175
+ }
176
+ const fields = [
177
+ ['URI', input.uri],
178
+ ['Version', input.version],
179
+ ['Chain ID', input.chainId],
180
+ ['Nonce', input.nonce],
181
+ ['Issued At', input.issuedAt],
182
+ ['Expiration Time', input.expirationTime],
183
+ ['Not Before', input.notBefore],
184
+ ['Request ID', input.requestId],
185
+ ];
186
+ for (const [label, value] of fields) {
187
+ if (value !== undefined) {
188
+ lines.push(`${label}: ${String(value)}`);
189
+ }
190
+ }
191
+ if (Array.isArray(input.resources) && input.resources.length > 0) {
192
+ lines.push('Resources:');
193
+ for (const resource of input.resources) {
194
+ lines.push(`- ${String(resource)}`);
195
+ }
196
+ }
197
+ return new TextEncoder().encode(lines.join('\n'));
198
+ };
199
+ const handleSolanaSessionRequest = async (wallet, solana, event, context) => {
200
+ const method = event.params.request.method;
201
+ if (!solana.methods.includes(method)) {
202
+ throw rpcError(4200, `The mock wallet does not support the method "${method}".`);
203
+ }
204
+ const params = normalizeObjectParams(event.params.request.params);
205
+ switch (method) {
206
+ case 'solana_getAccounts': {
207
+ const accounts = (await wallet.handleExternalRequest({ method: 'eth_accounts', params: [] }, context));
208
+ return accounts && accounts.length > 0 ? solanaAccountsResult(solana) : [];
209
+ }
210
+ case 'solana_requestAccounts':
211
+ await wallet.handleExternalRequest({ method, params: [params] }, context);
212
+ return solanaAccountsResult(solana);
213
+ case 'solana_signIn': {
214
+ const input = normalizeObjectParams(params.input ?? params);
215
+ if (input.address !== undefined && input.address !== solana.publicKey) {
216
+ throw rpcError(4100, `The requested Solana account ${String(input.address)} is not authorized.`);
217
+ }
218
+ const signedMessage = buildSolanaSignInMessage(solana.publicKey, input);
219
+ await wallet.handleExternalRequest({
220
+ method,
221
+ params: [{ input, publicKey: solana.publicKey, message: [...signedMessage] }],
222
+ }, context);
223
+ return {
224
+ address: input.address ?? solana.publicKey,
225
+ publicKey: solana.publicKey,
226
+ signedMessage: [...signedMessage],
227
+ signature: signatureFor(solana.publicKey, signedMessage),
228
+ signatureType: 'ed25519',
229
+ };
230
+ }
231
+ case 'solana_signMessage':
232
+ assertSolanaPubkey(params, solana.publicKey);
233
+ await wallet.handleExternalRequest({ method, params: [params] }, context);
234
+ return {
235
+ signature: signatureFor(solana.publicKey, params.message ?? ''),
236
+ };
237
+ case 'solana_signTransaction':
238
+ await wallet.handleExternalRequest({ method, params: [params] }, context);
239
+ return {
240
+ signature: signatureFor(solana.publicKey, params.transaction ?? params),
241
+ ...(typeof params.transaction === 'string' ? { transaction: params.transaction } : {}),
242
+ };
243
+ case 'solana_signAllTransactions': {
244
+ await wallet.handleExternalRequest({ method, params: [params] }, context);
245
+ const transactions = Array.isArray(params.transactions) ? params.transactions : [];
246
+ return {
247
+ transactions,
248
+ signatures: transactions.map((transaction) => signatureFor(solana.publicKey, transaction)),
249
+ };
250
+ }
251
+ case 'solana_signAndSendAllTransactions': {
252
+ await wallet.handleExternalRequest({ method, params: [params] }, context);
253
+ const transactions = Array.isArray(params.transactions) ? params.transactions : [];
254
+ return {
255
+ publicKey: solana.publicKey,
256
+ signatures: transactions.map((transaction) => signatureFor(solana.publicKey, transaction)),
257
+ };
258
+ }
259
+ case 'solana_signAndSendTransaction':
260
+ await wallet.handleExternalRequest({ method, params: [params] }, context);
261
+ return {
262
+ signature: signatureFor(solana.publicKey, params.transaction ?? params),
263
+ };
264
+ default:
265
+ throw rpcError(4200, `The mock wallet does not support the method "${method}".`);
266
+ }
267
+ };
33
268
  /**
34
269
  * The session_request loop, exported for hermetic tests: validates the CAIP
35
270
  * chain against the approved set, switches the wallet when an approved
@@ -42,8 +277,26 @@ export const createSessionRequestHandler = (wallet, config, respond) => {
42
277
  return async (event) => {
43
278
  const { id, topic, params, verifyContext } = event;
44
279
  try {
280
+ const context = config.enforceOrigins
281
+ ? { origin: verifyContext?.verified?.origin }
282
+ : { bypassOriginCheck: true };
283
+ const solanaChain = parseSolanaCaipChainId(params.chainId);
284
+ if (solanaChain) {
285
+ if (!config.solana || !config.solana.chains.includes(solanaChain)) {
286
+ await respond(topic, {
287
+ id,
288
+ jsonrpc: '2.0',
289
+ error: { code: 5100, message: 'Requested chain is not approved for this session.' },
290
+ });
291
+ return;
292
+ }
293
+ const result = await handleSolanaSessionRequest(wallet, config.solana, event, context);
294
+ await respond(topic, { id, jsonrpc: '2.0', result });
295
+ return;
296
+ }
45
297
  const chainId = parseCaipChainId(params.chainId);
46
- if (!chainId || !config.chains.includes(chainId)) {
298
+ const approvedChains = config.getChains?.() ?? config.chains;
299
+ if (!chainId || !approvedChains.includes(chainId)) {
47
300
  await respond(topic, {
48
301
  id,
49
302
  jsonrpc: '2.0',
@@ -54,9 +307,7 @@ export const createSessionRequestHandler = (wallet, config, respond) => {
54
307
  if (chainId !== wallet.currentChainId) {
55
308
  await wallet.switchNetwork(chainId);
56
309
  }
57
- const result = await wallet.handleExternalRequest({ method: params.request.method, params: params.request.params }, config.enforceOrigins
58
- ? { origin: verifyContext?.verified?.origin }
59
- : { bypassOriginCheck: true });
310
+ const result = await wallet.handleExternalRequest({ method: params.request.method, params: normalizeJsonRpcParams(params.request.params) }, context);
60
311
  await respond(topic, { id, jsonrpc: '2.0', result });
61
312
  }
62
313
  catch (error) {
@@ -73,32 +324,140 @@ export const createSessionRequestHandler = (wallet, config, respond) => {
73
324
  }
74
325
  };
75
326
  };
327
+ export const createSessionAuthenticateHandler = (wallet, config, auth) => {
328
+ return async (event) => {
329
+ const { id, params, verifyContext } = event;
330
+ const context = config.enforceOrigins
331
+ ? { origin: verifyContext?.verified?.origin }
332
+ : { bypassOriginCheck: true };
333
+ const requestedChains = Array.isArray(params.authPayload.chains)
334
+ ? params.authPayload.chains.map(parseCaipChainId)
335
+ : [];
336
+ const approvedChains = config.getChains?.() ?? config.chains;
337
+ const unsupported = requestedChains.some((chainId) => !chainId || !approvedChains.includes(chainId));
338
+ if (requestedChains.length === 0 || unsupported) {
339
+ await auth.reject(id, auth.getSdkError('UNSUPPORTED_CHAINS')).catch(() => undefined);
340
+ return;
341
+ }
342
+ try {
343
+ const proposalPayload = {
344
+ origin: verifyContext?.verified?.origin,
345
+ requester: params.requester?.metadata,
346
+ authPayload: params.authPayload,
347
+ };
348
+ const accounts = (await wallet.handleExternalRequest({ method: 'eth_requestAccounts', params: [proposalPayload] }, context));
349
+ const auths = [];
350
+ for (const chainId of requestedChains) {
351
+ const caip = toCaipChainId(chainId);
352
+ for (const account of accounts) {
353
+ const iss = `did:pkh:${caip}:${account}`;
354
+ const message = auth.formatAuthMessage({ request: params.authPayload, iss });
355
+ const signature = (await wallet.handleExternalRequest({ method: 'personal_sign', params: [toHex(message), account] }, context));
356
+ auths.push(auth.buildAuthObject(params.authPayload, { t: 'eip191', s: signature, m: message }, iss));
357
+ }
358
+ }
359
+ const result = await auth.approve(id, auths);
360
+ if (result.session) {
361
+ auth.onSession?.({
362
+ topic: result.session.topic,
363
+ namespaces: result.session.namespaces ?? {},
364
+ peerMetadata: result.session.peer?.metadata ??
365
+ params.requester?.metadata ??
366
+ { name: '', description: '', url: '', icons: [] },
367
+ });
368
+ }
369
+ }
370
+ catch {
371
+ await auth.reject(id, auth.getSdkError('USER_REJECTED')).catch(() => undefined);
372
+ }
373
+ };
374
+ };
375
+ const DEFAULT_URI_SELECTORS = ['wui-qr-code', '[uri^="wc:"]', '[data-uri^="wc:"]', '[href^="wc:"]'];
376
+ const DEFAULT_URI_ATTRIBUTES = ['uri', 'data-uri', 'href', 'value'];
377
+ const DEFAULT_TEXT_SELECTORS = ['textarea', 'input', 'code', 'pre', '[data-wc-uri]'];
378
+ const DEFAULT_COPY_BUTTON_SELECTORS = ['[data-testid="copy-wc2-uri"]'];
379
+ const URI_PROBE_MS = 250;
380
+ const uniq = (values) => [
381
+ ...new Set(values.filter((value) => Boolean(value))),
382
+ ];
383
+ const extractWalletConnectUri = (value) => {
384
+ if (typeof value !== 'string')
385
+ return undefined;
386
+ const trimmed = value.trim();
387
+ const decoded = /wc%3a/i.test(trimmed)
388
+ ? decodeURIComponent(trimmed)
389
+ : trimmed;
390
+ const match = /wc:[^\s"'<>`]+/.exec(decoded);
391
+ return match?.[0];
392
+ };
393
+ const readLocatorText = async (locator) => {
394
+ return locator
395
+ .first()
396
+ .evaluate((element) => {
397
+ if (element instanceof HTMLInputElement ||
398
+ element instanceof HTMLTextAreaElement ||
399
+ element instanceof HTMLSelectElement) {
400
+ return element.value;
401
+ }
402
+ return element.textContent ?? '';
403
+ }, undefined, { timeout: URI_PROBE_MS })
404
+ .catch(() => undefined);
405
+ };
406
+ const readClipboard = async (page) => page
407
+ .evaluate(() => navigator.clipboard?.readText?.())
408
+ .catch(() => undefined);
76
409
  /**
77
- * Polls the AppKit/W3M modal for the pairing URI: Playwright CSS locators
78
- * pierce the shadow DOM, and AppKit's w3m-connecting-wc-qrcode renders
79
- * `<wui-qr-code uri=...>` as a real DOM attribute (the same contract
80
- * Reown's own laboratory tests read). For non-AppKit modals pass a
81
- * `selector` or use the connect() `getUri` hook.
410
+ * Polls common WalletConnect QR/modal surfaces for a pairing URI. Defaults
411
+ * keep the AppKit/W3M `wui-qr-code[uri]` contract, then fall back to generic
412
+ * URI attributes, text/value-bearing elements, and AppKit's copy button.
413
+ * For unusual modals pass selectors/textSelectors/copyButtonSelector or use
414
+ * the connect() getUri hook.
82
415
  */
83
416
  export async function getWalletConnectUri(page, options = {}) {
84
417
  const timeoutMs = options.timeoutMs ?? 15_000;
85
- const selector = options.selector ?? 'wui-qr-code';
418
+ const selectors = uniq([
419
+ options.selector,
420
+ ...(options.selectors ?? []),
421
+ ...DEFAULT_URI_SELECTORS,
422
+ ]);
423
+ const attributes = options.attributes ?? DEFAULT_URI_ATTRIBUTES;
424
+ const textSelectors = uniq([...(options.textSelectors ?? []), ...DEFAULT_TEXT_SELECTORS]);
425
+ const copyButtonSelectors = uniq([
426
+ options.copyButtonSelector,
427
+ ...(options.copyButtonSelectors ?? []),
428
+ ...DEFAULT_COPY_BUTTON_SELECTORS,
429
+ ]);
86
430
  const deadline = Date.now() + timeoutMs;
87
431
  while (Date.now() < deadline) {
88
- // Bounded per attempt: getAttribute would otherwise auto-wait for the
89
- // element with Playwright's default timeout.
90
- const uri = await page
91
- .locator(selector)
92
- .first()
93
- .getAttribute('uri', { timeout: 250 })
94
- .catch(() => null);
95
- if (uri?.startsWith('wc:')) {
96
- return uri;
432
+ for (const selector of selectors) {
433
+ const locator = page.locator(selector).first();
434
+ for (const attribute of attributes) {
435
+ const uri = extractWalletConnectUri(await locator.getAttribute(attribute, { timeout: URI_PROBE_MS }).catch(() => null));
436
+ if (uri)
437
+ return uri;
438
+ }
439
+ const uri = extractWalletConnectUri(await readLocatorText(locator));
440
+ if (uri)
441
+ return uri;
442
+ }
443
+ for (const selector of textSelectors) {
444
+ const uri = extractWalletConnectUri(await readLocatorText(page.locator(selector)));
445
+ if (uri)
446
+ return uri;
447
+ }
448
+ for (const selector of copyButtonSelectors) {
449
+ const clicked = await page.locator(selector).first().click({ timeout: URI_PROBE_MS }).then(() => true, () => false);
450
+ if (!clicked)
451
+ continue;
452
+ const uri = extractWalletConnectUri(await readClipboard(page));
453
+ if (uri)
454
+ return uri;
97
455
  }
98
456
  await new Promise((resolve) => setTimeout(resolve, 100));
99
457
  }
100
- throw new Error(`Timed out after ${timeoutMs}ms waiting for a wc: pairing URI on "${selector}". ` +
101
- 'For non-AppKit modals pass { selector } or supply a getUri(page) hook to connect().');
458
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for a wc: pairing URI. ` +
459
+ `Probed selectors: ${[...selectors, ...textSelectors].join(', ')}. ` +
460
+ 'For unusual modals pass selector/selectors/textSelectors/copyButtonSelector or supply a getUri(page) hook to connect().');
102
461
  }
103
462
  export class WalletConnectWallet {
104
463
  /** Underlying SignClient — escape hatch for protocol-level access. */
@@ -106,11 +465,14 @@ export class WalletConnectWallet {
106
465
  wallet;
107
466
  utils;
108
467
  chains;
468
+ evm;
109
469
  methods;
110
470
  events;
471
+ solana;
111
472
  enforceOrigins;
112
473
  sessionList = [];
113
474
  requestHandler;
475
+ authHandler;
114
476
  deleteHandler;
115
477
  unsubscribeProviderEvents;
116
478
  closed = false;
@@ -118,16 +480,40 @@ export class WalletConnectWallet {
118
480
  this.client = client;
119
481
  this.utils = utils;
120
482
  this.wallet = options.wallet;
483
+ const persona = options.persona ? createWalletPersona(options.persona) : undefined;
484
+ this.evm = options.evm ?? persona?.evm !== false;
121
485
  this.chains = (options.chains ?? [options.wallet.currentChainId]).map((chainId) => toHex(typeof chainId === 'number' ? chainId : BigInt(chainId)));
122
- this.methods = options.methods ?? DEFAULT_WALLETCONNECT_METHODS;
486
+ this.methods =
487
+ options.methods ??
488
+ (persona?.flags?.isCoinbaseWallet === true
489
+ ? [...DEFAULT_WALLETCONNECT_METHODS, ...DEFAULT_WALLETCONNECT_COINBASE_METHODS]
490
+ : DEFAULT_WALLETCONNECT_METHODS);
123
491
  this.events = options.events ?? DEFAULT_EVENTS;
492
+ this.solana = resolveSolanaOptions(options.solana, persona);
124
493
  this.enforceOrigins = options.enforceOrigins ?? true;
125
- // INVARIANT: never register a 'session_authenticate' listener. With zero
126
- // listeners, sign-client routes One-Click Auth (SIWE) dapps through the
127
- // wc_sessionPropose fallback — a plain session plus personal_sign —
128
- // which this wallet handles. Subscribing would suppress that fallback.
129
- this.requestHandler = createSessionRequestHandler(this.wallet, { chains: this.chains, enforceOrigins: this.enforceOrigins }, (topic, response) => this.client.respond({ topic, response }));
494
+ this.requestHandler = createSessionRequestHandler(this.wallet, {
495
+ chains: this.chains,
496
+ getChains: () => this.chains,
497
+ enforceOrigins: this.enforceOrigins,
498
+ solana: this.solana,
499
+ }, (topic, response) => this.client.respond({ topic, response }));
130
500
  client.on('session_request', this.requestHandler);
501
+ // One-Click Auth / SIWE uses a separate WC event and suppresses
502
+ // sign-client's plain session_proposal fallback once a listener exists.
503
+ // Keep the fallback available through sessionAuthenticate: false.
504
+ if (options.sessionAuthenticate !== false && this.evm) {
505
+ this.authHandler = createSessionAuthenticateHandler(this.wallet, { chains: this.chains, getChains: () => this.chains, enforceOrigins: this.enforceOrigins }, {
506
+ approve: (id, auths) => this.client.approveSessionAuthenticate({ id, auths }),
507
+ buildAuthObject: (requestPayload, signature, iss) => this.utils.buildAuthObject(requestPayload, signature, iss),
508
+ formatAuthMessage: (args) => this.client.formatAuthMessage(args),
509
+ getSdkError: (code) => this.utils.getSdkError(code),
510
+ onSession: (session) => {
511
+ this.sessionList.push(session);
512
+ },
513
+ reject: (id, reason) => this.client.rejectSessionAuthenticate({ id, reason }),
514
+ });
515
+ client.on('session_authenticate', this.authHandler);
516
+ }
131
517
  this.deleteHandler = ({ topic }) => {
132
518
  const index = this.sessionList.findIndex((session) => session.topic === topic);
133
519
  if (index !== -1) {
@@ -160,11 +546,16 @@ export class WalletConnectWallet {
160
546
  const client = await SignClient.init({
161
547
  projectId: options.projectId,
162
548
  ...(options.relayUrl ? { relayUrl: options.relayUrl } : {}),
549
+ customStoragePrefix: options.customStoragePrefix ??
550
+ `web3-tester-wallet-${walletConnectStoragePrefixCounter++}`,
163
551
  metadata: {
164
552
  name: 'web3-tester Wallet',
165
553
  description: 'Headless WalletConnect wallet for E2E tests',
166
554
  url: 'https://github.com/AndyMarigoldLabs/web3-tester',
167
555
  icons: [],
556
+ ...(options.persona
557
+ ? walletConnectMetadataForPersona(createWalletPersona(options.persona))
558
+ : {}),
168
559
  ...options.metadata,
169
560
  },
170
561
  // A request parked by holdNextRequest must not starve delivery of
@@ -190,7 +581,7 @@ export class WalletConnectWallet {
190
581
  const proposal = await new Promise((resolve, reject) => {
191
582
  const cleanup = () => {
192
583
  clearTimeout(timer);
193
- (this.client.off ?? this.client.removeListener)?.call(this.client, 'session_proposal', handler);
584
+ removeWalletConnectListener(this.client, 'session_proposal', handler);
194
585
  };
195
586
  const timer = setTimeout(() => {
196
587
  cleanup();
@@ -213,19 +604,21 @@ export class WalletConnectWallet {
213
604
  // simulateRejection, and holds all apply with zero new machinery.
214
605
  let accounts;
215
606
  try {
216
- accounts = (await this.wallet.handleExternalRequest({
217
- method: 'eth_requestAccounts',
218
- params: [
219
- {
220
- origin: proposal.verifyContext?.verified?.origin,
221
- proposer: proposal.params?.proposer?.metadata,
222
- requiredNamespaces: proposal.params?.requiredNamespaces,
223
- optionalNamespaces: proposal.params?.optionalNamespaces,
224
- },
225
- ],
226
- }, this.enforceOrigins
607
+ const context = this.enforceOrigins
227
608
  ? { origin: proposal.verifyContext?.verified?.origin }
228
- : { bypassOriginCheck: true }));
609
+ : { bypassOriginCheck: true };
610
+ const proposalPayload = {
611
+ origin: proposal.verifyContext?.verified?.origin,
612
+ proposer: proposal.params?.proposer?.metadata,
613
+ requiredNamespaces: proposal.params?.requiredNamespaces,
614
+ optionalNamespaces: proposal.params?.optionalNamespaces,
615
+ };
616
+ accounts = this.evm
617
+ ? (await this.wallet.handleExternalRequest({ method: 'eth_requestAccounts', params: [proposalPayload] }, context))
618
+ : [];
619
+ if (!this.evm && this.solana) {
620
+ await this.wallet.handleExternalRequest({ method: 'solana_requestAccounts', params: [proposalPayload] }, context);
621
+ }
229
622
  }
230
623
  catch (error) {
231
624
  await this.client
@@ -235,16 +628,29 @@ export class WalletConnectWallet {
235
628
  }
236
629
  let namespaces;
237
630
  try {
631
+ const supportedNamespaces = {};
632
+ if (this.evm) {
633
+ supportedNamespaces.eip155 = {
634
+ chains: this.chains.map(toCaipChainId),
635
+ methods: [...this.methods],
636
+ events: [...this.events],
637
+ accounts: this.chains.flatMap((chainId) => accounts.map((account) => `${toCaipChainId(chainId)}:${account}`)),
638
+ };
639
+ }
640
+ if (this.solana) {
641
+ supportedNamespaces.solana = {
642
+ chains: [...this.solana.chains],
643
+ methods: [...this.solana.methods],
644
+ events: [...this.solana.events],
645
+ accounts: this.solana.chains.map((chain) => `${chain}:${this.solana.publicKey}`),
646
+ };
647
+ }
648
+ if (Object.keys(supportedNamespaces).length === 0) {
649
+ throw new Error('No WalletConnect namespaces are enabled.');
650
+ }
238
651
  namespaces = this.utils.buildApprovedNamespaces({
239
652
  proposal: proposal.params,
240
- supportedNamespaces: {
241
- eip155: {
242
- chains: this.chains.map(toCaipChainId),
243
- methods: [...this.methods],
244
- events: [...this.events],
245
- accounts: this.chains.flatMap((chainId) => accounts.map((account) => `${toCaipChainId(chainId)}:${account}`)),
246
- },
247
- },
653
+ supportedNamespaces,
248
654
  });
249
655
  }
250
656
  catch (error) {
@@ -295,8 +701,11 @@ export class WalletConnectWallet {
295
701
  new Promise((resolve) => setTimeout(resolve, 5_000)),
296
702
  ]).catch(() => undefined);
297
703
  this.unsubscribeProviderEvents();
298
- (this.client.off ?? this.client.removeListener)?.call(this.client, 'session_request', this.requestHandler);
299
- (this.client.off ?? this.client.removeListener)?.call(this.client, 'session_delete', this.deleteHandler);
704
+ removeWalletConnectListener(this.client, 'session_request', this.requestHandler);
705
+ removeWalletConnectListener(this.client, 'session_delete', this.deleteHandler);
706
+ if (this.authHandler) {
707
+ removeWalletConnectListener(this.client, 'session_authenticate', this.authHandler);
708
+ }
300
709
  await this.client.core?.relayer?.transportClose?.().catch(() => undefined);
301
710
  this.client.core?.heartbeat?.stop?.();
302
711
  }
@@ -307,28 +716,30 @@ export class WalletConnectWallet {
307
716
  for (const session of [...this.sessionList]) {
308
717
  try {
309
718
  if (event === 'chainChanged') {
719
+ const namespaces = session.namespaces;
720
+ const eip155 = namespaces.eip155;
721
+ if (!eip155)
722
+ continue;
310
723
  const chainId = payload;
724
+ const caip = toCaipChainId(chainId);
311
725
  if (!this.chains.includes(chainId)) {
312
- // Extend the session namespace first (MetaMask-mobile behavior).
313
726
  this.chains = [...this.chains, chainId];
314
- const namespaces = session.namespaces;
315
- const eip155 = namespaces.eip155;
316
- if (eip155) {
317
- const caip = toCaipChainId(chainId);
318
- const extended = {
319
- ...namespaces,
320
- eip155: {
321
- ...eip155,
322
- chains: [...(eip155.chains ?? []), caip],
323
- accounts: [
324
- ...(eip155.accounts ?? []),
325
- ...this.wallet.currentAccounts.map((account) => `${caip}:${account}`),
326
- ],
327
- },
328
- };
329
- await this.client.update({ topic: session.topic, namespaces: extended });
330
- session.namespaces = extended;
331
- }
727
+ }
728
+ if (!(eip155.chains ?? []).includes(caip)) {
729
+ // Extend the session namespace first (MetaMask-mobile behavior).
730
+ const extended = {
731
+ ...namespaces,
732
+ eip155: {
733
+ ...eip155,
734
+ chains: uniq([...(eip155.chains ?? []), caip]),
735
+ accounts: uniq([
736
+ ...(eip155.accounts ?? []),
737
+ ...this.wallet.currentAccounts.map((account) => `${caip}:${account}`),
738
+ ]),
739
+ },
740
+ };
741
+ await this.client.update({ topic: session.topic, namespaces: extended });
742
+ session.namespaces = extended;
332
743
  }
333
744
  await this.client.emit({
334
745
  topic: session.topic,
@@ -337,11 +748,40 @@ export class WalletConnectWallet {
337
748
  });
338
749
  }
339
750
  else if (event === 'accountsChanged') {
340
- await this.client.emit({
341
- topic: session.topic,
342
- event: { name: 'accountsChanged', data: payload },
343
- chainId: toCaipChainId(this.wallet.currentChainId),
344
- });
751
+ const namespaces = session.namespaces;
752
+ if (namespaces.eip155) {
753
+ const accounts = Array.isArray(payload) ? payload.filter(isAddressString) : [];
754
+ if (accounts.length > 0) {
755
+ const caipChains = (namespaces.eip155.chains ?? this.chains.map(toCaipChainId)).filter((chainId) => typeof chainId === 'string');
756
+ const nextAccounts = eip155AccountsFor(caipChains, accounts);
757
+ if (!arraysEqual(namespaces.eip155.accounts, nextAccounts)) {
758
+ const updated = {
759
+ ...namespaces,
760
+ eip155: {
761
+ ...namespaces.eip155,
762
+ accounts: nextAccounts,
763
+ },
764
+ };
765
+ await this.client.update({ topic: session.topic, namespaces: updated });
766
+ session.namespaces = updated;
767
+ }
768
+ }
769
+ await this.client.emit({
770
+ topic: session.topic,
771
+ event: { name: 'accountsChanged', data: payload },
772
+ chainId: toCaipChainId(this.wallet.currentChainId),
773
+ });
774
+ }
775
+ if (this.solana && namespaces.solana) {
776
+ const accounts = Array.isArray(payload) && payload.length > 0 ? [this.solana.publicKey] : [];
777
+ for (const chainId of namespaces.solana.chains ?? this.solana.chains) {
778
+ await this.client.emit({
779
+ topic: session.topic,
780
+ event: { name: 'accountsChanged', data: accounts },
781
+ chainId,
782
+ });
783
+ }
784
+ }
345
785
  }
346
786
  else if (event === 'disconnect') {
347
787
  await this.client