@farmslot/adapter-web 0.5.0 → 0.6.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.
- package/CHANGELOG.md +5 -0
- package/README.md +24 -1
- package/dist/web-dapp/cleanup.d.mts +15 -0
- package/dist/web-dapp/index.d.mts +123 -0
- package/dist/web-dapp/inject.d.mts +12 -0
- package/dist/web-dapp/launch.d.mts +56 -0
- package/dist/web-dapp/lib/blocked-hosts.d.mts +3 -0
- package/dist/web-dapp/lib/cdp-client.d.mts +8 -0
- package/dist/web-dapp/lib/display.d.mts +26 -0
- package/dist/web-dapp/lib/hud.d.mts +14 -0
- package/dist/web-dapp/lib/paths.d.mts +11 -0
- package/dist/web-dapp/lib/policy-fence.d.mts +18 -0
- package/dist/web-dapp/lib/policy.d.mts +4 -0
- package/dist/web-dapp/lib/processes.d.mts +57 -0
- package/dist/web-dapp/lib/readiness.d.mts +85 -0
- package/dist/web-dapp/lib/runtime.d.mts +29 -0
- package/dist/web-dapp/lib/signers.d.mts +13 -0
- package/dist/web-dapp/lib/viem.d.mts +1 -0
- package/dist/web-dapp/stop.d.mts +2 -0
- package/dist/web-dapp/verify.d.mts +2 -0
- package/dist/web-dapp/wallet-host.d.mts +2 -0
- package/package.json +17 -10
- package/src/web-dapp/cleanup.mjs +74 -0
- package/src/web-dapp/index.mjs +590 -0
- package/src/web-dapp/inject.mjs +100 -0
- package/src/web-dapp/launch.mjs +806 -0
- package/src/web-dapp/lib/blocked-hosts.mjs +24 -0
- package/src/web-dapp/lib/cdp-client.mjs +86 -0
- package/src/web-dapp/lib/display.mjs +169 -0
- package/src/web-dapp/lib/hud.mjs +156 -0
- package/src/web-dapp/lib/paths.mjs +55 -0
- package/src/web-dapp/lib/policy-fence.mjs +128 -0
- package/src/web-dapp/lib/policy.mjs +108 -0
- package/src/web-dapp/lib/processes.mjs +215 -0
- package/src/web-dapp/lib/readiness.mjs +438 -0
- package/src/web-dapp/lib/runtime.mjs +195 -0
- package/src/web-dapp/lib/signers.mjs +105 -0
- package/src/web-dapp/lib/viem.mjs +26 -0
- package/src/web-dapp/stop.mjs +44 -0
- package/src/web-dapp/verify.mjs +68 -0
- package/src/web-dapp/wallet-host.mjs +728 -0
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// wallet-host.mjs — long-running observer (and, in injected mode, signer) for the
|
|
3
|
+
// web-dapp slot browser.
|
|
4
|
+
//
|
|
5
|
+
// Purpose:
|
|
6
|
+
// Attaches to the slot browser over CDP and, for every app document, installs
|
|
7
|
+
// the request-log page script before page scripts run. The app tab is created
|
|
8
|
+
// blank, hooked, and only then navigated to the app, so the first app
|
|
9
|
+
// document already has the log binding; ready is written only after that is
|
|
10
|
+
// verified on the committed app document. Appends one JSON line
|
|
11
|
+
// per wallet request to <runtime>/wallet-requests.jsonl. In extension mode it
|
|
12
|
+
// also logs every wallet confirmation window the extension signer's `confirm`
|
|
13
|
+
// hook (lib/signers.mjs) recognises as opened. In injected mode
|
|
14
|
+
// it answers the page's EIP-1193 requests with the strict test wallet signing
|
|
15
|
+
// as the fixture account. It also redraws the recipe HUD (<runtime>/hud.json)
|
|
16
|
+
// on app pages after navigations and whenever the HUD state changes.
|
|
17
|
+
//
|
|
18
|
+
// Inputs (flags): --cdp-port <port> --runtime-dir <dir> --signer <extension|injected>
|
|
19
|
+
// --app-origin <url> [--served-check not-applicable] [--extension-id <id>] [--signer-module <path>]
|
|
20
|
+
// [--target <checkout> --account <name> --start-chain <id>] (injected mode only)
|
|
21
|
+
// [--observe-focus 1] (a visible browser launched in the background on macOS:
|
|
22
|
+
// the host records in focus.log if a wallet notification window brought it to
|
|
23
|
+
// front; it never activates an app)
|
|
24
|
+
// Outputs: <runtime>/wallet-requests.jsonl, <runtime>/wallet-host.ready, stderr log.
|
|
25
|
+
// Exits when the browser connection closes.
|
|
26
|
+
// Never logs: key material, request params, messages, signatures.
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
appendFileSync,
|
|
30
|
+
chmodSync,
|
|
31
|
+
existsSync,
|
|
32
|
+
unwatchFile,
|
|
33
|
+
watchFile,
|
|
34
|
+
writeFileSync,
|
|
35
|
+
} from 'node:fs';
|
|
36
|
+
import { createRequire } from 'node:module';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
|
|
39
|
+
import {
|
|
40
|
+
createStrictWallet,
|
|
41
|
+
createWalletRequestBinding,
|
|
42
|
+
pageReadyExpression,
|
|
43
|
+
pageScriptSource,
|
|
44
|
+
} from '../dapp/index.cjs';
|
|
45
|
+
import { isAppUrl as isExactAppUrl, shortUrl } from '../origin.cjs';
|
|
46
|
+
|
|
47
|
+
import { hostOf, isBlockedUrl } from './lib/blocked-hosts.mjs';
|
|
48
|
+
import { browserWebSocketUrl, CdpClient } from './lib/cdp-client.mjs';
|
|
49
|
+
import { hudFile, hudRenderExpression, readHudState } from './lib/hud.mjs';
|
|
50
|
+
import { ownedBrowserPids } from './lib/processes.mjs';
|
|
51
|
+
import { fixtureAccount, webDappPolicy } from './lib/runtime.mjs';
|
|
52
|
+
import { injectedWalletIdentity, loadSigners, SIGNER_MODULE_ENV } from './lib/signers.mjs';
|
|
53
|
+
|
|
54
|
+
const { captureMacFrontmost, macFocusDisabled } = createRequire(import.meta.url)(
|
|
55
|
+
'../macos-focus.cjs',
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
function usage() {
|
|
59
|
+
return 'Usage: wallet-host.mjs --cdp-port <port> --runtime-dir <dir> --signer <extension|injected> --app-origin <url> [--network testnet|mainnet] [--mainnet-hosts <h1,h2>] [--testnet-hosts <h1,h2>] [--served-check not-applicable] [--extension-id <id>] [--signer-module <path>] [--target <checkout> --account <name> --start-chain <id>] [--observe-focus 1] [--window x,y,w,h] [--start-path <path>] [--mm-harness-owner=<marker>]';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseArgs(argv) {
|
|
63
|
+
const args = {};
|
|
64
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
65
|
+
const flag = argv[index];
|
|
66
|
+
if (flag === '--help' || flag === '-h') {
|
|
67
|
+
process.stdout.write(`${usage()}\n`);
|
|
68
|
+
process.exit(0);
|
|
69
|
+
}
|
|
70
|
+
if (flag.startsWith('--') && flag.includes('=')) {
|
|
71
|
+
args[flag.slice(2, flag.indexOf('='))] = flag.slice(flag.indexOf('=') + 1);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (!flag.startsWith('--') || index + 1 >= argv.length) throw new Error(usage());
|
|
75
|
+
args[flag.slice(2)] = argv[index + 1];
|
|
76
|
+
index += 1;
|
|
77
|
+
}
|
|
78
|
+
for (const key of ['cdp-port', 'runtime-dir', 'signer', 'app-origin']) {
|
|
79
|
+
if (!args[key]) throw new Error(`Missing --${key}\n${usage()}`);
|
|
80
|
+
}
|
|
81
|
+
return args;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const args = parseArgs(process.argv.slice(2));
|
|
85
|
+
// The venue policy (e.g. the Terminal plugin's policy.mjs), from RECIPE_WEB_DAPP_POLICY:
|
|
86
|
+
// link hosts, the probe, the typed-data refusal, the start page and chain.
|
|
87
|
+
const policy = webDappPolicy();
|
|
88
|
+
const LINK_HOSTS = new Set(policy.linkHosts);
|
|
89
|
+
const runtimeDir = path.resolve(args['runtime-dir']);
|
|
90
|
+
const logFile = path.join(runtimeDir, 'wallet-requests.jsonl');
|
|
91
|
+
const signer = args.signer;
|
|
92
|
+
const appOrigin = args['app-origin'].replace(/\/$/u, '');
|
|
93
|
+
// Testnet runs are held to testnet here too: requests to the mainnet venue
|
|
94
|
+
// hosts are failed and logged, and a mainnet typed-data action is refused.
|
|
95
|
+
const network = args.network === 'mainnet' ? 'mainnet' : 'testnet';
|
|
96
|
+
const mainnetHosts =
|
|
97
|
+
network === 'testnet'
|
|
98
|
+
? String(args['mainnet-hosts'] ?? '')
|
|
99
|
+
.split(',')
|
|
100
|
+
.filter(Boolean)
|
|
101
|
+
: [];
|
|
102
|
+
if (network === 'testnet' && mainnetHosts.length === 0)
|
|
103
|
+
throw new Error('testnet mode needs --mainnet-hosts (the venue hosts to block).');
|
|
104
|
+
// The testnet venue endpoints the served-network check looks for: API hosts
|
|
105
|
+
// only, never a link host (app.hyperliquid-testnet.xyz serves pages and icons).
|
|
106
|
+
const testnetHosts = String(args['testnet-hosts'] ?? '')
|
|
107
|
+
.split(',')
|
|
108
|
+
.filter((host) => host && !LINK_HOSTS.has(host));
|
|
109
|
+
// The policy declared that the app's page makes no venue requests of its own
|
|
110
|
+
// (launch passes --served-check not-applicable): there is no served network to
|
|
111
|
+
// check, and the record says so. Without that declaration the check always runs,
|
|
112
|
+
// and an empty --testnet-hosts fails it closed. Mainnet blocking is unaffected.
|
|
113
|
+
const servedApplicable = args['served-check'] !== 'not-applicable';
|
|
114
|
+
const PROBE_MARK = 'mm-harness-probe=1';
|
|
115
|
+
// A testnet run never lets the typed data the policy refuses reach the signer.
|
|
116
|
+
const refuseTypedData = network === 'testnet' ? policy.refuseTypedData : null;
|
|
117
|
+
// The signer module (lib/signers.mjs): the extension signer's hooks and, under
|
|
118
|
+
// `injected.identity`, how the injected strict wallet presents itself.
|
|
119
|
+
const signerHooks = await loadSigners({
|
|
120
|
+
...process.env,
|
|
121
|
+
...(args['signer-module'] ? { [SIGNER_MODULE_ENV]: args['signer-module'] } : {}),
|
|
122
|
+
});
|
|
123
|
+
// How the injected strict wallet presents itself (see injectedWalletIdentity).
|
|
124
|
+
const INJECTED_WALLET = injectedWalletIdentity(signerHooks);
|
|
125
|
+
|
|
126
|
+
// The request log is evidence; keep it owner-only even when it already exists.
|
|
127
|
+
if (existsSync(logFile)) chmodSync(logFile, 0o600);
|
|
128
|
+
|
|
129
|
+
function appendEntry(entry) {
|
|
130
|
+
appendFileSync(logFile, `${JSON.stringify({ signer, ...entry })}\n`, { mode: 0o600 });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function say(message) {
|
|
134
|
+
process.stderr.write(`${new Date().toISOString()} [wallet-host] ${message}\n`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let wallet = null;
|
|
138
|
+
if (signer === 'injected') {
|
|
139
|
+
if (!args.target || !args.account)
|
|
140
|
+
throw new Error('injected mode requires --target and --account');
|
|
141
|
+
const account = await fixtureAccount(path.resolve(args.target), args.account);
|
|
142
|
+
wallet = createStrictWallet({
|
|
143
|
+
account,
|
|
144
|
+
chainId: Number(args['start-chain'] ?? policy.startChain),
|
|
145
|
+
});
|
|
146
|
+
say(
|
|
147
|
+
`injected strict wallet ready for ${args.account} (${account.address}) on chain ${wallet.chainId}`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const source = pageScriptSource({
|
|
152
|
+
signer,
|
|
153
|
+
appOrigin,
|
|
154
|
+
refuseTypedData,
|
|
155
|
+
injectedWallet: INJECTED_WALLET,
|
|
156
|
+
});
|
|
157
|
+
const client = await CdpClient.connect(await browserWebSocketUrl(Number(args['cdp-port'])));
|
|
158
|
+
// Judges each binding call against the frame and document that made it, logs
|
|
159
|
+
// it and, in injected mode, answers it with the strict wallet.
|
|
160
|
+
const binding = createWalletRequestBinding({
|
|
161
|
+
client,
|
|
162
|
+
appOrigin,
|
|
163
|
+
signer,
|
|
164
|
+
wallet,
|
|
165
|
+
refuseTypedData,
|
|
166
|
+
record: appendEntry,
|
|
167
|
+
say,
|
|
168
|
+
});
|
|
169
|
+
// Hooks are installed once per target: a second session for the same tab
|
|
170
|
+
// (auto-attach plus a restored tab) would log every request twice.
|
|
171
|
+
const hookedTargets = new Map();
|
|
172
|
+
const appSessions = new Set();
|
|
173
|
+
// Per app session: its target id (= main frame id) and the URL its top frame
|
|
174
|
+
// has committed.
|
|
175
|
+
const sessionTargets = new Map();
|
|
176
|
+
const sessionCommittedUrls = new Map();
|
|
177
|
+
// App sessions whose target runs with domains enabled; the HUD is drawn only
|
|
178
|
+
// there so no evaluate ever waits on a tab paused for setup.
|
|
179
|
+
const hudSessions = new Set();
|
|
180
|
+
|
|
181
|
+
// New tabs start blank before they navigate to the app.
|
|
182
|
+
function isAppUrl(url) {
|
|
183
|
+
return url === 'about:blank' || url === '' || isExactAppUrl(url, appOrigin);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// The extension signer's view of its own confirmation surfaces (its `confirm`
|
|
187
|
+
// hook, from signerHooks above): which tab URLs are the wallet's, and what each
|
|
188
|
+
// one is showing. Without the hook no tab counts as a wallet surface.
|
|
189
|
+
|
|
190
|
+
// A wallet notification or popup window opens as a new window and can
|
|
191
|
+
// activate the browser. The host only observes this: it records in
|
|
192
|
+
// <runtime>/focus.log when one of this slot's browser processes (by pid, never
|
|
193
|
+
// by app bundle) is the frontmost app after such a window opens, and never
|
|
194
|
+
// activates any app. Only for a visible background launch (--observe-focus).
|
|
195
|
+
const focus =
|
|
196
|
+
args['observe-focus'] === '1' && !macFocusDisabled()
|
|
197
|
+
? {
|
|
198
|
+
observe(surface, product = 'wallet') {
|
|
199
|
+
const front = captureMacFrontmost();
|
|
200
|
+
if (front && ownedBrowserPids(runtimeDir).includes(front.pid)) {
|
|
201
|
+
appendFileSync(
|
|
202
|
+
path.join(runtimeDir, 'focus.log'),
|
|
203
|
+
`${new Date().toISOString()} slot browser (pid ${front.pid}) is frontmost after a ${product} ${surface} window opened (observed only)\n`,
|
|
204
|
+
{ mode: 0o600 },
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
}
|
|
209
|
+
: null;
|
|
210
|
+
|
|
211
|
+
const confirmations =
|
|
212
|
+
signer === 'extension' && signerHooks.extension?.confirm
|
|
213
|
+
? signerHooks.extension.confirm({ args, appendEntry, say, focus })
|
|
214
|
+
: null;
|
|
215
|
+
|
|
216
|
+
function isWalletSurfaceUrl(url) {
|
|
217
|
+
return confirmations?.isWalletSurfaceUrl(url) ?? false;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function observeWalletSurface(targetId, url) {
|
|
221
|
+
confirmations?.observe(targetId, url);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function deferred() {
|
|
225
|
+
let resolve;
|
|
226
|
+
let reject;
|
|
227
|
+
const promise = new Promise((ok, fail) => {
|
|
228
|
+
resolve = ok;
|
|
229
|
+
reject = fail;
|
|
230
|
+
});
|
|
231
|
+
promise.catch(() => {});
|
|
232
|
+
return { promise, resolve, reject };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Each CDP call is bounded so a slow browser can never leave a new tab paused.
|
|
236
|
+
function bounded(promise, label, ms = 10000) {
|
|
237
|
+
let timer;
|
|
238
|
+
return Promise.race([
|
|
239
|
+
promise,
|
|
240
|
+
new Promise((_, reject) => {
|
|
241
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
242
|
+
}),
|
|
243
|
+
]).finally(() => clearTimeout(timer));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Register the binding and the document script first: neither needs the
|
|
247
|
+
// target running, while Runtime.enable/Page.enable can wait on a target that is
|
|
248
|
+
// paused for the debugger. Enable the domains only after the caller resumed it.
|
|
249
|
+
async function installAppHooks(sessionId, targetId) {
|
|
250
|
+
appSessions.add(sessionId);
|
|
251
|
+
sessionTargets.set(sessionId, targetId);
|
|
252
|
+
await bounded(binding.install(sessionId, targetId), 'Runtime.addBinding');
|
|
253
|
+
await bounded(
|
|
254
|
+
client.send(
|
|
255
|
+
'Page.addScriptToEvaluateOnNewDocument',
|
|
256
|
+
{ source, runImmediately: true },
|
|
257
|
+
sessionId,
|
|
258
|
+
),
|
|
259
|
+
'Page.addScriptToEvaluateOnNewDocument',
|
|
260
|
+
);
|
|
261
|
+
if (network === 'testnet') {
|
|
262
|
+
await bounded(
|
|
263
|
+
client.send(
|
|
264
|
+
'Fetch.enable',
|
|
265
|
+
{
|
|
266
|
+
patterns: mainnetHosts.map((host) => ({ urlPattern: `*://${host}/*` })),
|
|
267
|
+
},
|
|
268
|
+
sessionId,
|
|
269
|
+
),
|
|
270
|
+
'Fetch.enable',
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Venue traffic per app session: the served-network check. Only venue
|
|
276
|
+
// endpoints count (the block list and the testnet endpoints); other hosts on a
|
|
277
|
+
// venue domain, such as the coin icons on app.hyperliquid.xyz, are ignored.
|
|
278
|
+
const venueTraffic = new Map();
|
|
279
|
+
function recordVenueTraffic(sessionId, url) {
|
|
280
|
+
const host = hostOf(url);
|
|
281
|
+
if (!host || String(url).includes(PROBE_MARK)) return;
|
|
282
|
+
const side = mainnetHosts.includes(host)
|
|
283
|
+
? 'mainnet'
|
|
284
|
+
: testnetHosts.includes(host)
|
|
285
|
+
? 'testnet'
|
|
286
|
+
: null;
|
|
287
|
+
if (!side) return;
|
|
288
|
+
const traffic = venueTraffic.get(sessionId) ?? { testnet: new Set(), mainnet: new Set() };
|
|
289
|
+
traffic[side].add(host);
|
|
290
|
+
venueTraffic.set(sessionId, traffic);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// How the launch probes failed. The HTTP probe must have been failed by this
|
|
294
|
+
// host (Fetch.failRequest, which Chrome reports as net::ERR_BLOCKED_BY_CLIENT
|
|
295
|
+
// with the blocked reason inspector or other). A probe to the same host on a
|
|
296
|
+
// port the request block does not match must fail with the resolver rule's
|
|
297
|
+
// net::ERR_NAME_NOT_RESOLVED: the rule maps the host for every port and
|
|
298
|
+
// transport, so the WebSocket probe failing too is the resolver's doing. Any
|
|
299
|
+
// other failure (CORS, another blocker) does not prove the block. WebSocket
|
|
300
|
+
// error texts are kept as evidence; Chrome sometimes reports them empty.
|
|
301
|
+
const RESOLVER_PROBE_PORT = 444;
|
|
302
|
+
const probeRequests = new Map();
|
|
303
|
+
const probeFailures = { http: [], resolver: [], websocket: [] };
|
|
304
|
+
let probeFailedHere = false;
|
|
305
|
+
|
|
306
|
+
async function enableAppDomains(sessionId) {
|
|
307
|
+
await bounded(client.send('Runtime.enable', {}, sessionId), 'Runtime.enable');
|
|
308
|
+
await bounded(client.send('Page.enable', {}, sessionId), 'Page.enable');
|
|
309
|
+
if (network === 'testnet')
|
|
310
|
+
await bounded(client.send('Network.enable', {}, sessionId), 'Network.enable');
|
|
311
|
+
const { frameTree } = await bounded(
|
|
312
|
+
client.send('Page.getFrameTree', {}, sessionId),
|
|
313
|
+
'Page.getFrameTree',
|
|
314
|
+
);
|
|
315
|
+
if (frameTree?.frame?.url !== undefined) {
|
|
316
|
+
sessionCommittedUrls.set(sessionId, frameTree.frame.url);
|
|
317
|
+
binding.commit(sessionId, frameTree.frame.url, frameTree.frame.loaderId);
|
|
318
|
+
}
|
|
319
|
+
if (appSessions.has(sessionId)) hudSessions.add(sessionId);
|
|
320
|
+
binding.drain(sessionId);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Redraw the HUD on app pages. The expression is a no-op when the same state
|
|
324
|
+
// is already drawn or the page is not on the app origin.
|
|
325
|
+
async function drawHud(sessions = hudSessions) {
|
|
326
|
+
const expression = hudRenderExpression(readHudState(runtimeDir), appOrigin);
|
|
327
|
+
await Promise.all(
|
|
328
|
+
[...sessions].map((sessionId) =>
|
|
329
|
+
bounded(
|
|
330
|
+
client.send('Runtime.evaluate', { expression, returnByValue: true }, sessionId),
|
|
331
|
+
'HUD draw',
|
|
332
|
+
3000,
|
|
333
|
+
).catch(() => {}),
|
|
334
|
+
),
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
client.on('Target.attachedToTarget', async ({ sessionId, targetInfo, waitingForDebugger }) => {
|
|
339
|
+
const { targetId } = targetInfo;
|
|
340
|
+
const isApp =
|
|
341
|
+
targetInfo.type === 'page' && isAppUrl(targetInfo.url) && !hookedTargets.has(targetId);
|
|
342
|
+
let hooked;
|
|
343
|
+
if (isApp) {
|
|
344
|
+
hooked = deferred();
|
|
345
|
+
hookedTargets.set(targetId, { sessionId, ready: hooked.promise });
|
|
346
|
+
}
|
|
347
|
+
try {
|
|
348
|
+
if (isApp) await installAppHooks(sessionId, targetId);
|
|
349
|
+
else if (targetInfo.type === 'page') observeWalletSurface(targetId, targetInfo.url);
|
|
350
|
+
} catch (error) {
|
|
351
|
+
// A tab whose hooks did not all install is not an app tab: its requests
|
|
352
|
+
// would go unrecorded (or unblocked). The app tab's own failure fails ready.
|
|
353
|
+
say(`setup failed for ${targetInfo.url}: ${error.message}`);
|
|
354
|
+
appSessions.delete(sessionId);
|
|
355
|
+
hooked?.reject(error);
|
|
356
|
+
} finally {
|
|
357
|
+
if (waitingForDebugger)
|
|
358
|
+
await bounded(
|
|
359
|
+
client.send('Runtime.runIfWaitingForDebugger', {}, sessionId),
|
|
360
|
+
'runIfWaitingForDebugger',
|
|
361
|
+
).catch((error) => say(error.message));
|
|
362
|
+
if (!appSessions.has(sessionId))
|
|
363
|
+
await client.send('Target.detachFromTarget', { sessionId }).catch(() => {});
|
|
364
|
+
}
|
|
365
|
+
if (isApp && appSessions.has(sessionId)) {
|
|
366
|
+
try {
|
|
367
|
+
await enableAppDomains(sessionId);
|
|
368
|
+
say(`attached app page ${targetInfo.url || '(blank)'}`);
|
|
369
|
+
hooked.resolve(sessionId);
|
|
370
|
+
} catch (error) {
|
|
371
|
+
say(`enable failed for ${targetInfo.url}: ${error.message}`);
|
|
372
|
+
hooked.reject(error);
|
|
373
|
+
}
|
|
374
|
+
} else if (hooked) {
|
|
375
|
+
hooked.reject(new Error(`app hooks were not installed on ${targetInfo.url || 'a blank tab'}`));
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
client.on('Target.targetInfoChanged', ({ targetInfo }) => {
|
|
380
|
+
if (targetInfo.type === 'page') observeWalletSurface(targetInfo.targetId, targetInfo.url);
|
|
381
|
+
});
|
|
382
|
+
client.on('Target.targetCreated', ({ targetInfo }) => {
|
|
383
|
+
if (targetInfo.type === 'page') observeWalletSurface(targetInfo.targetId, targetInfo.url);
|
|
384
|
+
});
|
|
385
|
+
client.on('Target.detachedFromTarget', ({ sessionId }) => {
|
|
386
|
+
binding.detach(sessionId);
|
|
387
|
+
const targetId = sessionTargets.get(sessionId);
|
|
388
|
+
if (targetId && hookedTargets.get(targetId)?.sessionId === sessionId)
|
|
389
|
+
hookedTargets.delete(targetId);
|
|
390
|
+
appSessions.delete(sessionId);
|
|
391
|
+
hudSessions.delete(sessionId);
|
|
392
|
+
sessionTargets.delete(sessionId);
|
|
393
|
+
sessionCommittedUrls.delete(sessionId);
|
|
394
|
+
});
|
|
395
|
+
// What the app tab's first navigation did, for the diagnostic when it never
|
|
396
|
+
// commits: main-frame commits, and the document's response or failure.
|
|
397
|
+
const appNavigation = { commits: [], document: null };
|
|
398
|
+
client.on('Network.responseReceived', ({ type, response }, sessionId) => {
|
|
399
|
+
if (sessionId === appNavigation.session && type === 'Document')
|
|
400
|
+
appNavigation.document = `HTTP ${response.status} for ${pathOf(response.url)}`;
|
|
401
|
+
});
|
|
402
|
+
client.on('Network.loadingFailed', ({ type, errorText, canceled }, sessionId) => {
|
|
403
|
+
if (sessionId === appNavigation.session && type === 'Document')
|
|
404
|
+
appNavigation.document = `load failed: ${errorText}${canceled ? ' (canceled)' : ''}`;
|
|
405
|
+
});
|
|
406
|
+
client.on('Page.frameNavigated', ({ frame }, sessionId) => {
|
|
407
|
+
if (sessionId === appNavigation.session && !frame.parentId)
|
|
408
|
+
appNavigation.commits.push(pathOf(frame.url));
|
|
409
|
+
if (appSessions.has(sessionId) && !frame.parentId) {
|
|
410
|
+
sessionCommittedUrls.set(sessionId, frame.url);
|
|
411
|
+
binding.commit(sessionId, frame.url, frame.loaderId);
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
client.on('Fetch.requestPaused', ({ requestId, request, resourceType }, sessionId) => {
|
|
415
|
+
const mainnet = isBlockedUrl(request.url, mainnetHosts);
|
|
416
|
+
if (mainnet) {
|
|
417
|
+
appendEntry({
|
|
418
|
+
kind: 'blocked-mainnet',
|
|
419
|
+
t: Date.now(),
|
|
420
|
+
transport: 'http',
|
|
421
|
+
url: shortUrl(request.url),
|
|
422
|
+
resourceType,
|
|
423
|
+
probe: request.url.includes(PROBE_MARK),
|
|
424
|
+
});
|
|
425
|
+
say(`blocked a mainnet request to ${shortUrl(request.url)}`);
|
|
426
|
+
}
|
|
427
|
+
const command = mainnet
|
|
428
|
+
? client.send('Fetch.failRequest', { requestId, errorReason: 'BlockedByClient' }, sessionId)
|
|
429
|
+
: client.send('Fetch.continueRequest', { requestId }, sessionId);
|
|
430
|
+
command.then(
|
|
431
|
+
() => {
|
|
432
|
+
if (mainnet && request.url.includes(PROBE_MARK)) probeFailedHere = true;
|
|
433
|
+
},
|
|
434
|
+
() => {},
|
|
435
|
+
);
|
|
436
|
+
});
|
|
437
|
+
client.on('Network.requestWillBeSent', ({ requestId, request }, sessionId) => {
|
|
438
|
+
if (request.url.includes(PROBE_MARK))
|
|
439
|
+
probeRequests.set(
|
|
440
|
+
requestId,
|
|
441
|
+
new URL(request.url).port === String(RESOLVER_PROBE_PORT) ? 'resolver' : 'http',
|
|
442
|
+
);
|
|
443
|
+
recordVenueTraffic(sessionId, request.url);
|
|
444
|
+
});
|
|
445
|
+
client.on('Network.loadingFailed', ({ requestId, errorText, blockedReason }) => {
|
|
446
|
+
const kind = probeRequests.get(requestId);
|
|
447
|
+
if (kind === 'http' || kind === 'resolver')
|
|
448
|
+
probeFailures[kind].push({ errorText, blockedReason: blockedReason ?? null });
|
|
449
|
+
});
|
|
450
|
+
client.on('Network.webSocketFrameError', ({ requestId, errorMessage }) => {
|
|
451
|
+
if (probeRequests.get(requestId) === 'websocket')
|
|
452
|
+
probeFailures.websocket.push({ errorText: errorMessage });
|
|
453
|
+
});
|
|
454
|
+
client.on('Network.webSocketCreated', ({ requestId, url }, sessionId) => {
|
|
455
|
+
if (url.includes(PROBE_MARK)) probeRequests.set(requestId, 'websocket');
|
|
456
|
+
recordVenueTraffic(sessionId, url);
|
|
457
|
+
// The host resolver rule fails the connection; the attempt is still evidence.
|
|
458
|
+
if (isBlockedUrl(url, mainnetHosts)) {
|
|
459
|
+
appendEntry({
|
|
460
|
+
kind: 'blocked-mainnet',
|
|
461
|
+
t: Date.now(),
|
|
462
|
+
transport: 'websocket',
|
|
463
|
+
url: shortUrl(url),
|
|
464
|
+
probe: url.includes(PROBE_MARK),
|
|
465
|
+
});
|
|
466
|
+
say(`blocked a mainnet WebSocket to ${shortUrl(url)}`);
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
for (const event of [
|
|
470
|
+
'Page.domContentEventFired',
|
|
471
|
+
'Page.loadEventFired',
|
|
472
|
+
'Page.navigatedWithinDocument',
|
|
473
|
+
]) {
|
|
474
|
+
client.on(event, (_params, sessionId) => {
|
|
475
|
+
if (hudSessions.has(sessionId)) drawHud([sessionId]);
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
// The file watch catches state changes; the slow repeat redraws a HUD that a
|
|
479
|
+
// client-side render removed.
|
|
480
|
+
watchFile(hudFile(runtimeDir), { interval: 500 }, () => drawHud());
|
|
481
|
+
const hudRepeat = setInterval(() => drawHud(), 2000);
|
|
482
|
+
|
|
483
|
+
client.on('close', () => {
|
|
484
|
+
clearInterval(hudRepeat);
|
|
485
|
+
unwatchFile(hudFile(runtimeDir));
|
|
486
|
+
say('browser connection closed; exiting');
|
|
487
|
+
process.exit(0);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
function pathOf(url) {
|
|
491
|
+
try {
|
|
492
|
+
const parsed = new URL(url);
|
|
493
|
+
return parsed.protocol === 'about:' ? url : `${parsed.origin}${parsed.pathname}`;
|
|
494
|
+
} catch {
|
|
495
|
+
return '?';
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Why the app tab never committed its first page (Codex #298 round 7: a
|
|
500
|
+
// 120 s stall seen once on mini): what Page.navigate answered, the tab's
|
|
501
|
+
// commits and document response, where the tab is now, and whether the dev
|
|
502
|
+
// server answers the same URL outside the browser.
|
|
503
|
+
async function commitStallDetail(navigation) {
|
|
504
|
+
const info = await bounded(
|
|
505
|
+
client.send('Target.getTargetInfo', { targetId: appTargetId }),
|
|
506
|
+
'Target.getTargetInfo',
|
|
507
|
+
5000,
|
|
508
|
+
).catch(() => null);
|
|
509
|
+
const server = await fetch(startUrl, { signal: AbortSignal.timeout(5000), redirect: 'manual' })
|
|
510
|
+
.then((response) => `HTTP ${response.status}`)
|
|
511
|
+
.catch((error) => (error?.name === 'TimeoutError' ? 'no answer in 5 s' : 'unreachable'));
|
|
512
|
+
return [
|
|
513
|
+
`navigate answered ${navigation?.errorText ? `error ${navigation.errorText}` : `loader ${navigation?.loaderId ? 'set' : 'unset'}`}`,
|
|
514
|
+
`commits [${appNavigation.commits.join(', ') || 'none'}]`,
|
|
515
|
+
`document ${appNavigation.document ?? 'no response seen'}`,
|
|
516
|
+
`tab now at ${pathOf(info?.targetInfo?.url ?? '')}`,
|
|
517
|
+
`dev server ${server}`,
|
|
518
|
+
].join('; ');
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function fail(message) {
|
|
522
|
+
say(`not ready: ${message}`);
|
|
523
|
+
process.exit(1);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
await client.send('Target.setDiscoverTargets', { discover: true });
|
|
527
|
+
await client.send('Target.setAutoAttach', {
|
|
528
|
+
autoAttach: true,
|
|
529
|
+
waitForDebuggerOnStart: true,
|
|
530
|
+
flatten: true,
|
|
531
|
+
});
|
|
532
|
+
const { targetInfos } = await client.send('Target.getTargets');
|
|
533
|
+
for (const info of targetInfos) {
|
|
534
|
+
if (info.type === 'page') observeWalletSurface(info.targetId, info.url);
|
|
535
|
+
}
|
|
536
|
+
// The app gets a tab of its own, created blank: hooks and domains go in while
|
|
537
|
+
// nothing has loaded, then the tab navigates to the app.
|
|
538
|
+
// Created in the background so the browser is never activated (it would take
|
|
539
|
+
// the operator's keyboard focus on macOS). A visible browser started with no
|
|
540
|
+
// window gets the app in a new background window at the slot's bounds.
|
|
541
|
+
const windowBounds = String(args.window ?? '')
|
|
542
|
+
.split(',')
|
|
543
|
+
.map(Number);
|
|
544
|
+
const createParams =
|
|
545
|
+
windowBounds.length === 4 && windowBounds.every(Number.isFinite)
|
|
546
|
+
? {
|
|
547
|
+
url: 'about:blank',
|
|
548
|
+
newWindow: true,
|
|
549
|
+
background: true,
|
|
550
|
+
left: windowBounds[0],
|
|
551
|
+
top: windowBounds[1],
|
|
552
|
+
width: windowBounds[2],
|
|
553
|
+
height: windowBounds[3],
|
|
554
|
+
}
|
|
555
|
+
: { url: 'about:blank', newWindow: true, background: true };
|
|
556
|
+
const { targetId: appTargetId } = await bounded(
|
|
557
|
+
// No foreground fallback: a tab that is not created in the background
|
|
558
|
+
// activates the browser.
|
|
559
|
+
client
|
|
560
|
+
.send('Target.createTarget', createParams)
|
|
561
|
+
.catch(() => client.send('Target.createTarget', { url: 'about:blank', background: true })),
|
|
562
|
+
'Target.createTarget',
|
|
563
|
+
60000,
|
|
564
|
+
);
|
|
565
|
+
const appStart = Date.now();
|
|
566
|
+
while (!hookedTargets.has(appTargetId) && Date.now() - appStart < 60000)
|
|
567
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
568
|
+
if (!hookedTargets.has(appTargetId)) fail(`app tab ${appTargetId} was never attached`);
|
|
569
|
+
const appSession = await bounded(
|
|
570
|
+
hookedTargets.get(appTargetId).ready,
|
|
571
|
+
'app tab hooks',
|
|
572
|
+
60000,
|
|
573
|
+
).catch((error) => fail(error.message));
|
|
574
|
+
// Every other tab (the initial blank tab, app tabs restored with a reused
|
|
575
|
+
// profile, MetaMask's home tab) is closed, as the Extension launcher closes its
|
|
576
|
+
// startup tabs: the hooked tab is then the only app tab and the active tab of
|
|
577
|
+
// its window, so it paints without ever being brought to front.
|
|
578
|
+
const { targetInfos: startupTabs } = await client
|
|
579
|
+
.send('Target.getTargets')
|
|
580
|
+
.catch(() => ({ targetInfos }));
|
|
581
|
+
for (const info of startupTabs) {
|
|
582
|
+
if (info.type === 'page' && info.targetId !== appTargetId && !isWalletSurfaceUrl(info.url)) {
|
|
583
|
+
await client.send('Target.closeTarget', { targetId: info.targetId }).catch(() => {});
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
// The first app document is a market page: it streams from the venue in the
|
|
587
|
+
// browser, which the served-network check below observes (the home page's
|
|
588
|
+
// data comes from the server render).
|
|
589
|
+
const startUrl = new URL(args['start-path'] || policy.startPath, `${appOrigin}/`).href;
|
|
590
|
+
appNavigation.session = appSession;
|
|
591
|
+
const navigation = await bounded(
|
|
592
|
+
client.send('Page.navigate', { url: startUrl }, appSession),
|
|
593
|
+
'Page.navigate',
|
|
594
|
+
60000,
|
|
595
|
+
).catch((error) => fail(error.message));
|
|
596
|
+
const navigateStart = Date.now();
|
|
597
|
+
// A cold dev server compiles the first page before it answers.
|
|
598
|
+
const commitTimeoutMs = Number(process.env.MM_HARNESS_APP_COMMIT_TIMEOUT_MS) || 120000;
|
|
599
|
+
while (
|
|
600
|
+
!isExactAppUrl(sessionCommittedUrls.get(appSession), appOrigin) &&
|
|
601
|
+
Date.now() - navigateStart < commitTimeoutMs
|
|
602
|
+
) {
|
|
603
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
604
|
+
}
|
|
605
|
+
if (!isExactAppUrl(sessionCommittedUrls.get(appSession), appOrigin))
|
|
606
|
+
fail(`the app tab did not commit ${startUrl}: ${await commitStallDetail(navigation)}`);
|
|
607
|
+
|
|
608
|
+
const evaluate = (expression, timeoutMs = 5000) =>
|
|
609
|
+
bounded(
|
|
610
|
+
client.send(
|
|
611
|
+
'Runtime.evaluate',
|
|
612
|
+
{ expression, returnByValue: true, awaitPromise: true },
|
|
613
|
+
appSession,
|
|
614
|
+
),
|
|
615
|
+
'app evaluation',
|
|
616
|
+
timeoutMs,
|
|
617
|
+
)
|
|
618
|
+
.then((response) => response?.result?.value)
|
|
619
|
+
.catch(() => undefined);
|
|
620
|
+
|
|
621
|
+
// The first app document must carry the log binding and the installed
|
|
622
|
+
// preload (its marker), with the page's provider wrapped, or requests on it
|
|
623
|
+
// would go unrecorded.
|
|
624
|
+
const readyExpression = pageReadyExpression({ signer, refusesTypedData: refuseTypedData !== null });
|
|
625
|
+
let hooksReady = false;
|
|
626
|
+
for (let attempt = 0; attempt < 50 && !hooksReady; attempt += 1) {
|
|
627
|
+
hooksReady = (await evaluate(readyExpression)) === true;
|
|
628
|
+
if (!hooksReady) await new Promise((resolve) => setTimeout(resolve, 200));
|
|
629
|
+
}
|
|
630
|
+
if (!hooksReady)
|
|
631
|
+
fail('the app document has no wallet log binding, no harness preload, or an unwrapped provider');
|
|
632
|
+
|
|
633
|
+
if (network === 'testnet') {
|
|
634
|
+
// The block must hold before anything runs: a probe to the mainnet venue
|
|
635
|
+
// from the app page has to fail (HTTP through the request block and the
|
|
636
|
+
// resolver rule, WebSocket through the resolver rule).
|
|
637
|
+
const { probe } = policy;
|
|
638
|
+
const host = mainnetHosts.includes(probe.host) ? probe.host : mainnetHosts[0];
|
|
639
|
+
const httpProbe = await evaluate(
|
|
640
|
+
`fetch('https://${host}${probe.httpPath}?${PROBE_MARK}', { method: 'POST', headers: { 'content-type': 'application/json' }, body: ${JSON.stringify(probe.httpBody)} }).then(() => 'reached', () => 'blocked')`,
|
|
641
|
+
20000,
|
|
642
|
+
);
|
|
643
|
+
const resolverProbe = await evaluate(
|
|
644
|
+
`fetch('https://${host}:${RESOLVER_PROBE_PORT}${probe.httpPath}?${PROBE_MARK}', { method: 'POST' }).then(() => 'reached', () => 'blocked')`,
|
|
645
|
+
20000,
|
|
646
|
+
);
|
|
647
|
+
const wsProbe = await evaluate(
|
|
648
|
+
`new Promise((resolve) => { const socket = new WebSocket('wss://${host}${probe.wsPath}?${PROBE_MARK}'); const done = (value) => { try { socket.close(); } catch {} resolve(value); }; socket.onopen = () => done('reached'); socket.onerror = () => done('blocked'); setTimeout(() => done('timeout'), 10000); })`,
|
|
649
|
+
20000,
|
|
650
|
+
);
|
|
651
|
+
if (httpProbe !== 'blocked' || resolverProbe !== 'blocked' || wsProbe !== 'blocked') {
|
|
652
|
+
fail(
|
|
653
|
+
`the mainnet venue is reachable from the app (http ${httpProbe}, resolver ${resolverProbe}, websocket ${wsProbe})`,
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
const blockedHere = () =>
|
|
657
|
+
probeFailedHere &&
|
|
658
|
+
probeFailures.http.some((failure) =>
|
|
659
|
+
/^net::ERR_BLOCKED_BY_CLIENT\b/u.test(failure.errorText ?? ''),
|
|
660
|
+
);
|
|
661
|
+
const unresolved = () =>
|
|
662
|
+
probeFailures.resolver.some((failure) =>
|
|
663
|
+
/^net::ERR_NAME_NOT_RESOLVED\b/u.test(failure.errorText ?? ''),
|
|
664
|
+
);
|
|
665
|
+
const probeStart = Date.now();
|
|
666
|
+
while (!(blockedHere() && unresolved()) && Date.now() - probeStart < 5000)
|
|
667
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
668
|
+
const seen = (failures) =>
|
|
669
|
+
failures
|
|
670
|
+
.map((failure) => [failure.errorText, failure.blockedReason].filter(Boolean).join(' '))
|
|
671
|
+
.join('; ') || 'no failure reported';
|
|
672
|
+
if (!blockedHere())
|
|
673
|
+
fail(
|
|
674
|
+
`the HTTP probe failed, but not by the request block (${seen(probeFailures.http)}; failed here: ${probeFailedHere})`,
|
|
675
|
+
);
|
|
676
|
+
if (!unresolved())
|
|
677
|
+
fail(
|
|
678
|
+
`the resolver probe failed, but not by the host resolver rule (${seen(probeFailures.resolver)})`,
|
|
679
|
+
);
|
|
680
|
+
// The app must actually be talking to the testnet venue.
|
|
681
|
+
const servedStart = Date.now();
|
|
682
|
+
let traffic = venueTraffic.get(appSession);
|
|
683
|
+
const servedTimeoutMs = Number(process.env.MM_HARNESS_SERVED_TIMEOUT_MS) || 90000;
|
|
684
|
+
while (
|
|
685
|
+
servedApplicable &&
|
|
686
|
+
(!traffic || (traffic.testnet.size === 0 && traffic.mainnet.size === 0)) &&
|
|
687
|
+
Date.now() - servedStart < servedTimeoutMs
|
|
688
|
+
) {
|
|
689
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
690
|
+
traffic = venueTraffic.get(appSession);
|
|
691
|
+
}
|
|
692
|
+
if (traffic?.mainnet.size)
|
|
693
|
+
fail(
|
|
694
|
+
`the app requested the mainnet venue (${[...traffic.mainnet].join(', ')}): the dev server does not serve testnet`,
|
|
695
|
+
);
|
|
696
|
+
if (servedApplicable && !traffic?.testnet.size)
|
|
697
|
+
fail(
|
|
698
|
+
`the app made no request to a testnet venue endpoint (${testnetHosts.join(', ') || 'none known'}) within ${Math.round(servedTimeoutMs / 1000)}s, so its network could not be checked`,
|
|
699
|
+
);
|
|
700
|
+
appendEntry({
|
|
701
|
+
kind: 'network-enforcement',
|
|
702
|
+
t: Date.now(),
|
|
703
|
+
network,
|
|
704
|
+
enforcement: 'enforced',
|
|
705
|
+
mainnetHosts,
|
|
706
|
+
servedHosts: [...(traffic?.testnet ?? [])],
|
|
707
|
+
...(servedApplicable ? {} : { served: 'not-applicable' }),
|
|
708
|
+
probes: {
|
|
709
|
+
http: seen(probeFailures.http),
|
|
710
|
+
resolver: seen(probeFailures.resolver),
|
|
711
|
+
websocket: seen(probeFailures.websocket),
|
|
712
|
+
},
|
|
713
|
+
});
|
|
714
|
+
} else {
|
|
715
|
+
appendEntry({
|
|
716
|
+
kind: 'network-enforcement',
|
|
717
|
+
t: Date.now(),
|
|
718
|
+
network,
|
|
719
|
+
enforcement: 'disabled',
|
|
720
|
+
reason: 'network=mainnet with mainnet_confirmation=REAL_FUNDS',
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
say(`opened app tab ${appTargetId}`);
|
|
724
|
+
writeFileSync(
|
|
725
|
+
path.join(runtimeDir, 'wallet-host.ready'),
|
|
726
|
+
`${JSON.stringify({ pid: process.pid, signer, network, at: new Date().toISOString() })}\n`,
|
|
727
|
+
);
|
|
728
|
+
say(`ready (signer=${signer}, network=${network}, app=${appOrigin})`);
|