@marigoldlabs/web3-tester 0.1.2 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/.env.example +26 -17
  2. package/LICENSE +21 -0
  3. package/README.md +480 -48
  4. package/dist/anvil.d.ts +90 -2
  5. package/dist/anvil.d.ts.map +1 -1
  6. package/dist/anvil.js +215 -13
  7. package/dist/anvil.js.map +1 -1
  8. package/dist/benchmark.d.ts +55 -0
  9. package/dist/benchmark.d.ts.map +1 -0
  10. package/dist/benchmark.js +168 -0
  11. package/dist/benchmark.js.map +1 -0
  12. package/dist/contracts/test-erc20.d.ts +227 -0
  13. package/dist/contracts/test-erc20.d.ts.map +1 -0
  14. package/dist/contracts/test-erc20.js +8 -0
  15. package/dist/contracts/test-erc20.js.map +1 -0
  16. package/dist/erc20.d.ts +38 -0
  17. package/dist/erc20.d.ts.map +1 -0
  18. package/dist/erc20.js +229 -0
  19. package/dist/erc20.js.map +1 -0
  20. package/dist/fixtures.d.ts +44 -2
  21. package/dist/fixtures.d.ts.map +1 -1
  22. package/dist/fixtures.js +162 -17
  23. package/dist/fixtures.js.map +1 -1
  24. package/dist/index.d.ts +26 -5
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +11 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/injected-provider.d.ts.map +1 -1
  29. package/dist/injected-provider.js +863 -77
  30. package/dist/injected-provider.js.map +1 -1
  31. package/dist/live-fixtures.d.ts +32 -3
  32. package/dist/live-fixtures.d.ts.map +1 -1
  33. package/dist/live-fixtures.js +64 -27
  34. package/dist/live-fixtures.js.map +1 -1
  35. package/dist/matchers.d.ts +90 -0
  36. package/dist/matchers.d.ts.map +1 -0
  37. package/dist/matchers.js +268 -0
  38. package/dist/matchers.js.map +1 -0
  39. package/dist/metamask-extension.d.ts +34 -0
  40. package/dist/metamask-extension.d.ts.map +1 -0
  41. package/dist/metamask-extension.js +97 -0
  42. package/dist/metamask-extension.js.map +1 -0
  43. package/dist/mock-wallet-controller.d.ts +354 -4
  44. package/dist/mock-wallet-controller.d.ts.map +1 -1
  45. package/dist/mock-wallet-controller.js +1444 -58
  46. package/dist/mock-wallet-controller.js.map +1 -1
  47. package/dist/private-key-rpc-client.d.ts +1744 -2
  48. package/dist/private-key-rpc-client.d.ts.map +1 -1
  49. package/dist/private-key-rpc-client.js +245 -30
  50. package/dist/private-key-rpc-client.js.map +1 -1
  51. package/dist/real-wallet-cache.d.ts +103 -0
  52. package/dist/real-wallet-cache.d.ts.map +1 -0
  53. package/dist/real-wallet-cache.js +331 -0
  54. package/dist/real-wallet-cache.js.map +1 -0
  55. package/dist/real-wallet-extension-fixtures.d.ts +35 -0
  56. package/dist/real-wallet-extension-fixtures.d.ts.map +1 -0
  57. package/dist/real-wallet-extension-fixtures.js +96 -0
  58. package/dist/real-wallet-extension-fixtures.js.map +1 -0
  59. package/dist/real-wallet-extension.d.ts +86 -0
  60. package/dist/real-wallet-extension.d.ts.map +1 -0
  61. package/dist/real-wallet-extension.js +245 -0
  62. package/dist/real-wallet-extension.js.map +1 -0
  63. package/dist/real-wallet-fixtures.d.ts +52 -0
  64. package/dist/real-wallet-fixtures.d.ts.map +1 -0
  65. package/dist/real-wallet-fixtures.js +88 -0
  66. package/dist/real-wallet-fixtures.js.map +1 -0
  67. package/dist/real-wallet.d.ts +119 -19
  68. package/dist/real-wallet.d.ts.map +1 -1
  69. package/dist/real-wallet.js +1656 -144
  70. package/dist/real-wallet.js.map +1 -1
  71. package/dist/safe.d.ts +311 -0
  72. package/dist/safe.d.ts.map +1 -0
  73. package/dist/safe.js +743 -0
  74. package/dist/safe.js.map +1 -0
  75. package/dist/transactions.d.ts +118 -0
  76. package/dist/transactions.d.ts.map +1 -0
  77. package/dist/transactions.js +207 -0
  78. package/dist/transactions.js.map +1 -0
  79. package/dist/types.d.ts +36 -1
  80. package/dist/types.d.ts.map +1 -1
  81. package/dist/wallet-personas.d.ts +99 -0
  82. package/dist/wallet-personas.d.ts.map +1 -0
  83. package/dist/wallet-personas.js +666 -0
  84. package/dist/wallet-personas.js.map +1 -0
  85. package/dist/walletconnect.d.ts +373 -0
  86. package/dist/walletconnect.d.ts.map +1 -0
  87. package/dist/walletconnect.js +799 -0
  88. package/dist/walletconnect.js.map +1 -0
  89. package/examples/live-sepolia.spec.ts +20 -1
  90. package/examples/playwright.config.ts +8 -0
  91. package/package.json +90 -8
  92. package/docs/API.md +0 -223
  93. package/docs/ARCHITECTURE.md +0 -81
  94. package/docs/CONSUMING_FROM_FJORD.md +0 -123
  95. package/docs/FJORD_LIVE_QA.md +0 -87
  96. package/docs/RELEASE_CHECKLIST.md +0 -55
