@farmslot/adapter-web 0.1.0 → 0.3.0

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.
@@ -0,0 +1,321 @@
1
+ 'use strict';
2
+
3
+ // The wallet host's side of the page script's bindings. Every binding call is
4
+ // judged against the frame and document that made it: only the app's own top
5
+ // frame may log a wallet request or ask the injected wallet to sign. Anything
6
+ // else (an iframe, a blank popup, a page the tab navigated to) is dropped and
7
+ // recorded, and a refused request is answered with 4100. A call that arrives
8
+ // before its execution context or its document's commit is known waits
9
+ // briefly; one that still cannot be judged is recorded as unattributed (and a
10
+ // request is refused).
11
+ //
12
+ // The host owns the CDP connection and the tabs; it tells the binding when it
13
+ // hooks a tab (install), when the tab's top frame commits a document (commit),
14
+ // when the tab's domains are enabled (drain) and when the tab detaches
15
+ // (detach). Records never hold params, messages or signatures.
16
+
17
+ const { isAppTopFrameContext, shortUrl } = require('../origin.cjs');
18
+ const {
19
+ LOG_BINDING,
20
+ REFUSAL_CHECK_FAILED,
21
+ REQUEST_BINDING,
22
+ RESOLVE_FN,
23
+ assertTypedDataRefusal,
24
+ } = require('./page-script.cjs');
25
+
26
+ const PENDING_CALL_TTL_MS = 5000;
27
+
28
+ /**
29
+ * - `client`: `{ send(method, params, sessionId), on(event, handler(params, sessionId)) }`.
30
+ * - `signer`: 'extension' (log only) or 'injected' (`wallet` answers requests).
31
+ * - `wallet`: an EIP-1193 `{ request }` for the injected signer.
32
+ * - `refuseTypedData`: the page script's policy (`{ reason, kind, message }`),
33
+ * applied again here before the wallet signs.
34
+ * - `record(entry)`: append one entry to the wallet request log.
35
+ * - `say(message)`: the host's own log.
36
+ *
37
+ * @param {object} options
38
+ * @param {{ send(method: string, params?: object, sessionId?: string): Promise<any>, on(method: string, handler: (params: any, sessionId?: string) => void): unknown }} options.client
39
+ * @param {string} options.appOrigin
40
+ * @param {'extension' | 'injected'} options.signer
41
+ * @param {{ request(args: { method: string, params?: unknown }): Promise<unknown> } | null} [options.wallet]
42
+ * @param {import('./page-script.cjs').TypedDataRefusal | null} [options.refuseTypedData]
43
+ * @param {(entry: Record<string, unknown>) => void} options.record
44
+ * @param {(message: string) => void} [options.say]
45
+ */
46
+ function createWalletRequestBinding({
47
+ client,
48
+ appOrigin,
49
+ signer,
50
+ wallet = null,
51
+ refuseTypedData = null,
52
+ record,
53
+ say = () => {},
54
+ }) {
55
+ assertTypedDataRefusal(refuseTypedData);
56
+ // Per app session: its target id (= main frame id) and live execution
57
+ // contexts, so a binding call can be traced to the frame, document and
58
+ // origin that made it.
59
+ const sessionTargets = new Map();
60
+ const sessionContexts = new Map();
61
+ // Binding calls whose execution context, or that context's document, is not
62
+ // known yet.
63
+ const pendingCalls = new Map();
64
+ // Per app session: the URL of a top-frame commit no execution context has
65
+ // been tied to yet.
66
+ const awaitingDocuments = new Map();
67
+ // A commit is applied once: Page.getFrameTree and Page.frameNavigated can
68
+ // both report the same document (its loaderId); a second report would leave
69
+ // a stale commit waiting for the next document's context.
70
+ const appliedCommits = new Map();
71
+
72
+ function isTopDefaultContext(sessionId, context) {
73
+ return (
74
+ context?.auxData?.isDefault === true &&
75
+ context.auxData.frameId === sessionTargets.get(sessionId)
76
+ );
77
+ }
78
+
79
+ // A top-frame commit is tied to the top frame's default context of its
80
+ // document: one created before the commit and still untied, or else the
81
+ // next one created.
82
+ function assignDocumentUrl(sessionId, url, loaderId) {
83
+ if (loaderId) {
84
+ if (appliedCommits.get(sessionId) === loaderId) return;
85
+ appliedCommits.set(sessionId, loaderId);
86
+ }
87
+ const untied = [...(sessionContexts.get(sessionId)?.values() ?? [])].filter(
88
+ (context) => isTopDefaultContext(sessionId, context) && context.documentUrl === null,
89
+ );
90
+ if (untied.length === 0) {
91
+ awaitingDocuments.set(sessionId, url);
92
+ return;
93
+ }
94
+ for (const context of untied) context.documentUrl = url;
95
+ }
96
+
97
+ // Judged against the caller's own document, not the tab's latest commit.
98
+ function callerIsAppTopFrame(sessionId, executionContextId) {
99
+ const context = sessionContexts.get(sessionId)?.get(executionContextId);
100
+ return isAppTopFrameContext(context, {
101
+ targetId: sessionTargets.get(sessionId),
102
+ appOrigin,
103
+ committedUrl: context?.documentUrl ?? null,
104
+ });
105
+ }
106
+
107
+ // Answer a request the host refuses, so the page's promise settles instead
108
+ // of hanging; harmless where the preload (and its resolver) is absent.
109
+ function refuse(sessionId, executionContextId, id, message) {
110
+ const outcome = { error: { message, code: 4100 } };
111
+ const expression = `typeof window.${RESOLVE_FN} === 'function' && window.${RESOLVE_FN}(${JSON.stringify(id)}, ${JSON.stringify(outcome)})`;
112
+ return client
113
+ .send('Runtime.evaluate', { expression, contextId: executionContextId }, sessionId)
114
+ .catch(() => {});
115
+ }
116
+
117
+ function requestId(name, payload) {
118
+ if (name !== REQUEST_BINDING) return null;
119
+ try {
120
+ return JSON.parse(payload).id ?? null;
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
125
+
126
+ async function handleBindingCall({ name, payload, executionContextId }, sessionId) {
127
+ if (!callerIsAppTopFrame(sessionId, executionContextId)) {
128
+ const context = sessionContexts.get(sessionId)?.get(executionContextId);
129
+ record({
130
+ kind: 'outside-app-frame',
131
+ t: Date.now(),
132
+ binding: name === REQUEST_BINDING ? 'request' : 'log',
133
+ origin: context?.origin ?? null,
134
+ document: context?.documentUrl ? shortUrl(context.documentUrl) : null,
135
+ });
136
+ say(
137
+ `ignored ${name === REQUEST_BINDING ? 'wallet request' : 'log entry'} from outside the app top frame`,
138
+ );
139
+ const id = requestId(name, payload);
140
+ if (id !== null)
141
+ await refuse(
142
+ sessionId,
143
+ executionContextId,
144
+ id,
145
+ 'Wallet requests are accepted only from the app top frame.',
146
+ );
147
+ return;
148
+ }
149
+ if (name === LOG_BINDING) {
150
+ try {
151
+ record(JSON.parse(payload));
152
+ } catch (error) {
153
+ say(`unparseable log payload: ${error.message}`);
154
+ }
155
+ return;
156
+ }
157
+ if (name !== REQUEST_BINDING || !wallet) return;
158
+ const { id, method, params } = JSON.parse(payload);
159
+ if (refuseTypedData && /^eth_signTypedData/u.test(String(method))) {
160
+ // As in the page: unparseable data is the wallet's to reject; a check that throws refuses.
161
+ let data;
162
+ let parsed = true;
163
+ try {
164
+ data = typeof params?.[1] === 'string' ? JSON.parse(params[1]) : params?.[1];
165
+ } catch {
166
+ parsed = false;
167
+ }
168
+ let reason = null;
169
+ if (parsed) {
170
+ try {
171
+ reason = refuseTypedData.reason(data);
172
+ } catch {
173
+ reason = REFUSAL_CHECK_FAILED;
174
+ }
175
+ }
176
+ if (reason) {
177
+ record({ kind: refuseTypedData.kind, t: Date.now(), method, reason, layer: 'wallet-host' });
178
+ await refuse(sessionId, executionContextId, id, refuseTypedData.message);
179
+ return;
180
+ }
181
+ }
182
+ let outcome;
183
+ try {
184
+ outcome = { result: await wallet.request({ method, params }) };
185
+ } catch (error) {
186
+ outcome = { error: { message: String(error.message), code: error.code ?? -32603 } };
187
+ }
188
+ await client
189
+ .send(
190
+ 'Runtime.evaluate',
191
+ {
192
+ expression: `window.${RESOLVE_FN}(${JSON.stringify(id)}, ${JSON.stringify(outcome)})`,
193
+ contextId: executionContextId,
194
+ },
195
+ sessionId,
196
+ )
197
+ .catch((error) => say(`resolve ${method} failed: ${error.message}`));
198
+ }
199
+
200
+ function recordUnattributed(call, why) {
201
+ let entry = {};
202
+ try {
203
+ entry = JSON.parse(call.payload);
204
+ } catch {
205
+ entry = {};
206
+ }
207
+ const binding = call.name === REQUEST_BINDING ? 'request' : 'log';
208
+ record({
209
+ kind: 'unattributed',
210
+ t: Date.now(),
211
+ binding,
212
+ method: typeof entry.method === 'string' ? entry.method : null,
213
+ loggedKind: typeof entry.kind === 'string' ? entry.kind : null,
214
+ reason: why,
215
+ });
216
+ say(
217
+ `a wallet ${binding === 'request' ? 'request' : 'log entry'} could not be judged (${why}); recorded as unattributed`,
218
+ );
219
+ }
220
+
221
+ // A binding call can be judged once its execution context is known and, for
222
+ // the top frame's default context, the URL of that context's own document.
223
+ function callAttributable(sessionId, executionContextId) {
224
+ const context = sessionContexts.get(sessionId)?.get(executionContextId);
225
+ if (!context) return false;
226
+ return !isTopDefaultContext(sessionId, context) || context.documentUrl !== null;
227
+ }
228
+
229
+ // A binding call can arrive before its execution context is reported (the
230
+ // domains are enabled after the tab resumes), or before its document's
231
+ // commit. Hold it until both are known; after a short wait it cannot be
232
+ // judged.
233
+ function drain(sessionId) {
234
+ const queue = pendingCalls.get(sessionId);
235
+ if (!queue) return;
236
+ for (const call of [...queue]) {
237
+ const known = callAttributable(sessionId, call.executionContextId);
238
+ if (!known && Date.now() - call.at < PENDING_CALL_TTL_MS) continue;
239
+ queue.splice(queue.indexOf(call), 1);
240
+ if (known) {
241
+ handleBindingCall(call, sessionId);
242
+ continue;
243
+ }
244
+ recordUnattributed(call, 'its document was never attributed');
245
+ const id = requestId(call.name, call.payload);
246
+ if (id !== null)
247
+ refuse(
248
+ sessionId,
249
+ call.executionContextId,
250
+ id,
251
+ 'Wallet request from a document the harness could not attribute.',
252
+ );
253
+ }
254
+ }
255
+
256
+ client.on('Runtime.executionContextCreated', ({ context }, sessionId) => {
257
+ const contexts = sessionContexts.get(sessionId);
258
+ if (!contexts) return;
259
+ contexts.set(context.id, context);
260
+ if (isTopDefaultContext(sessionId, context)) {
261
+ // The navigation this document belongs to may already have committed
262
+ // (frameNavigated first) or not yet (the context first).
263
+ const awaiting = awaitingDocuments.get(sessionId);
264
+ context.documentUrl = awaiting ?? null;
265
+ awaitingDocuments.delete(sessionId);
266
+ }
267
+ drain(sessionId);
268
+ });
269
+ client.on('Runtime.executionContextDestroyed', ({ executionContextId }, sessionId) => {
270
+ sessionContexts.get(sessionId)?.delete(executionContextId);
271
+ });
272
+ client.on('Runtime.executionContextsCleared', (_params, sessionId) => {
273
+ sessionContexts.get(sessionId)?.clear();
274
+ });
275
+ client.on('Runtime.bindingCalled', (call, sessionId) => {
276
+ if (callAttributable(sessionId, call.executionContextId)) {
277
+ handleBindingCall(call, sessionId);
278
+ return;
279
+ }
280
+ const queue = pendingCalls.get(sessionId) ?? [];
281
+ queue.push({ ...call, at: Date.now() });
282
+ pendingCalls.set(sessionId, queue);
283
+ setTimeout(() => drain(sessionId), PENDING_CALL_TTL_MS + 50);
284
+ });
285
+
286
+ return {
287
+ /**
288
+ * Track an app tab and add its bindings. Call before the page script is
289
+ * registered; neither needs the target running.
290
+ */
291
+ install(sessionId, targetId) {
292
+ sessionTargets.set(sessionId, targetId);
293
+ sessionContexts.set(sessionId, new Map());
294
+ return Promise.all([
295
+ client.send('Runtime.addBinding', { name: LOG_BINDING }, sessionId),
296
+ ...(signer === 'injected'
297
+ ? [client.send('Runtime.addBinding', { name: REQUEST_BINDING }, sessionId)]
298
+ : []),
299
+ ]);
300
+ },
301
+ /** The tab's top frame committed `url` (a frameNavigated or the frame tree). */
302
+ commit(sessionId, url, loaderId) {
303
+ assignDocumentUrl(sessionId, url, loaderId);
304
+ drain(sessionId);
305
+ },
306
+ /** Judge the calls that were waiting for the tab's contexts. */
307
+ drain,
308
+ /** The tab detached: calls still waiting for their document can no longer be judged. */
309
+ detach(sessionId) {
310
+ for (const call of pendingCalls.get(sessionId) ?? [])
311
+ recordUnattributed(call, 'its tab detached first');
312
+ awaitingDocuments.delete(sessionId);
313
+ appliedCommits.delete(sessionId);
314
+ sessionTargets.delete(sessionId);
315
+ sessionContexts.delete(sessionId);
316
+ pendingCalls.delete(sessionId);
317
+ },
318
+ };
319
+ }
320
+
321
+ module.exports = { PENDING_CALL_TTL_MS, createWalletRequestBinding };