@farmslot/adapter-web 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -529,8 +529,12 @@ async function withProbeLock(
529
529
  }
530
530
  }
531
531
 
532
- // Probes in flight, so a CLI watchdog can stop their browsers before exiting.
533
- const activeProbeProfiles = new Set();
532
+ // Probes in flight (profile -> spawned browser pid, or null for LaunchServices),
533
+ // so a watchdog, a signal or process exit can stop their browsers first.
534
+ const activeProbes = new Map();
535
+ const PROBE_PROFILE_PREFIX = 'farmslot-browser-probe-';
536
+ const PROBE_PROFILE_OWNER = new RegExp(`^${PROBE_PROFILE_PREFIX}(\\d+)-`, 'u');
537
+ const PROBE_SIGNALS = ['SIGTERM', 'SIGINT', 'SIGHUP', 'SIGQUIT'];
534
538
 
535
539
  function profilePids(profile) {
536
540
  try {
@@ -556,6 +560,72 @@ function killProfile(profile) {
556
560
  }
557
561
  }
558
562
 
563
+ // Synchronous, so it also runs from 'exit' and signal handlers. The browser is
564
+ // outside the caller's process group (LaunchServices parents it; a spawned
565
+ // probe is detached), so a group kill of the caller never reaches it.
566
+ function stopProbe(profile) {
567
+ const pid = activeProbes.get(profile);
568
+ try {
569
+ if (pid) process.kill(-pid, 'SIGKILL');
570
+ } catch {
571
+ // Already exited.
572
+ }
573
+ killProfile(profile);
574
+ fs.rmSync(profile, { recursive: true, force: true });
575
+ activeProbes.delete(profile);
576
+ }
577
+
578
+ function stopActiveProbes() {
579
+ for (const profile of [...activeProbes.keys()]) stopProbe(profile);
580
+ }
581
+
582
+ // A hook timeout sends SIGTERM to the caller's process group. Stop the probe
583
+ // browsers, then let the signal end the process as it would have; a host with
584
+ // its own handler for that signal (a `once` handler is already gone by now, so
585
+ // it is noted at install) keeps control of the exit.
586
+ function onProbeSignal(signal) {
587
+ stopActiveProbes();
588
+ const hostHandles = hostSignals.has(signal) || process.listenerCount(signal) > 1;
589
+ setProbeExitHandlers(false);
590
+ if (!hostHandles) process.kill(process.pid, signal);
591
+ }
592
+
593
+ let probeExitHandlers = false;
594
+ let hostSignals = new Set();
595
+
596
+ function setProbeExitHandlers(on) {
597
+ if (probeExitHandlers === on) return;
598
+ probeExitHandlers = on;
599
+ if (on) hostSignals = new Set(PROBE_SIGNALS.filter((signal) => process.listenerCount(signal)));
600
+ for (const signal of PROBE_SIGNALS) {
601
+ if (on) process.on(signal, onProbeSignal);
602
+ else process.off(signal, onProbeSignal);
603
+ }
604
+ if (on) process.on('exit', stopActiveProbes);
605
+ else process.off('exit', stopActiveProbes);
606
+ }
607
+
608
+ function trackProbe(profile, pid = null) {
609
+ activeProbes.set(profile, pid);
610
+ setProbeExitHandlers(true);
611
+ }
612
+
613
+ // A resolver killed outright (SIGKILL) cannot clean up. Its profile name
614
+ // carries its pid, so the next probe by the same user stops that browser and
615
+ // removes the profile. Another user's profiles (a shared /tmp) are left alone.
616
+ function reapOrphanedProbes(tmp = os.tmpdir()) {
617
+ for (const name of fs.readdirSync(tmp)) {
618
+ const owner = PROBE_PROFILE_OWNER.exec(name);
619
+ if (!owner || Number(owner[1]) === process.pid || pidAlive(Number(owner[1]))) continue;
620
+ const profile = path.join(tmp, name);
621
+ // Another resolver's reaper may have removed it already.
622
+ const stat = fs.lstatSync(profile, { throwIfNoEntry: false });
623
+ if (!stat || stat.uid !== process.getuid()) continue;
624
+ killProfile(profile);
625
+ fs.rmSync(profile, { recursive: true, force: true });
626
+ }
627
+ }
628
+
559
629
  // `--version` succeeds even for a build that crashes at startup, so a browser
560
630
  // is accepted only when a start answers on DevTools, paints a frame, and
561
631
  // survives a settle without dying. The Chrome for Testing crash on macOS 26+
@@ -574,8 +644,9 @@ async function probeLaunch(
574
644
  } = {},
575
645
  ) {
576
646
  const method = effectiveLaunchMethod(executable, launchMethod);
577
- const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'farmslot-browser-probe-'));
578
- activeProbeProfiles.add(profile);
647
+ reapOrphanedProbes();
648
+ const profile = fs.mkdtempSync(path.join(os.tmpdir(), `${PROBE_PROFILE_PREFIX}${process.pid}-`));
649
+ trackProbe(profile);
579
650
  const startedAt = Date.now();
