@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,438 @@
|
|
|
1
|
+
// Read-only readiness of a web-dapp slot for recipe runs: checkout shape,
|
|
2
|
+
// dependencies, the slot dev server, forced testnet, wallet fixture, browser,
|
|
3
|
+
// the extension signer's own checks (its `readinessChecks` hook) and the
|
|
4
|
+
// per-slot CDP port. Never launches anything except the optional short-lived
|
|
5
|
+
// browser start probe.
|
|
6
|
+
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { ownsProcess, pidAlive, portFree } from './processes.mjs';
|
|
12
|
+
import {
|
|
13
|
+
canonicalPath,
|
|
14
|
+
fixtureEntry,
|
|
15
|
+
loadWalletFixture,
|
|
16
|
+
readBrowserState,
|
|
17
|
+
viemAccountFromEntry,
|
|
18
|
+
webDappPolicy,
|
|
19
|
+
webDappRuntimeDir,
|
|
20
|
+
} from './runtime.mjs';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} id
|
|
24
|
+
* @param {boolean} ok
|
|
25
|
+
* @param {string} detail
|
|
26
|
+
* @param {{ required?: boolean, status?: 'pass' | 'fail' | 'warn' }} [options]
|
|
27
|
+
*/
|
|
28
|
+
export function check(id, ok, detail, { required = true, status } = {}) {
|
|
29
|
+
return { id, status: status ?? (ok ? 'pass' : 'fail'), required, detail };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function httpStatus(url, timeoutMs = 20000) {
|
|
33
|
+
try {
|
|
34
|
+
const response = await fetch(url, {
|
|
35
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
36
|
+
redirect: 'manual',
|
|
37
|
+
});
|
|
38
|
+
return response.status;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
return error?.name === 'TimeoutError' ? 'timeout' : 'unreachable';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Diagnostic only. Whether the slot's dev server looks configured for testnet:
|
|
45
|
+
// the farm starts `next dev` (temp/farmslot/next-dev.pid) with the policy's
|
|
46
|
+
// testnet variable set to true (the Terminal's
|
|
47
|
+
// NEXT_PUBLIC_HYPERLIQUID_FORCE_TESTNET). Process inspection cannot prove
|
|
48
|
+
// what the browser talks to (macOS reports a process's initial environment,
|
|
49
|
+
// and lsof cannot see other users' sockets), so this only warns; the browser
|
|
50
|
+
// is held to testnet by the network and wallet layers (the venue policy's
|
|
51
|
+
// blocked hosts, the wallet host). Each listener on the app port must descend
|
|
52
|
+
// from the pid-file process and run from the checkout (its cwd); every process on the way from
|
|
53
|
+
// a listener up to the pid-file process is read exactly (argv and environment
|
|
54
|
+
// entries from sysctl KERN_PROCARGS2) and must carry the testnet variable. An
|
|
55
|
+
// unreadable process makes the result unknown.
|
|
56
|
+
export const DEV_SERVER_PID_FILE = 'temp/farmslot/next-dev.pid';
|
|
57
|
+
|
|
58
|
+
function run(command, args) {
|
|
59
|
+
try {
|
|
60
|
+
return execFileSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
61
|
+
} catch (error) {
|
|
62
|
+
return typeof error?.stdout === 'string' ? error.stdout : '';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const PROCARGS_HELPER = `
|
|
67
|
+
import ctypes, ctypes.util, json, struct, sys
|
|
68
|
+
libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
|
|
69
|
+
pid = int(sys.argv[1])
|
|
70
|
+
argmax = ctypes.c_int(0)
|
|
71
|
+
size = ctypes.c_size_t(ctypes.sizeof(argmax))
|
|
72
|
+
if libc.sysctl((ctypes.c_int * 2)(1, 8), 2, ctypes.byref(argmax), ctypes.byref(size), None, 0) != 0:
|
|
73
|
+
print("null"); sys.exit(0)
|
|
74
|
+
buf = ctypes.create_string_buffer(argmax.value)
|
|
75
|
+
size = ctypes.c_size_t(argmax.value)
|
|
76
|
+
if libc.sysctl((ctypes.c_int * 3)(1, 49, pid), 3, buf, ctypes.byref(size), None, 0) != 0:
|
|
77
|
+
print("null"); sys.exit(0)
|
|
78
|
+
raw = buf.raw[:size.value]
|
|
79
|
+
argc = struct.unpack("i", raw[:4])[0]
|
|
80
|
+
rest = raw[4:]
|
|
81
|
+
rest = rest[rest.index(b"\\0"):].lstrip(b"\\0")
|
|
82
|
+
parts = rest.split(b"\\0")
|
|
83
|
+
env = []
|
|
84
|
+
for part in parts[argc:]:
|
|
85
|
+
if not part: break
|
|
86
|
+
env.append(part.decode("utf-8", "replace"))
|
|
87
|
+
print(json.dumps({"argv": [p.decode("utf-8", "replace") for p in parts[:argc]], "env": env}))
|
|
88
|
+
`;
|
|
89
|
+
|
|
90
|
+
export const systemProbe = Object.freeze({
|
|
91
|
+
listeners: (port) =>
|
|
92
|
+
run('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'])
|
|
93
|
+
.split('\n')
|
|
94
|
+
.map(Number)
|
|
95
|
+
.filter((pid) => pid > 0),
|
|
96
|
+
parents: () =>
|
|
97
|
+
new Map(
|
|
98
|
+
run('ps', ['-A', '-o', 'pid=,ppid='])
|
|
99
|
+
.split('\n')
|
|
100
|
+
.map((line) => line.trim().split(/\s+/u).map(Number))
|
|
101
|
+
.filter(([pid, ppid]) => pid > 0 && ppid >= 0),
|
|
102
|
+
),
|
|
103
|
+
cwd: (pid) =>
|
|
104
|
+
(
|
|
105
|
+
run('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'])
|
|
106
|
+
.split('\n')
|
|
107
|
+
.find((line) => line.startsWith('n')) ?? ''
|
|
108
|
+
).slice(1) || null,
|
|
109
|
+
// { argv: string[], env: string[] } with exact element boundaries, or null.
|
|
110
|
+
procArgs: (pid) => {
|
|
111
|
+
const python = usablePython();
|
|
112
|
+
if (!python) return null;
|
|
113
|
+
try {
|
|
114
|
+
return JSON.parse(
|
|
115
|
+
execFileSync(python, ['-c', PROCARGS_HELPER, String(pid)], {
|
|
116
|
+
encoding: 'utf8',
|
|
117
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
118
|
+
timeout: 15000,
|
|
119
|
+
}).trim(),
|
|
120
|
+
);
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// macOS's /usr/bin/python3 is a stub until the Command Line Tools are
|
|
128
|
+
// installed, and running it opens an install dialog that takes keyboard
|
|
129
|
+
// focus. Use it only when xcode-select reports a developer directory; any
|
|
130
|
+
// other python3 on PATH is a real install.
|
|
131
|
+
export function pythonFor({ found, platform = process.platform, developerDir }) {
|
|
132
|
+
if (!found) return null;
|
|
133
|
+
if (platform === 'darwin' && found === '/usr/bin/python3' && !developerDir()) return null;
|
|
134
|
+
return found;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let pythonChoice;
|
|
138
|
+
function usablePython() {
|
|
139
|
+
if (pythonChoice === undefined) {
|
|
140
|
+
pythonChoice = pythonFor({
|
|
141
|
+
found: run('/usr/bin/which', ['python3']).trim(),
|
|
142
|
+
developerDir: () => run('/usr/bin/xcode-select', ['-p']).trim(),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return pythonChoice;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function environmentValue(env, name) {
|
|
149
|
+
const entry = env.find((item) => item.startsWith(`${name}=`));
|
|
150
|
+
return entry === undefined ? undefined : entry.slice(name.length + 1);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// `next dev` / `next start`: the argument after the next binary.
|
|
154
|
+
export function nextServerMode(argv) {
|
|
155
|
+
for (let index = 0; index < argv.length - 1; index += 1) {
|
|
156
|
+
const element = argv[index];
|
|
157
|
+
if (
|
|
158
|
+
element === 'next' ||
|
|
159
|
+
element.endsWith('/next') ||
|
|
160
|
+
element.endsWith('/next/dist/bin/next')
|
|
161
|
+
) {
|
|
162
|
+
if (argv[index + 1] === 'dev' || argv[index + 1] === 'start') return argv[index + 1];
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function chainTo(listener, root, parents) {
|
|
169
|
+
const chain = [];
|
|
170
|
+
let current = listener;
|
|
171
|
+
for (let hops = 0; current && hops < 64; hops += 1) {
|
|
172
|
+
chain.push(current);
|
|
173
|
+
if (current === root) return chain;
|
|
174
|
+
current = parents.get(current);
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* @param {string} target
|
|
181
|
+
* @param {{ appPort?: number, variable?: string, probe?: typeof systemProbe }} [options]
|
|
182
|
+
*/
|
|
183
|
+
export function devServerTestnetDiagnostic(
|
|
184
|
+
target,
|
|
185
|
+
{ appPort, variable, probe = systemProbe } = {},
|
|
186
|
+
) {
|
|
187
|
+
const checkout = canonicalPath(target);
|
|
188
|
+
const unknown = (detail) => ({ known: false, forced: false, detail });
|
|
189
|
+
const pidFile = path.join(checkout, DEV_SERVER_PID_FILE);
|
|
190
|
+
if (!existsSync(pidFile)) return unknown(`no ${DEV_SERVER_PID_FILE}`);
|
|
191
|
+
const root = Number(readFileSync(pidFile, 'utf8').trim());
|
|
192
|
+
if (!pidAlive(root)) return unknown(`next dev pid ${root} is not running`);
|
|
193
|
+
if (!appPort) return unknown('no app port to check');
|
|
194
|
+
const listeners = probe.listeners(appPort);
|
|
195
|
+
if (listeners.length === 0)
|
|
196
|
+
return unknown(`nothing (visible to this user) listens on app port ${appPort}`);
|
|
197
|
+
const parents = probe.parents();
|
|
198
|
+
let mode = null;
|
|
199
|
+
for (const listener of listeners) {
|
|
200
|
+
const chain = chainTo(listener, root, parents);
|
|
201
|
+
if (!chain)
|
|
202
|
+
return {
|
|
203
|
+
known: true,
|
|
204
|
+
forced: false,
|
|
205
|
+
detail: `app port ${appPort} is served by pid ${listener}, outside next dev pid ${root}`,
|
|
206
|
+
};
|
|
207
|
+
const cwd = probe.cwd(listener);
|
|
208
|
+
if (!cwd || canonicalPath(cwd) !== checkout) {
|
|
209
|
+
return {
|
|
210
|
+
known: true,
|
|
211
|
+
forced: false,
|
|
212
|
+
detail: `pid ${listener} on app port ${appPort} runs from ${cwd ?? 'an unknown directory'}, not ${checkout}`,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
for (const pid of chain) {
|
|
216
|
+
const args = probe.procArgs(pid);
|
|
217
|
+
if (!args) return unknown(`cannot read the arguments and environment of pid ${pid}`);
|
|
218
|
+
// A process that rewrote its title (next-server) overwrote the memory
|
|
219
|
+
// its arguments and environment are read from.
|
|
220
|
+
if (args.env.length === 0)
|
|
221
|
+
return unknown(
|
|
222
|
+
`pid ${pid} (${args.argv[0] ?? '?'}) rewrote its process title; its environment is not readable`,
|
|
223
|
+
);
|
|
224
|
+
const processMode = nextServerMode(args.argv);
|
|
225
|
+
if (processMode === 'start')
|
|
226
|
+
return {
|
|
227
|
+
known: true,
|
|
228
|
+
forced: false,
|
|
229
|
+
detail: `pid ${pid} runs next start; its environment cannot prove what the build inlined`,
|
|
230
|
+
};
|
|
231
|
+
if (processMode === 'dev') mode = 'dev';
|
|
232
|
+
const value = environmentValue(args.env, variable);
|
|
233
|
+
if (value !== 'true') {
|
|
234
|
+
return {
|
|
235
|
+
known: true,
|
|
236
|
+
forced: false,
|
|
237
|
+
detail: `pid ${pid} (between app port ${appPort} and next dev pid ${root}) has ${variable}=${value ?? '<unset>'}`,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (mode !== 'dev')
|
|
243
|
+
return {
|
|
244
|
+
known: true,
|
|
245
|
+
forced: false,
|
|
246
|
+
detail: `no process serving app port ${appPort} runs next dev`,
|
|
247
|
+
};
|
|
248
|
+
return {
|
|
249
|
+
known: true,
|
|
250
|
+
forced: true,
|
|
251
|
+
detail: `every process from app port ${appPort}'s listeners (cwd ${checkout}) up to next dev pid ${root} has ${variable}=true`,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export const MAINNET_CONFIRMATION = 'REAL_FUNDS';
|
|
256
|
+
|
|
257
|
+
// The launch only checks the requested network: mainnet needs the real-funds
|
|
258
|
+
// confirmation. Testnet is enforced inside the browser (the venue policy's blocked hosts).
|
|
259
|
+
/**
|
|
260
|
+
* @param {string} _target
|
|
261
|
+
* @param {{ network?: string | null, mainnetConfirmation?: string }} [options]
|
|
262
|
+
* @returns {'testnet' | 'mainnet'}
|
|
263
|
+
*/
|
|
264
|
+
export function assertLaunchNetwork(_target, { network, mainnetConfirmation } = {}) {
|
|
265
|
+
const requested = network == null || network === '' ? 'testnet' : String(network);
|
|
266
|
+
if (requested !== 'testnet' && requested !== 'mainnet') {
|
|
267
|
+
throw new Error(`network must be testnet or mainnet, got ${JSON.stringify(network)}.`);
|
|
268
|
+
}
|
|
269
|
+
if (requested === 'mainnet' && mainnetConfirmation !== MAINNET_CONFIRMATION) {
|
|
270
|
+
throw new Error(
|
|
271
|
+
`network=mainnet needs mainnet_confirmation=${MAINNET_CONFIRMATION}; refusing to launch the ${webDappPolicy().adapterId} browser.`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
return requested;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// "a, b and c".
|
|
278
|
+
function listing(names) {
|
|
279
|
+
return names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names.at(-1)}` : names.join('');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// `policy`: the venue policy (checkout, dependencies, startPath, testnetVariable);
|
|
283
|
+
// by default the one RECIPE_WEB_DAPP_POLICY names. `signers`: the signer hooks
|
|
284
|
+
// by mode; signer=extension is judged by `signers.extension.readinessChecks`
|
|
285
|
+
// (`{ target, env, required }`, returning checks built with `check`).
|
|
286
|
+
/**
|
|
287
|
+
* @param {{
|
|
288
|
+
* policy?: any,
|
|
289
|
+
* signers?: Record<string, any>,
|
|
290
|
+
* target: string,
|
|
291
|
+
* appPort?: number,
|
|
292
|
+
* cdpPort?: number,
|
|
293
|
+
* signer?: string,
|
|
294
|
+
* account?: string,
|
|
295
|
+
* env?: NodeJS.ProcessEnv,
|
|
296
|
+
* probeBrowser?: (env: NodeJS.ProcessEnv) => Promise<{ bin: string, version: string | null, source: string }>,
|
|
297
|
+
* }} options
|
|
298
|
+
*/
|
|
299
|
+
export async function webDappReadiness({
|
|
300
|
+
policy = webDappPolicy(),
|
|
301
|
+
signers = {},
|
|
302
|
+
target,
|
|
303
|
+
appPort,
|
|
304
|
+
cdpPort,
|
|
305
|
+
// As defaultSigner: extension only when the host gave an extension signer.
|
|
306
|
+
signer = signers?.extension ? 'extension' : 'injected',
|
|
307
|
+
account = 'dev1',
|
|
308
|
+
env = process.env,
|
|
309
|
+
probeBrowser = undefined,
|
|
310
|
+
}) {
|
|
311
|
+
const checks = [];
|
|
312
|
+
const shape = policy.checkout.matches(target);
|
|
313
|
+
checks.push(
|
|
314
|
+
check(
|
|
315
|
+
'checkout',
|
|
316
|
+
shape,
|
|
317
|
+
shape ? policy.checkout.label : `${target} is not a ${policy.checkout.name} checkout`,
|
|
318
|
+
),
|
|
319
|
+
);
|
|
320
|
+
const deps = policy.dependencies.filter(
|
|
321
|
+
(name) => !existsSync(path.join(target, 'node_modules', name, 'package.json')),
|
|
322
|
+
);
|
|
323
|
+
checks.push(
|
|
324
|
+
check(
|
|
325
|
+
'dependencies',
|
|
326
|
+
deps.length === 0,
|
|
327
|
+
deps.length === 0
|
|
328
|
+
? `${listing(policy.dependencies)} installed`
|
|
329
|
+
: `missing: ${deps.join(', ')} (run npm ci on Node 22)`,
|
|
330
|
+
),
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
if (appPort) {
|
|
334
|
+
const status = await httpStatus(`http://127.0.0.1:${appPort}${policy.startPath}`);
|
|
335
|
+
checks.push(
|
|
336
|
+
check(
|
|
337
|
+
'app-dev-server',
|
|
338
|
+
status === 200,
|
|
339
|
+
`GET http://localhost:${appPort}${policy.startPath} -> ${status}`,
|
|
340
|
+
),
|
|
341
|
+
);
|
|
342
|
+
} else {
|
|
343
|
+
checks.push(
|
|
344
|
+
check(
|
|
345
|
+
'app-dev-server',
|
|
346
|
+
false,
|
|
347
|
+
'no dev-server port; pass --watcher-port <port> or set TERMINAL_APP_PORT',
|
|
348
|
+
),
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
// Advisory: the browser enforces testnet itself (the venue policy's blocked hosts).
|
|
352
|
+
const testnet = devServerTestnetDiagnostic(target, { appPort, variable: policy.testnetVariable });
|
|
353
|
+
checks.push(
|
|
354
|
+
check('testnet-forced', testnet.known && testnet.forced, testnet.detail, {
|
|
355
|
+
required: false,
|
|
356
|
+
status: testnet.known && testnet.forced ? 'pass' : 'warn',
|
|
357
|
+
}),
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
const { file, fixture } = await loadWalletFixture(target, env);
|
|
362
|
+
const address = viemAccountFromEntry(fixtureEntry(fixture, account), target).address;
|
|
363
|
+
checks.push(
|
|
364
|
+
check('wallet-fixture', true, `${path.basename(file)}: account ${account} -> ${address}`),
|
|
365
|
+
);
|
|
366
|
+
} catch (error) {
|
|
367
|
+
checks.push(check('wallet-fixture', false, error.message.split('\n')[0]));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (probeBrowser) {
|
|
371
|
+
try {
|
|
372
|
+
const browser = await probeBrowser(env);
|
|
373
|
+
checks.push(check('browser', true, `${browser.version ?? browser.bin} (${browser.source})`));
|
|
374
|
+
} catch (error) {
|
|
375
|
+
checks.push(check('browser', false, error.message.split('\n')[0]));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const extensionRequired = signer === 'extension';
|
|
380
|
+
if (signers.extension?.readinessChecks) {
|
|
381
|
+
checks.push(
|
|
382
|
+
...(await signers.extension.readinessChecks({ target, env, required: extensionRequired })),
|
|
383
|
+
);
|
|
384
|
+
} else if (extensionRequired) {
|
|
385
|
+
checks.push(
|
|
386
|
+
check(
|
|
387
|
+
'extension-signer',
|
|
388
|
+
false,
|
|
389
|
+
'no extension signer is configured (signers.extension / RECIPE_WEB_DAPP_SIGNER_MODULE)',
|
|
390
|
+
),
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const state = await readBrowserState(target);
|
|
395
|
+
const runtime = webDappRuntimeDir(target);
|
|
396
|
+
const live = Boolean(
|
|
397
|
+
state &&
|
|
398
|
+
ownsProcess('browser', state.browserPid, runtime) &&
|
|
399
|
+
ownsProcess('wallet-host', state.hostPid, runtime),
|
|
400
|
+
);
|
|
401
|
+
if (cdpPort) {
|
|
402
|
+
const free = await portFree(cdpPort);
|
|
403
|
+
const owned = live && state.cdpPort === cdpPort;
|
|
404
|
+
checks.push(
|
|
405
|
+
check(
|
|
406
|
+
'cdp-port',
|
|
407
|
+
free || owned,
|
|
408
|
+
free
|
|
409
|
+
? `CDP port ${cdpPort} is free`
|
|
410
|
+
: owned
|
|
411
|
+
? `CDP port ${cdpPort} held by this slot's browser`
|
|
412
|
+
: `CDP port ${cdpPort} is held by another process`,
|
|
413
|
+
),
|
|
414
|
+
);
|
|
415
|
+
} else {
|
|
416
|
+
checks.push(check('cdp-port', false, 'no CDP port; pass --cdp-port <port>'));
|
|
417
|
+
}
|
|
418
|
+
checks.push(
|
|
419
|
+
check(
|
|
420
|
+
'browser-live',
|
|
421
|
+
live,
|
|
422
|
+
live
|
|
423
|
+
? `browser pid ${state.browserPid} (${state.signer}, ${state.account?.name}) and wallet host pid ${state.hostPid}`
|
|
424
|
+
: 'no slot browser running (the launch recipe node starts it)',
|
|
425
|
+
{ required: false },
|
|
426
|
+
),
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
const failed = checks
|
|
430
|
+
.filter((item) => item.required && item.status === 'fail')
|
|
431
|
+
.map((item) => item.id);
|
|
432
|
+
return {
|
|
433
|
+
status: failed.length === 0 ? 'pass' : 'fail',
|
|
434
|
+
checks,
|
|
435
|
+
failed,
|
|
436
|
+
browser: live ? state : null,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Resolve the web-dapp slot runtime: the venue policy, ports, runtime paths,
|
|
2
|
+
// browser state and wallet-fixture accounts. Key material is only ever
|
|
3
|
+
// returned as a viem account object; callers must never log or persist it.
|
|
4
|
+
|
|
5
|
+
import { realpathSync } from 'node:fs';
|
|
6
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
import { recipeRuntimeDir, walletFixturePath } from './paths.mjs';
|
|
10
|
+
import { POLICY_ENV, webDappAdapterId, webDappPolicy } from './policy.mjs';
|
|
11
|
+
import { defaultSigner } from './signers.mjs';
|
|
12
|
+
import { loadViemAccounts } from './viem.mjs';
|
|
13
|
+
|
|
14
|
+
export { POLICY_ENV, webDappAdapterId, webDappPolicy };
|
|
15
|
+
|
|
16
|
+
export const SIGNER_MODES = Object.freeze(['extension', 'injected']);
|
|
17
|
+
export const DEFAULT_ACCOUNT = 'dev1';
|
|
18
|
+
|
|
19
|
+
// The checkout's canonical path: a symlinked or /tmp-vs-/private/tmp spelling
|
|
20
|
+
// of the same slot must name the same runtime dir, profile and pid owners.
|
|
21
|
+
export function canonicalPath(target) {
|
|
22
|
+
const resolved = path.resolve(target);
|
|
23
|
+
try {
|
|
24
|
+
return realpathSync(resolved);
|
|
25
|
+
} catch {
|
|
26
|
+
return resolved;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// <checkout>/<runtime>/<adapter id>, e.g. temp/recipe/runtime/terminal.
|
|
31
|
+
export function webDappRuntimeDir(projectRoot) {
|
|
32
|
+
return path.join(canonicalPath(projectRoot), recipeRuntimeDir(), webDappPolicy().adapterId);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function webDappRuntimePath(projectRoot, ...segments) {
|
|
36
|
+
return path.join(webDappRuntimeDir(projectRoot), ...segments);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function portFrom(raw, label) {
|
|
40
|
+
if (raw === undefined || raw === null || raw === '') return undefined;
|
|
41
|
+
const port = Number(raw);
|
|
42
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
43
|
+
throw new Error(`${label} must be a TCP port, got ${JSON.stringify(raw)}.`);
|
|
44
|
+
}
|
|
45
|
+
return port;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The slot's Next.js dev server. Farmslot passes it as --watcher-port {{port}};
|
|
49
|
+
// TERMINAL_APP_PORT is the explicit override.
|
|
50
|
+
export function appPort(input = {}, env = process.env) {
|
|
51
|
+
const port =
|
|
52
|
+
portFrom(input.node?.app_port, 'app_port') ??
|
|
53
|
+
portFrom(env.TERMINAL_APP_PORT, 'TERMINAL_APP_PORT') ??
|
|
54
|
+
portFrom(env.RECIPE_WATCHER_PORT, 'RECIPE_WATCHER_PORT') ??
|
|
55
|
+
portFrom(env.WATCHER_PORT, 'WATCHER_PORT');
|
|
56
|
+
if (!port) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`${webDappPolicy().adapterId} adapter requires the slot dev-server port.\n` +
|
|
59
|
+
'Next: pass --watcher-port <port> (Farmslot: {{port}}) or set TERMINAL_APP_PORT.',
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return port;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function appOrigin(input = {}, env = process.env) {
|
|
66
|
+
return `http://localhost:${appPort(input, env)}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function cdpPort(input = {}, env = process.env) {
|
|
70
|
+
const port =
|
|
71
|
+
portFrom(input.node?.cdp_port, 'cdp_port') ??
|
|
72
|
+
portFrom(env.RECIPE_CDP_PORT, 'RECIPE_CDP_PORT') ??
|
|
73
|
+
portFrom(env.CDP_PORT, 'CDP_PORT');
|
|
74
|
+
if (!port) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`${webDappPolicy().adapterId} adapter requires a per-slot browser CDP port.\n` +
|
|
77
|
+
'Next: pass --cdp-port <port> (Farmslot: {{cdp_port}}).',
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return port;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function readJsonFile(file) {
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(await readFile(file, 'utf8'));
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error?.code === 'ENOENT') return null;
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function writePrivateJson(file, value) {
|
|
93
|
+
await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function readBrowserState(projectRoot) {
|
|
97
|
+
return readJsonFile(webDappRuntimePath(projectRoot, 'browser.json'));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function resolveSigner(raw, fallback = defaultSigner()) {
|
|
101
|
+
const signer = raw == null || raw === '' ? fallback : String(raw);
|
|
102
|
+
if (!SIGNER_MODES.includes(signer)) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`signer must be one of ${SIGNER_MODES.join(' | ')}, got ${JSON.stringify(raw)}.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return signer;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Account name precedence: node.account_name → node.account (when not an
|
|
111
|
+
// address) → the running browser's account → dev1.
|
|
112
|
+
export function accountName(input = {}, browserState = null) {
|
|
113
|
+
const explicit = input.node?.account_name ?? input.node?.account;
|
|
114
|
+
if (
|
|
115
|
+
typeof explicit === 'string' &&
|
|
116
|
+
explicit.trim() &&
|
|
117
|
+
!/^0x[0-9a-fA-F]{40}$/u.test(explicit.trim())
|
|
118
|
+
) {
|
|
119
|
+
return explicit.trim();
|
|
120
|
+
}
|
|
121
|
+
return browserState?.account?.name ?? DEFAULT_ACCOUNT;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function fixturePath(projectRoot, env = process.env) {
|
|
125
|
+
return env.RECIPE_WALLET_FIXTURE
|
|
126
|
+
? path.resolve(env.RECIPE_WALLET_FIXTURE)
|
|
127
|
+
: walletFixturePath(projectRoot);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function loadWalletFixture(projectRoot, env = process.env) {
|
|
131
|
+
const file = fixturePath(projectRoot, env);
|
|
132
|
+
const fixture = await readJsonFile(file);
|
|
133
|
+
if (!fixture) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`wallet fixture not found at ${file}.\n` +
|
|
136
|
+
`Next: run the host's install command (--adapter ${webDappPolicy().adapterId} --target <checkout>) with RECIPE_WALLET_FIXTURE set, or copy the fixture there (0600).`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
if (!Array.isArray(fixture.accounts) || fixture.accounts.length === 0) {
|
|
140
|
+
throw new Error(`wallet fixture at ${file} has no accounts.`);
|
|
141
|
+
}
|
|
142
|
+
return { file, fixture };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function fixtureEntry(fixture, name) {
|
|
146
|
+
const entry = fixture.accounts.find((account) => account?.name === name);
|
|
147
|
+
if (!entry) {
|
|
148
|
+
const names = fixture.accounts
|
|
149
|
+
.map((account) => account?.name)
|
|
150
|
+
.filter(Boolean)
|
|
151
|
+
.join(', ');
|
|
152
|
+
throw new Error(`wallet fixture has no account named "${name}". Available: ${names}.`);
|
|
153
|
+
}
|
|
154
|
+
if (entry.type !== 'mnemonic' && entry.type !== 'privateKey') {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`wallet fixture account "${name}" must be mnemonic or privateKey, got "${entry.type}".`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
if (typeof entry.value !== 'string' || !entry.value.trim()) {
|
|
160
|
+
throw new Error(`wallet fixture account "${name}" has no value.`);
|
|
161
|
+
}
|
|
162
|
+
return entry;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Mnemonics use BIP-44 index 0, the MetaMask default and the derivation the
|
|
166
|
+
// Extension fixture flow imports.
|
|
167
|
+
export function viemAccountFromEntry(entry, projectRoot) {
|
|
168
|
+
const { mnemonicToAccount, privateKeyToAccount } = loadViemAccounts(projectRoot);
|
|
169
|
+
if (entry.type === 'mnemonic') return mnemonicToAccount(entry.value.trim(), { addressIndex: 0 });
|
|
170
|
+
const raw = entry.value.trim();
|
|
171
|
+
const key = raw.startsWith('0x') ? raw : `0x${raw}`;
|
|
172
|
+
if (!/^0x[0-9a-fA-F]{64}$/u.test(key)) {
|
|
173
|
+
throw new Error(`wallet fixture privateKey account "${entry.name}" is not a 32-byte hex key.`);
|
|
174
|
+
}
|
|
175
|
+
return privateKeyToAccount(key);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function fixtureAccount(projectRoot, name, env = process.env) {
|
|
179
|
+
const { fixture } = await loadWalletFixture(projectRoot, env);
|
|
180
|
+
return viemAccountFromEntry(fixtureEntry(fixture, name), projectRoot);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Address-only view of the account a node targets: an explicit address wins
|
|
184
|
+
// (read-only use), otherwise the fixture account is derived.
|
|
185
|
+
export async function resolveAccountAddress(input, env = process.env) {
|
|
186
|
+
const raw = input.node?.account;
|
|
187
|
+
if (typeof raw === 'string' && /^0x[0-9a-fA-F]{40}$/u.test(raw.trim())) {
|
|
188
|
+
return { name: null, address: raw.trim() };
|
|
189
|
+
}
|
|
190
|
+
const projectRoot = input.context?.projectRoot;
|
|
191
|
+
const browserState = projectRoot ? await readBrowserState(projectRoot) : null;
|
|
192
|
+
const name = accountName(input, browserState);
|
|
193
|
+
const account = await fixtureAccount(projectRoot, name, env);
|
|
194
|
+
return { name, address: account.address };
|
|
195
|
+
}
|