@@ -0,0 +1,799 @@
1
+ import { toHex } from 'viem';
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
+ };
11
+ export const DEFAULT_WALLETCONNECT_METHODS = [
12
+ 'eth_sendTransaction',
13
+ 'eth_sendRawTransaction',
14
+ 'personal_sign',
15
+ 'eth_sign',
16
+ 'eth_signTypedData',
17
+ 'eth_signTypedData_v3',
18
+ 'eth_signTypedData_v4',
19
+ 'eth_accounts',
20
+ 'eth_requestAccounts',
21
+ 'eth_chainId',
22
+ 'wallet_switchEthereumChain',
23
+ 'wallet_addEthereumChain',
24
+ 'wallet_getPermissions',
25
+ 'wallet_requestPermissions',
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',
38
+ ];
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
+ ];
56
+ // Injectable so the missing-optional-peer hint is hermetically testable
57
+ // (the peers are devDependencies here, so they always resolve in-repo).
58
+ let loadModule = (specifier) => import(specifier);
59
+ let walletConnectStoragePrefixCounter = 0;
60
+ /** @internal Test hook: stub the dynamic importer; call with no args to restore. */
61
+ export const __setWalletConnectModuleLoader = (loader) => {
62
+ loadModule = loader ?? ((specifier) => import(specifier));
63
+ };
64
+ const parseCaipChainId = (caip) => {
65
+ const match = typeof caip === 'string' ? /^eip155:(\d+)$/.exec(caip) : null;
66
+ return match ? toHex(BigInt(match[1])) : undefined;
67
+ };
68
+ const parseSolanaCaipChainId = (caip) => typeof caip === 'string' && /^solana:[a-z0-9-]+$/i.test(caip) ? caip : undefined;
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
+ };
268
+ /**
269
+ * The session_request loop, exported for hermetic tests: validates the CAIP
270
+ * chain against the approved set, switches the wallet when an approved
271
+ * non-active chain is requested (single-active-chain semantics, like a
272
+ * mobile wallet), and dispatches through the controller's gating. Errors map
273
+ * through serializeRpcError, so 4001/4100/4200/4902/-32602 cross the relay
274
+ * verbatim.
275
+ */
276
+ export const createSessionRequestHandler = (wallet, config, respond) => {
277
+ return async (event) => {
278
+ const { id, topic, params, verifyContext } = event;
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
+ }
297
+ const chainId = parseCaipChainId(params.chainId);
298
+ const approvedChains = config.getChains?.() ?? config.chains;
299
+ if (!chainId || !approvedChains.includes(chainId)) {
300
+ await respond(topic, {
301
+ id,
302
+ jsonrpc: '2.0',
303
+ error: { code: 5100, message: 'Requested chain is not approved for this session.' },
304
+ });
305
+ return;
306
+ }
307
+ if (chainId !== wallet.currentChainId) {
308
+ await wallet.switchNetwork(chainId);
309
+ }
310
+ const result = await wallet.handleExternalRequest({ method: params.request.method, params: normalizeJsonRpcParams(params.request.params) }, context);
311
+ await respond(topic, { id, jsonrpc: '2.0', result });
312
+ }
313
+ catch (error) {
314
+ const serialized = serializeRpcError(error);
315
+ await respond(topic, {
316
+ id,
317
+ jsonrpc: '2.0',
318
+ error: {
319
+ code: serialized.code,
320
+ message: serialized.message,
321
+ ...(typeof serialized.data === 'string' ? { data: serialized.data } : {}),
322
+ },
323
+ });
324
+ }
325
+ };
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);
409
+ /**
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.
415
+ */
416
+ export async function getWalletConnectUri(page, options = {}) {
417
+ const timeoutMs = options.timeoutMs ?? 15_000;
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
+ ]);
430
+ const deadline = Date.now() + timeoutMs;
431
+ while (Date.now() < deadline) {
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;
455
+ }
456
+ await new Promise((resolve) => setTimeout(resolve, 100));
457
+ }
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().');
461
+ }
462
+ export class WalletConnectWallet {
463
+ /** Underlying SignClient — escape hatch for protocol-level access. */
464
+ client;
465
+ wallet;
466
+ utils;
467
+ chains;
468
+ evm;
469
+ methods;
470
+ events;
471
+ solana;
472
+ enforceOrigins;
473
+ sessionList = [];
474
+ requestHandler;
475
+ authHandler;
476
+ deleteHandler;
477
+ unsubscribeProviderEvents;
478
+ closed = false;
479
+ constructor(client, utils, options) {
480
+ this.client = client;
481
+ this.utils = utils;
482
+ this.wallet = options.wallet;
483
+ const persona = options.persona ? createWalletPersona(options.persona) : undefined;
484
+ this.evm = options.evm ?? persona?.evm !== false;
485
+ this.chains = (options.chains ?? [options.wallet.currentChainId]).map((chainId) => toHex(typeof chainId === 'number' ? chainId : BigInt(chainId)));
486
+ this.methods =
487
+ options.methods ??
488
+ (persona?.flags?.isCoinbaseWallet === true
489
+ ? [...DEFAULT_WALLETCONNECT_METHODS, ...DEFAULT_WALLETCONNECT_COINBASE_METHODS]
490
+ : DEFAULT_WALLETCONNECT_METHODS);
491
+ this.events = options.events ?? DEFAULT_EVENTS;
492
+ this.solana = resolveSolanaOptions(options.solana, persona);
493
+ this.enforceOrigins = options.enforceOrigins ?? true;
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 }));
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
+ }
517
+ this.deleteHandler = ({ topic }) => {
518
+ const index = this.sessionList.findIndex((session) => session.topic === topic);
519
+ if (index !== -1) {
520
+ this.sessionList.splice(index, 1);
521
+ }
522
+ };
523
+ client.on('session_delete', this.deleteHandler);
524
+ this.unsubscribeProviderEvents = this.wallet.onProviderEvent((event, payload) => {
525
+ void this.forwardProviderEvent(event, payload);
526
+ });
527
+ }
528
+ /**
529
+ * Async factory: dynamically imports the optional peers and throws an
530
+ * install hint when they are missing.
531
+ */
532
+ static async create(options) {
533
+ let signClientModule;
534
+ let utils;
535
+ try {
536
+ signClientModule = (await loadModule('@walletconnect/sign-client'));
537
+ utils = (await loadModule('@walletconnect/utils'));
538
+ }
539
+ catch (error) {
540
+ throw new Error("The './walletconnect' module requires optional peer dependencies. " +
541
+ 'Install with: npm i -D @walletconnect/sign-client @walletconnect/utils @walletconnect/types', { cause: error });
542
+ }
543
+ const SignClient = (signClientModule.SignClient ??
544
+ signClientModule.default ??
545
+ signClientModule);
546
+ const client = await SignClient.init({
547
+ projectId: options.projectId,
548
+ ...(options.relayUrl ? { relayUrl: options.relayUrl } : {}),
549
+ customStoragePrefix: options.customStoragePrefix ??
550
+ `web3-tester-wallet-${walletConnectStoragePrefixCounter++}`,
551
+ metadata: {
552
+ name: 'web3-tester Wallet',
553
+ description: 'Headless WalletConnect wallet for E2E tests',
554
+ url: 'https://github.com/AndyMarigoldLabs/web3-tester',
555
+ icons: [],
556
+ ...(options.persona
557
+ ? walletConnectMetadataForPersona(createWalletPersona(options.persona))
558
+ : {}),
559
+ ...options.metadata,
560
+ },
561
+ // A request parked by holdNextRequest must not starve delivery of
562
+ // subsequent session_requests (sign-client serializes them by default).
563
+ signConfig: { disableRequestQueue: true },
564
+ // Nothing touches disk: the SDK default would persist ./walletconnect.db.
565
+ ...(options.storage ? { storage: options.storage } : { storageOptions: { database: ':memory:' } }),
566
+ });
567
+ return new WalletConnectWallet(client, utils, options);
568
+ }
569
+ get sessions() {
570
+ return [...this.sessionList];
571
+ }
572
+ /**
573
+ * Pairs with a wc: URI, gates the session proposal through the controller
574
+ * (as a synthetic eth_requestAccounts — approveNext('eth_requestAccounts')
575
+ * arms it; the match callback receives the proposal payload), builds the
576
+ * approved namespaces, and settles the session.
577
+ */
578
+ async pair(options) {
579
+ const timeoutMs = options.timeoutMs ?? 30_000;
580
+ const { topic: pairingTopic } = this.utils.parseUri(options.uri);
581
+ const proposal = await new Promise((resolve, reject) => {
582
+ const cleanup = () => {
583
+ clearTimeout(timer);
584
+ removeWalletConnectListener(this.client, 'session_proposal', handler);
585
+ };
586
+ const timer = setTimeout(() => {
587
+ cleanup();
588
+ reject(new Error(`Timed out after ${timeoutMs}ms waiting for the session proposal.`));
589
+ }, timeoutMs);
590
+ const handler = (event) => {
591
+ if (event.params?.pairingTopic && event.params.pairingTopic !== pairingTopic) {
592
+ return;
593
+ }
594
+ cleanup();
595
+ resolve(event);
596
+ };
597
+ this.client.on('session_proposal', handler);
598
+ this.client.pair({ uri: options.uri }).catch((error) => {
599
+ cleanup();
600
+ reject(error instanceof Error ? error : new Error(String(error)));
601
+ });
602
+ });
603
+ // Gate the connect through the controller. Deny-by-default live mode,
604
+ // simulateRejection, and holds all apply with zero new machinery.
605
+ let accounts;
606
+ try {
607
+ const context = this.enforceOrigins
608
+ ? { origin: proposal.verifyContext?.verified?.origin }
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
+ }
622
+ }
623
+ catch (error) {
624
+ await this.client
625
+ .reject({ id: proposal.id, reason: this.utils.getSdkError('USER_REJECTED') })
626
+ .catch(() => undefined);
627
+ throw error;
628
+ }
629
+ let namespaces;
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
+ }
651
+ namespaces = this.utils.buildApprovedNamespaces({
652
+ proposal: proposal.params,
653
+ supportedNamespaces,
654
+ });
655
+ }
656
+ catch (error) {
657
+ await this.client
658
+ .reject({ id: proposal.id, reason: this.utils.getSdkError('UNSUPPORTED_CHAINS') })
659
+ .catch(() => undefined);
660
+ throw new Error("Could not satisfy the dapp's requested namespaces — pass the chains it needs in " +
661
+ `WalletConnectWalletOptions.chains. ${error instanceof Error ? error.message : String(error)}`, { cause: error });
662
+ }
663
+ const { topic, acknowledged } = await this.client.approve({ id: proposal.id, namespaces });
664
+ const settled = await acknowledged();
665
+ const session = {
666
+ topic,
667
+ namespaces: settled?.namespaces ?? namespaces,
668
+ peerMetadata: proposal.params?.proposer?.metadata ??
669
+ { name: '', description: '', url: '', icons: [] },
670
+ };
671
+ this.sessionList.push(session);
672
+ return session;
673
+ }
674
+ /** Convenience: extract the URI from the dapp's modal, then pair(). */
675
+ async connect(page, options = {}) {
676
+ const uri = options.getUri ? await options.getUri(page) : await getWalletConnectUri(page, options);
677
+ return this.pair({ uri, timeoutMs: options.timeoutMs });
678
+ }
679
+ /** Wallet-initiated disconnect; all sessions when topic is omitted. */
680
+ async disconnect(topic) {
681
+ const targets = this.sessionList.filter((session) => !topic || session.topic === topic);
682
+ for (const session of targets) {
683
+ await this.client
684
+ .disconnect({ topic: session.topic, reason: this.utils.getSdkError('USER_DISCONNECTED') })
685
+ .catch(() => undefined);
686
+ this.deleteHandler({ topic: session.topic });
687
+ }
688
+ }
689
+ /**
690
+ * Best-effort teardown: disconnect sessions (5s cap), unsubscribe the
691
+ * controller listener, close the relay transport, stop the heartbeat.
692
+ * Always call in finally / fixture teardown.
693
+ */
694
+ async close() {
695
+ if (this.closed) {
696
+ return;
697
+ }
698
+ this.closed = true;
699
+ await Promise.race([
700
+ this.disconnect(),
701
+ new Promise((resolve) => setTimeout(resolve, 5_000)),
702
+ ]).catch(() => undefined);
703
+ this.unsubscribeProviderEvents();
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
+ }
709
+ await this.client.core?.relayer?.transportClose?.().catch(() => undefined);
710
+ this.client.core?.heartbeat?.stop?.();
711
+ }
712
+ // Relay round-trips happen here, OFF the controller's await path — the
713
+ // onProviderEvent dispatch is fire-and-forget, so a dead relay can never
714
+ // hang wallet.switchNetwork()/disconnect().
715
+ async forwardProviderEvent(event, payload) {
716
+ for (const session of [...this.sessionList]) {
717
+ try {
718
+ if (event === 'chainChanged') {
719
+ const namespaces = session.namespaces;
720
+ const eip155 = namespaces.eip155;
721
+ if (!eip155)
722
+ continue;
723
+ const chainId = payload;
724
+ const caip = toCaipChainId(chainId);
725
+ if (!this.chains.includes(chainId)) {
726
+ this.chains = [...this.chains, chainId];
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;
743
+ }
744
+ await this.client.emit({
745
+ topic: session.topic,
746
+ event: { name: 'chainChanged', data: Number(BigInt(chainId)) },
747
+ chainId: toCaipChainId(chainId),
748
+ });
749
+ }
750
+ else if (event === 'accountsChanged') {
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
+ }
785
+ }
786
+ else if (event === 'disconnect') {
787
+ await this.client
788
+ .disconnect({ topic: session.topic, reason: this.utils.getSdkError('USER_DISCONNECTED') })
789
+ .catch(() => undefined);
790
+ this.deleteHandler({ topic: session.topic });
791
+ }
792
+ }
793
+ catch {
794
+ // Relay errors must never break wallet state transitions.
795
+ }
796
+ }
797
+ }
798
+ }
799
+ //# sourceMappingURL=walletconnect.js.map