580
651
  const verdict = (ok, reason, transient = false) => ({
581
652
  ok,
@@ -589,6 +660,8 @@ async function probeLaunch(
589
660
  let exited = null;
590
661
  try {
591
662
  const port = await freePort();
663
+ // A host that survives a signal resumes here after stopProbe ran.
664
+ if (!activeProbes.has(profile)) return verdict(false, 'probe stopped by its caller', true);
592
665
  // A headful probe starts like the slot browsers (the launchers and the
593
666
  // library autolaunch): in the background with no startup window (Chrome
594
667
  // activates itself when it opens one); renderCheck opens a background
@@ -615,6 +688,7 @@ async function probeLaunch(
615
688
  });
616
689
  } else {
617
690
  child = spawn(executable, args, { detached: true, stdio: 'ignore' });
691
+ if (child.pid) trackProbe(profile, child.pid);
618
692
  child.on('exit', (code, signal) => {
619
693
  exited = signal ?? `code ${code}`;
620
694
  });
@@ -624,12 +698,16 @@ async function probeLaunch(
624
698
  }
625
699
  const alive = () =>
626
700
  method === LAUNCH_SERVICES ? profilePids(profile).length > 0 : exited === null;
701
+ // stopProbe removes the profile: the caller stopped the browser, so its
702
+ // exit says nothing about the binary.
627
703
  const died = (when) =>
628
- verdict(
629
- false,
630
- `exited ${when}${exited ? ` (${exited})` : ''}`,
631
- String(exited).startsWith('spawn failed'),
632
- );
704
+ activeProbes.has(profile)
705
+ ? verdict(
706
+ false,
707
+ `exited ${when}${exited ? ` (${exited})` : ''}`,
708
+ String(exited).startsWith('spawn failed'),
709
+ )
710
+ : verdict(false, 'probe stopped by its caller', true);
633
711
  let up = false;
634
712
  while (!up && Date.now() - startedAt < startupMs) {
635
713
  if (exited) return died('during startup');
@@ -655,7 +733,9 @@ async function probeLaunch(
655
733
  if (!alive()) return died('after DevTools started');
656
734
  await sleep(250);
657
735
  }
658
- if (await devtoolsAnswers(port)) return verdict(true, 'started and rendered');
736
+ const answers = await devtoolsAnswers(port);
737
+ if (!activeProbes.has(profile)) return verdict(false, 'probe stopped by its caller', true);
738
+ if (answers) return verdict(true, 'started and rendered');
659
739
  // Only a proven exit is a property of the binary.
660
740
  return alive()
661
741
  ? verdict(false, 'DevTools stopped answering after the settle', true)
@@ -663,15 +743,11 @@ async function probeLaunch(
663
743
  } catch (error) {
664
744
  return verdict(false, `launch failed: ${error.message}`, true);
665
745
  } finally {
666
- try {
667
- if (child?.pid) process.kill(-child.pid, 'SIGKILL');
668
- } catch {
669
- // Already exited.
670
- }
671
- killProfile(profile);
746
+ stopProbe(profile);
747
+ if (activeProbes.size === 0) setProbeExitHandlers(false);
748
+ // A dying browser can still write into its profile; remove it once more.
672
749
  await sleep(300);
673
750
  fs.rmSync(profile, { recursive: true, force: true });
674
- activeProbeProfiles.delete(profile);
675
751
  }
676
752
  }
677
753
 
@@ -1093,10 +1169,7 @@ async function main(argv) {
1093
1169
  }
1094
1170
  const args = parseCliArgs(rest);
1095
1171
  const watchdog = setTimeout(() => {
1096
- for (const profile of activeProbeProfiles) {
1097
- killProfile(profile);
1098
- fs.rmSync(profile, { recursive: true, force: true });
1099
- }
1172
+ stopActiveProbes();
1100
1173
  process.stderr.write(
1101
1174
  `browser-resolver ${command} did not finish within ${CLI_DEADLINE_MS}ms.\n`,
1102
1175
  );
@@ -1200,6 +1273,7 @@ module.exports = {
1200
1273
  probeCacheKey,
1201
1274
  probeLaunch,
1202
1275
  readProbeCache,
1276
+ reapOrphanedProbes,
1203
1277
  renderCheck,
1204
1278
  resolveBrowser,
1205
1279
  withProbeLock,
@@ -0,0 +1,75 @@
1
+ 'use strict';
2
+
3
+ // The web-dapp layer of the web adapter: a strict EIP-1193 test wallet, the
4
+ // page script that wraps the app's provider and logs its wallet requests (or
5
+ // injects a provider the host answers), the host's side of that binding, and
6
+ // the request log that wallet assertions read. Product policy (typed data to
7
+ // refuse, the injected wallet's identity, forbidden log entries) is an input.
8
+
9
+ const {
10
+ DEFAULT_KNOWN_CHAINS,
11
+ READ_ONLY_RPC_METHODS,
12
+ createStrictWallet,
13
+ } = require('./strict-wallet.cjs');
14
+ const {
15
+ ERROR_CATEGORIES,
16
+ LOGGED_METHODS,
17
+ LOG_BINDING,
18
+ PAGE_MARKER,
19
+ REQUEST_BINDING,
20
+ RESOLVE_FN,
21
+ pageReadyExpression,
22
+ pageScriptSource,
23
+ } = require('./page-script.cjs');
24
+ const { PENDING_CALL_TTL_MS, createWalletRequestBinding } = require('./wallet-requests.cjs');
25
+ const {
26
+ awaitSignatureLog,
27
+ evaluateSignatureLog,
28
+ isTypedDataRequest,
29
+ parseLog,
30
+ readCursor,
31
+ readLog,
32
+ resetWindow,
33
+ shortPrimaryType,
34
+ summarize,
35
+ windowSinceCursor,
36
+ writeCursor,
37
+ writeLogArtifact,
38
+ } = require('./signature-log.cjs');
39
+
40
+ /**
41
+ * @typedef {import('./page-script.cjs').TypedDataRefusal} TypedDataRefusal
42
+ * @typedef {import('./page-script.cjs').InjectedWallet} InjectedWallet
43
+ * @typedef {import('./signature-log.cjs').SignatureLog} SignatureLog
44
+ * @typedef {import('./signature-log.cjs').SignaturePolicy} SignaturePolicy
45
+ * @typedef {import('./signature-log.cjs').SignatureSummary} SignatureSummary
46
+ * @typedef {import('./signature-log.cjs').LogEntry} LogEntry
47
+ */
48
+
49
+ module.exports = {
50
+ DEFAULT_KNOWN_CHAINS,
51
+ ERROR_CATEGORIES,
52
+ LOGGED_METHODS,
53
+ LOG_BINDING,
54
+ PAGE_MARKER,
55
+ PENDING_CALL_TTL_MS,
56
+ READ_ONLY_RPC_METHODS,
57
+ REQUEST_BINDING,
58
+ RESOLVE_FN,
59
+ awaitSignatureLog,
60
+ createStrictWallet,
61
+ createWalletRequestBinding,
62
+ evaluateSignatureLog,
63
+ isTypedDataRequest,
64
+ pageReadyExpression,
65
+ pageScriptSource,
66
+ parseLog,
67
+ readCursor,
68
+ readLog,
69
+ resetWindow,
70
+ shortPrimaryType,
71
+ summarize,
72
+ windowSinceCursor,
73
+ writeCursor,
74
+ writeLogArtifact,
75
+ };
@@ -0,0 +1,400 @@
1
+ 'use strict';
2
+
3
+ // Source of the script a wallet host evaluates in every app document before
4
+ // page scripts. It reports each wallet request the app makes (method, EIP-712
5
+ // primary type, domain/active chain, outcome; no params or signatures) through
6
+ // a CDP binding, and with the injected signer also provides an EIP-1193
7
+ // provider, announced over EIP-6963, whose requests the host answers. It
8
+ // installs itself only in the top frame of a document on the exact app origin:
9
+ // the CDP preload also runs in iframes and after the tab navigates elsewhere.
10
+ // Errors are logged as a code and a fixed category, never as provider text.
11
+ //
12
+ // Policy is an input: `refuseTypedData` names the typed data the run must never
13
+ // sign (its reason function runs in the page), `injectedWallet` the identity
14
+ // the injected provider announces.
15
+
16
+ const LOG_BINDING = '__farmslotWalletLog';
17
+ const REQUEST_BINDING = '__farmslotWalletRequest';
18
+ const RESOLVE_FN = '__farmslotWalletResolve';
19
+ // The marker the preload leaves on the window; pageReadyExpression reads it.
20
+ const PAGE_MARKER = '__farmslotDapp';
21
+
22
+ const LOGGED_METHODS = Object.freeze([
23
+ 'eth_requestAccounts',
24
+ 'wallet_requestPermissions',
25
+ 'wallet_revokePermissions',
26
+ 'wallet_switchEthereumChain',
27
+ 'wallet_addEthereumChain',
28
+ 'eth_signTypedData',
29
+ 'eth_signTypedData_v3',
30
+ 'eth_signTypedData_v4',
31
+ 'personal_sign',
32
+ 'eth_sign',
33
+ 'eth_sendTransaction',
34
+ ]);
35
+
36
+ const ERROR_CATEGORIES = Object.freeze({
37
+ 4001: 'user-rejected',
38
+ 4100: 'unauthorized',
39
+ 4200: 'unsupported-method',
40
+ 4900: 'disconnected',
41
+ 4901: 'chain-disconnected',
42
+ 4902: 'unrecognized-chain',
43
+ [-32602]: 'invalid-params',
44
+ [-32603]: 'internal',
45
+ });
46
+
47
+ function pageMain(config, refusalReason) {
48
+ if (window.top !== window || location.origin !== config.appOrigin) return;
49
+ if (window[config.marker]) return;
50
+ // The wallet host's ready check reads this marker on the committed document:
51
+ // the preload ran, and the page's provider is wrapped (covers).
52
+ const wrapped = new WeakSet();
53
+ const harness = {
54
+ signer: config.signer,
55
+ refusesTypedData: Boolean(config.refusal),
56
+ wrapped: 0,
57
+ wrapFailures: [],
58
+ covers: (provider) => Boolean(provider) && wrapped.has(provider),
59
+ };
60
+ window[config.marker] = harness;
61
+ const logged = new Set(config.loggedMethods);
62
+ // Without the host's binding a wallet request would go unrecorded, so it is
63
+ // refused (fail closed) rather than passed through.
64
+ const unrecorded = () =>
65
+ Object.assign(
66
+ new Error(
67
+ 'The harness wallet host is not attached to this document; the request was refused so it cannot go unrecorded.',
68
+ ),
69
+ { code: 4100 },
70
+ );
71
+ const hasBinding = (binding) => typeof window[binding] === 'function';
72
+ const report = (entry) => {
73
+ try {
74
+ window[config.logBinding](JSON.stringify(entry));
75
+ } catch {
76
+ // Binding unavailable (host detached): the request still proceeds.
77
+ }
78
+ };
79
+ const describe = (method, params) => {
80
+ const entry = { kind: 'request', method, t: Date.now(), url: location.pathname };
81
+ if (/^eth_signTypedData/.test(method)) {
82
+ try {
83
+ const raw = params?.[1];
84
+ const data = typeof raw === 'string' ? JSON.parse(raw) : raw;
85
+ entry.primaryType = data?.primaryType ?? null;
86
+ entry.domainName = data?.domain?.name ?? null;
87
+ entry.domainChainId =
88
+ data?.domain?.chainId == null ? null : Number(BigInt(data.domain.chainId));
89
+ } catch {
90
+ entry.primaryType = null;
91
+ }
92
+ }
93
+ if (method === 'wallet_switchEthereumChain' || method === 'wallet_addEthereumChain') {
94
+ try {
95
+ entry.toChainId = Number.parseInt(params[0].chainId, 16);
96
+ } catch {
97
+ entry.toChainId = null;
98
+ }
99
+ }
100
+ return entry;
101
+ };
102
+ const wrap = (provider, source) => {
103
+ if (!provider || typeof provider.request !== 'function' || wrapped.has(provider))
104
+ return provider;
105
+ const original = provider.request.bind(provider);
106
+ const request = async (args) => {
107
+ const method = args?.method;
108
+ if (!logged.has(method)) return original(args);
109
+ if (!hasBinding(config.logBinding)) throw unrecorded();
110
+ const entry = { ...describe(method, args?.params), source };
111
+ // Typed data the run must never sign never reaches the signer. Data
112
+ // that does not parse is left to the signer to reject; a check that
113
+ // throws refuses.
114
+ if (config.refusal && /^eth_signTypedData/.test(method)) {
115
+ let data;
116
+ let parsed = true;
117
+ try {
118
+ const raw = args?.params?.[1];
119
+ data = typeof raw === 'string' ? JSON.parse(raw) : raw;
120
+ } catch {
121
+ parsed = false;
122
+ }
123
+ let reason = null;
124
+ if (parsed) {
125
+ try {
126
+ reason = refusalReason(data);
127
+ } catch {
128
+ reason = config.refusal.checkFailed;
129
+ }
130
+ }
131
+ if (reason) {
132
+ report({
133
+ ...entry,
134
+ kind: config.refusal.kind,
135
+ reason,
136
+ outcome: 'refused',
137
+ errorCode: 4100,
138
+ });
139
+ throw Object.assign(new Error(config.refusal.message), { code: 4100 });
140
+ }
141
+ }
142
+ try {
143
+ entry.activeChainId = Number.parseInt(await original({ method: 'eth_chainId' }), 16);
144
+ } catch {
145
+ entry.activeChainId = null;
146
+ }
147
+ const started = Date.now();
148
+ try {
149
+ const result = await original(args);
150
+ entry.outcome = /sign/i.test(method) ? 'signed' : 'approved';
151
+ entry.durationMs = Date.now() - started;
152
+ report(entry);
153
+ return result;
154
+ } catch (error) {
155
+ const code = Number.isInteger(error?.code) ? error.code : null;
156
+ entry.outcome = code === 4001 ? 'rejected' : 'error';
157
+ entry.errorCode = code;
158
+ entry.errorCategory = config.errorCategories[code] ?? 'other';
159
+ entry.durationMs = Date.now() - started;
160
+ report(entry);
161
+ throw error;
162
+ }
163
+ };
164
+ try {
165
+ provider.request = request;
166
+ } catch {
167
+ // Read-only property: define it instead (below).
168
+ }
169
+ if (provider.request !== request) {
170
+ try {
171
+ Object.defineProperty(provider, 'request', {
172
+ value: request,
173
+ configurable: true,
174
+ writable: true,
175
+ });
176
+ } catch {
177
+ // Frozen provider: recorded below.
178
+ }
179
+ }
180
+ // A provider whose request could not be replaced would sign unrecorded.
181
+ if (provider.request !== request) {
182
+ harness.wrapFailures.push(source);
183
+ return provider;
184
+ }
185
+ wrapped.add(provider);
186
+ harness.wrapped += 1;
187
+ return provider;
188
+ };
189
+
190
+ if (config.signer === 'injected') {
191
+ const listeners = {};
192
+ const emit = (event, ...args) =>
193
+ (listeners[event] ?? []).forEach((fn) => {
194
+ try {
195
+ fn(...args);
196
+ } catch {
197
+ /* listener errors stay in the app */
198
+ }
199
+ });
200
+ const pending = new Map();
201
+ let nextId = 1;
202
+ window[config.resolveFn] = (id, outcome) => {
203
+ const entry = pending.get(id);
204
+ if (!entry) return;
205
+ pending.delete(id);
206
+ if (outcome.error)
207
+ entry.reject(Object.assign(new Error(outcome.error.message), { code: outcome.error.code }));
208
+ else entry.resolve(outcome.result);
209
+ };
210
+ let lastChain = null;
211
+ const provider = {
212
+ ...(config.injectedWallet.isMetaMask
213
+ ? { isMetaMask: true, _metamask: { isUnlocked: async () => true } }
214
+ : {}),
215
+ request: ({ method, params }) =>
216
+ new Promise((resolve, reject) => {
217
+ if (!hasBinding(config.requestBinding)) {
218
+ reject(unrecorded());
219
+ return;
220
+ }
221
+ const id = nextId++;
222
+ pending.set(id, { resolve, reject });
223
+ window[config.requestBinding](JSON.stringify({ id, method, params }));
224
+ }).then((result) => {
225
+ if (method === 'wallet_switchEthereumChain' && params?.[0]?.chainId !== lastChain) {
226
+ lastChain = params[0].chainId;
227
+ emit('chainChanged', lastChain);
228
+ }
229
+ if (method === 'eth_chainId') lastChain = result;
230
+ if (method === 'eth_requestAccounts') emit('connect', { chainId: lastChain ?? '0x1' });
231
+ return result;
232
+ }),
233
+ on: (event, fn) => {
234
+ (listeners[event] ??= []).push(fn);
235
+ return provider;
236
+ },
237
+ removeListener: (event, fn) => {
238
+ listeners[event] = (listeners[event] ?? []).filter((f) => f !== fn);
239
+ return provider;
240
+ },
241
+ enable: () => provider.request({ method: 'eth_requestAccounts' }),
242
+ };
243
+ wrap(provider, 'injected-strict-wallet');
244
+ window.ethereum = provider;
245
+ const info = config.injectedWallet.info;
246
+ const announce = () =>
247
+ window.dispatchEvent(
248
+ new CustomEvent('eip6963:announceProvider', { detail: Object.freeze({ info, provider }) }),
249
+ );
250
+ window.addEventListener('eip6963:requestProvider', announce);
251
+ announce();
252
+ return;
253
+ }
254
+
255
+ // Extension mode: wrap whatever provider the wallet extension injects,
256
+ // however the app reaches it (window.ethereum or an EIP-6963 announcement).
257
+ let current = window.ethereum ? wrap(window.ethereum, 'window.ethereum') : undefined;
258
+ try {
259
+ Object.defineProperty(window, 'ethereum', {
260
+ configurable: true,
261
+ get: () => current,
262
+ set: (value) => {
263
+ current = wrap(value, 'window.ethereum');
264
+ },
265
+ });
266
+ } catch {
267
+ // A non-configurable window.ethereum is still wrapped by the poll below.
268
+ }
269
+ window.addEventListener(
270
+ 'eip6963:announceProvider',
271
+ (event) => {
272
+ wrap(event.detail?.provider, `eip6963:${event.detail?.info?.rdns ?? 'unknown'}`);
273
+ },
274
+ true,
275
+ );
276
+ let polls = 0;
277
+ const poll = setInterval(() => {
278
+ if (window.ethereum) wrap(window.ethereum, 'window.ethereum');
279
+ if (++polls > 200) clearInterval(poll);
280
+ }, 50);
281
+ }
282
+
283
+ // The reason recorded when a refusal check throws: the request is refused.
284
+ const REFUSAL_CHECK_FAILED = 'the refusal check threw';
285
+
286
+ /**
287
+ * Refuse a policy the page or the host could not apply: kind and message must
288
+ * be non-empty strings, and reason a function whose source is an expression
289
+ * in the page (a function declaration or an arrow; not a method shorthand, a
290
+ * bound or a native function).
291
+ * @param {TypedDataRefusal | null | undefined} refuseTypedData
292
+ */
293
+ function assertTypedDataRefusal(refuseTypedData) {
294
+ if (!refuseTypedData) return;
295
+ if (typeof refuseTypedData.reason !== 'function') {
296
+ throw new Error('refuseTypedData.reason must be a function.');
297
+ }
298
+ for (const field of ['kind', 'message']) {
299
+ if (typeof refuseTypedData[field] !== 'string' || !refuseTypedData[field].trim()) {
300
+ throw new Error(`refuseTypedData.${field} must be a non-empty string.`);
301
+ }
302
+ }
303
+ try {
304
+ new Function(`return (${refuseTypedData.reason.toString()});`);
305
+ } catch {
306
+ throw new Error(
307
+ 'refuseTypedData.reason must be a function declaration or an arrow function; a method shorthand, bound or native function does not serialize into the page.',
308
+ );
309
+ }
310
+ }
311
+
312
+ /**
313
+ * @typedef {object} TypedDataRefusal
314
+ * @property {(typedData: any) => string | null} reason Why the run must not sign this typed data, or null. Runs in the page too: self-contained (no closure, no imports).
315
+ * @property {string} kind The log entry kind of a refusal.
316
+ * @property {string} message The refusal's error message (code 4100).
317
+ */
318
+
319
+ /**
320
+ * @typedef {object} InjectedWallet
321
+ * @property {{ uuid: string, name: string, icon: string, rdns: string }} info EIP-6963 provider info to announce.
322
+ * @property {boolean} [isMetaMask] Present the provider as MetaMask (`isMetaMask`, `_metamask.isUnlocked`).
323
+ */
324
+
325
+ /**
326
+ * The preload for one app origin.
327
+ * - `signer`: 'extension' wraps the provider a wallet extension injects;
328
+ * 'injected' provides one whose requests go to the host (REQUEST_BINDING).
329
+ * - `refuseTypedData`: `{ reason, kind, message }` or null. `reason(typedData)`
330
+ * returns why the run must not sign it, or null; it runs in the page, so it
331
+ * must be a self-contained function (no closure, no imports). A refusal is
332
+ * logged as `kind` and answered with code 4100 and `message`.
333
+ * - `injectedWallet` (injected signer): `{ info, isMetaMask }`, the EIP-6963
334
+ * provider info to announce and whether the provider presents as MetaMask.
335
+ *
336
+ * @param {{ signer: 'extension' | 'injected', appOrigin: string, refuseTypedData?: TypedDataRefusal | null, injectedWallet?: InjectedWallet }} options
337
+ * @returns {string}
338
+ */
339
+ function pageScriptSource({ signer, appOrigin, refuseTypedData = null, injectedWallet }) {
340
+ if (signer !== 'extension' && signer !== 'injected') {
341
+ throw new Error(`signer must be extension or injected, got ${JSON.stringify(signer)}.`);
342
+ }
343
+ assertTypedDataRefusal(refuseTypedData);
344
+ if (signer === 'injected' && !injectedWallet?.info?.rdns) {
345
+ throw new Error('the injected signer needs injectedWallet.info (EIP-6963 provider info).');
346
+ }
347
+ const config = {
348
+ signer,
349
+ appOrigin: new URL(appOrigin).origin,
350
+ refusal: refuseTypedData
351
+ ? {
352
+ kind: refuseTypedData.kind,
353
+ message: refuseTypedData.message,
354
+ checkFailed: REFUSAL_CHECK_FAILED,
355
+ }
356
+ : null,
357
+ injectedWallet:
358
+ signer === 'injected'
359
+ ? { info: injectedWallet.info, isMetaMask: injectedWallet.isMetaMask === true }
360
+ : null,
361
+ errorCategories: ERROR_CATEGORIES,
362
+ loggedMethods: LOGGED_METHODS,
363
+ marker: PAGE_MARKER,
364
+ logBinding: LOG_BINDING,
365
+ requestBinding: REQUEST_BINDING,
366
+ resolveFn: RESOLVE_FN,
367
+ };
368
+ const reason = refuseTypedData ? refuseTypedData.reason.toString() : 'null';
369
+ return `(${pageMain.toString()})(${JSON.stringify(config)}, ${reason});`;
370
+ }
371
+
372
+ /**
373
+ * An expression that is true on a document whose preload is installed for
374
+ * this signer and policy, whose bindings are attached, and whose provider the
375
+ * app sees (window.ethereum) is wrapped with no wrap failure.
376
+ *
377
+ * @param {{ signer: 'extension' | 'injected', refusesTypedData: boolean }} options
378
+ * @returns {string}
379
+ */
380
+ function pageReadyExpression({ signer, refusesTypedData }) {
381
+ return [
382
+ `typeof window.${LOG_BINDING} === 'function'`,
383
+ ...(signer === 'injected' ? [`typeof window.${REQUEST_BINDING} === 'function'`] : []),
384
+ `Boolean(window.${PAGE_MARKER} && window.${PAGE_MARKER}.signer === ${JSON.stringify(signer)} && window.${PAGE_MARKER}.refusesTypedData === ${refusesTypedData === true})`,
385
+ `window.${PAGE_MARKER}.wrapFailures.length === 0 && window.${PAGE_MARKER}.covers(window.ethereum)`,
386
+ ].join(' && ');
387
+ }
388
+
389
+ module.exports = {
390
+ ERROR_CATEGORIES,
391
+ LOGGED_METHODS,
392
+ LOG_BINDING,
393
+ PAGE_MARKER,
394
+ REQUEST_BINDING,
395
+ REFUSAL_CHECK_FAILED,
396
+ RESOLVE_FN,
397
+ assertTypedDataRefusal,
398
+ pageReadyExpression,
399
+ pageScriptSource,
400
+ };