@deeeed/metamask-harness 0.49.1 → 0.50.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.50.1 - 2026-09-08
6
+
7
+ - Hand Extension builds to a fresh background worker before readiness, including watched edits and quick reloads, while preserving wallet profiles and native sidepanels. Refuse development copies over release artifacts.
8
+
9
+ ## 0.50.0 - 2026-09-08
10
+
11
+ - Bind Mobile order assertions and cleanup to accepted receipts, exact TP/SL values and complete snapshots. iOS passed one limit cancellation and one protected-market close; protected limits, full recipes, CUF and Android remain unvalidated.
12
+ - Fix Mobile stop helper lookup and detached-target discovery for an explicitly selected device.
13
+ - Add Mobile amount/leverage input with exact mounted-form and native readback against the unchanged unsigned confirmation. Changed and already-satisfied $10.25/1x input passed on iOS.
14
+
5
15
  ## 0.49.1 - 2026-09-08
6
16
 
7
17
  - fix(funding): bind balance evidence to the wallet runtime (#234).
@@ -0,0 +1,198 @@
1
+ // Replace an unpacked build while its extension is disabled, preserving storage and native panels.
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const { spawnSync } = require('node:child_process');
7
+ const { connectBrowserViaCdp, evaluatePageViaCdp } = require('./lib/playwright-cdp.cjs');
8
+ const { RUNTIME_IDENTITY_FILENAME, RUNTIME_NONCE_PREFIX } = require('./lib/chrome-args.cjs');
9
+ const { stampRuntimeTitles } = require('./stamp-runtime-title.cjs');
10
+
11
+ function acquireBuildHandoff(runtimeDir) {
12
+ const lock = path.join(runtimeDir, 'extension-build-handoff.lock');
13
+ const deadline = Date.now() + 30_000;
14
+ while (true) {
15
+ try {
16
+ const descriptor = fs.openSync(lock, 'wx', 0o600);
17
+ fs.writeFileSync(descriptor, String(process.pid));
18
+ fs.closeSync(descriptor);
19
+ return () => fs.rmSync(lock, { force: true });
20
+ } catch (error) {
21
+ if (error.code !== 'EEXIST') throw error;
22
+ let owner;
23
+ try { owner = Number(fs.readFileSync(lock, 'utf8')); } catch (readError) {
24
+ if (readError.code === 'ENOENT') continue;
25
+ throw readError;
26
+ }
27
+ if (Number.isInteger(owner) && owner > 0) {
28
+ try { process.kill(owner, 0); } catch (ownerError) {
29
+ if (ownerError.code !== 'ESRCH') throw ownerError;
30
+ fs.rmSync(lock, { force: true });
31
+ continue;
32
+ }
33
+ }
34
+ if (Date.now() >= deadline) throw new Error('Extension build handoff is busy. Next: wait for the current launch to finish.');
35
+ spawnSync('sleep', ['0.1']);
36
+ }
37
+ }
38
+ }
39
+
40
+ async function sidePanelContexts(context, url, windowIds = []) {
41
+ const page = await context.newPage();
42
+ const session = await context.newCDPSession(page);
43
+ const key = 'mm-harness:build-handoff:side-panel';
44
+ let preload;
45
+ try {
46
+ await session.send('Page.enable');
47
+ // Capture Chrome APIs before LavaMoat scuttles globals in the extension page.
48
+ preload = await session.send('Page.addScriptToEvaluateOnNewDocument', {
49
+ source: `(() => {
50
+ const read = chrome.runtime.getContexts.bind(chrome.runtime);
51
+ const open = chrome.sidePanel.open.bind(chrome.sidePanel);
52
+ const listWindows = chrome.windows.getAll.bind(chrome.windows);
53
+ globalThis[Symbol.for(${JSON.stringify(key)})] = async (windowIds) => {
54
+ await Promise.all(windowIds.map(windowId => open({ windowId })));
55
+ const [panels, windows] = await Promise.all([read({ contextTypes: ['SIDE_PANEL'] }), listWindows({ windowTypes: ['normal'] })]);
56
+ // Chromium can omit a panel's window ID; only one normal window is unambiguous.
57
+ return panels.map(panel => ({ ...panel, windowId: panel.windowId >= 0 ? panel.windowId : windows.length === 1 ? windows[0].id : -1 }));
58
+ };
59
+ })();`,
60
+ });
61
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15_000 });
62
+ const result = await session.send('Runtime.evaluate', {
63
+ expression: `globalThis[Symbol.for(${JSON.stringify(key)})](${JSON.stringify(windowIds)})`,
64
+ userGesture: windowIds.length > 0,
65
+ awaitPromise: true,
66
+ returnByValue: true,
67
+ });
68
+ if (result.exceptionDetails) throw new Error(`Extension sidepanel restoration failed: ${result.exceptionDetails.exception?.description ?? result.exceptionDetails.text}. Next: mm-harness launch --adapter extension --display-mode fullscreen`);
69
+ return result.result.value;
70
+ } finally {
71
+ if (!page.isClosed()) {
72
+ if (preload) await session.send('Page.removeScriptToEvaluateOnNewDocument', { identifier: preload.identifier });
73
+ await session.send('Runtime.evaluate', { expression: `delete globalThis[Symbol.for(${JSON.stringify(key)})]` });
74
+ await session.detach();
75
+ await page.close();
76
+ }
77
+ }
78
+ }
79
+
80
+ async function handoffRuntimeBuild({ target, runtimeDist, dist, cdpPort }) {
81
+ const runtimeDir = path.dirname(runtimeDist);
82
+ const release = acquireBuildHandoff(runtimeDir);
83
+ let browser;
84
+ let manager;
85
+ try {
86
+ if (dist && fs.existsSync(path.join(runtimeDir, 'extension-release-artifact.json'))) {
87
+ throw new Error('Development builds cannot replace a release artifact runtime. Next: launch the development runtime explicitly.');
88
+ }
89
+ const identityPath = path.join(runtimeDir, RUNTIME_IDENTITY_FILENAME);
90
+ // A watcher must not publish while a full launch has stopped the browser.
91
+ if (!fs.existsSync(identityPath)) {
92
+ if (cdpPort) throw new Error('Extension runtime ownership is missing. Next: mm-harness launch --adapter extension');
93
+ return false;
94
+ }
95
+ const identity = JSON.parse(fs.readFileSync(identityPath, 'utf8'));
96
+ if (cdpPort && Number(cdpPort) !== identity.port) throw new Error('Extension CDP port differs from its runtime owner.');
97
+ browser = await connectBrowserViaCdp(`http://127.0.0.1:${identity.port}`);
98
+ const session = await browser.newBrowserCDPSession();
99
+ const { arguments: command } = await session.send('Browser.getBrowserCommandLine');
100
+ if (!command.includes(`${RUNTIME_NONCE_PREFIX}${identity.nonce}`) || !command.includes(`--load-extension=${runtimeDist}`)) {
101
+ throw new Error('Extension build handoff refused a foreign browser.');
102
+ }
103
+ const context = browser.contexts()[0];
104
+ manager = await context.newPage();
105
+ await manager.goto('chrome://extensions/', { waitUntil: 'domcontentloaded', timeout: 15_000 });
106
+ const extensions = await evaluatePageViaCdp(manager, () => new Promise((resolve, reject) => {
107
+ const timer = setTimeout(() => reject(new Error('Extension management timed out.')), 15_000);
108
+ chrome.developerPrivate.getExtensionsInfo({ includeDisabled: true, includeTerminated: true }, (entries) => {
109
+ clearTimeout(timer);
110
+ if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message));
111
+ else resolve(entries.map(({ id, path: directory }) => ({ id, directory })));
112
+ });
113
+ }));
114
+ const extensionId = extensions.find((entry) => entry.directory === fs.realpathSync(runtimeDist))?.id;
115
+ if (!extensionId) throw new Error('Chrome has not registered the requested unpacked extension directory.');
116
+ const prefix = `chrome-extension://${extensionId}/`;
117
+ const pages = context.pages().filter((page) => page.url().startsWith(prefix)).map((page) => ({ page, url: page.url() }));
118
+ const oldManifest = JSON.parse(fs.readFileSync(path.join(runtimeDist, 'manifest.json'), 'utf8'));
119
+ const panelUrl = oldManifest.side_panel?.default_path && new URL(oldManifest.side_panel.default_path, prefix).href;
120
+ const panels = panelUrl && pages.some((entry) => new URL(entry.url).pathname === new URL(panelUrl).pathname)
121
+ ? await sidePanelContexts(context, panelUrl)
122
+ : [];
123
+ const panelWindowIds = [...new Set(panels.map((panel) => panel.windowId))];
124
+ if (panelWindowIds.some((id) => !Number.isInteger(id) || id < 0)) {
125
+ throw new Error('Extension sidepanel window is ambiguous. Next: close extra windows in this profile before reloading.');
126
+ }
127
+ const oldTargets = (await session.send('Target.getTargets')).targetInfos.filter((entry) => entry.url.startsWith(prefix));
128
+ async function setEnabled(enabled) {
129
+ await evaluatePageViaCdp(manager, ({ id, enabled: next }) => new Promise((resolve, reject) => {
130
+ const timer = setTimeout(() => reject(new Error('Extension enable/disable timed out.')), 15_000);
131
+ chrome.management.setEnabled(id, next, () => {
132
+ clearTimeout(timer);
133
+ if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message));
134
+ else resolve(true);
135
+ });
136
+ }), { id: extensionId, enabled });
137
+ }
138
+ await setEnabled(false);
139
+ if (dist) {
140
+ const copied = spawnSync('rsync', ['-a', '--delete', '--exclude', '_metadata', `${dist}/`, `${runtimeDist}/`], { encoding: 'utf8' });
141
+ if (copied.status !== 0) throw new Error(`Extension build copy failed: ${copied.stderr || copied.stdout}`);
142
+ const configured = spawnSync(process.execPath, [path.join(__dirname, 'configure-runtime-manifest.cjs'), '--target', target, '--manifest', path.join(runtimeDist, 'manifest.json')], { encoding: 'utf8' });
143
+ if (configured.status !== 0) throw new Error(`Extension runtime configuration failed: ${configured.stderr || configured.stdout}`);
144
+ stampRuntimeTitles({ target, runtimeDist });
145
+ }
146
+ await setEnabled(true);
147
+ for (const entry of pages) {
148
+ if (entry.page.isClosed() && panels.length && new URL(entry.url).pathname === new URL(panelUrl).pathname) continue;
149
+ const page = entry.page.isClosed() ? await context.newPage() : entry.page;
150
+ await page.goto(entry.url, { waitUntil: 'domcontentloaded', timeout: 15_000 });
151
+ }
152
+ const manifest = JSON.parse(fs.readFileSync(path.join(runtimeDist, 'manifest.json'), 'utf8'));
153
+ if (panels.length) {
154
+ if (!manifest.side_panel?.default_path) {
155
+ throw new Error('Extension sidepanel has no restorable window. Next: mm-harness launch --adapter extension --display-mode fullscreen');
156
+ }
157
+ await sidePanelContexts(context, new URL(manifest.side_panel.default_path, prefix).href, panelWindowIds);
158
+ }
159
+ if (manifest.background?.service_worker) {
160
+ const expected = `${prefix}${manifest.background.service_worker}`;
161
+ const deadline = Date.now() + 15_000;
162
+ let replaced = false;
163
+ while (Date.now() < deadline) {
164
+ const { targetInfos } = await session.send('Target.getTargets');
165
+ replaced = targetInfos.some((entry) => entry.type === 'service_worker' && entry.url === expected && !oldTargets.some((old) => old.targetId === entry.targetId));
166
+ if (replaced) break;
167
+ await new Promise((resolve) => setTimeout(resolve, 100));
168
+ }
169
+ if (!replaced) throw new Error('Extension build handoff did not start a new background worker. Next: mm-harness launch --adapter extension');
170
+ }
171
+ return true;
172
+ } finally {
173
+ try {
174
+ if (manager) await manager.close().catch(() => {});
175
+ if (browser) await browser.close();
176
+ } finally {
177
+ release();
178
+ }
179
+ }
180
+ }
181
+
182
+ module.exports = { acquireBuildHandoff, handoffRuntimeBuild };
183
+
184
+ if (require.main === module) {
185
+ const args = {};
186
+ for (let index = 2; index < process.argv.length; index += 2) {
187
+ const flag = process.argv[index];
188
+ if (flag === '--help') {
189
+ console.log('Usage: build-handoff.cjs --target <checkout> --runtime-dist <directory> [--dist <build>] [--cdp-port <port>]');
190
+ process.exit(0);
191
+ }
192
+ if (!flag.startsWith('--') || !process.argv[index + 1]) throw new Error(`Missing value for ${flag}`);
193
+ args[flag.slice(2)] = process.argv[index + 1];
194
+ }
195
+ if (!args.target || !args['runtime-dist']) throw new Error('build-handoff requires --target and --runtime-dist.');
196
+ handoffRuntimeBuild({ target: path.resolve(args.target), runtimeDist: path.resolve(args['runtime-dist']), dist: args.dist && path.resolve(args.dist), cdpPort: args['cdp-port'] })
197
+ .catch((error) => { console.error(`build-handoff: ${error.message}`); process.exitCode = 1; });
198
+ }
@@ -94,6 +94,7 @@ fs.rmSync(path.join(harnessDir, 'runner/library.json'), { force: true });
94
94
  // The installed overlay has a flat scripts/ runtime API independent of the
95
95
  // package's source layout.
96
96
  copyFile(path.join(runnerDir, 'adapters/extension/launch-browser.cjs'), path.join(harnessDir, 'scripts/launch-browser.cjs'));
97
+ copyFile(path.join(runnerDir, 'adapters/extension/build-handoff.cjs'), path.join(harnessDir, 'scripts/build-handoff.cjs'));
97
98
  copyFile(path.join(runnerDir, 'adapters/extension/console-tail.mjs'), path.join(harnessDir, 'scripts/console-tail.mjs'));
98
99
  copyFile(path.join(runnerDir, 'adapters/extension/launch.sh'), path.join(harnessDir, 'scripts/launch.sh'));
99
100
  copyFile(path.join(runnerDir, 'adapters/extension/live.sh'), path.join(harnessDir, 'scripts/live.sh'));
@@ -25,6 +25,7 @@ const path = require('node:path');
25
25
  const { execFileSync, spawn, spawnSync } = require('node:child_process');
26
26
  const { extensionIdFromExtensionDir } = require('./lib/extension-id.cjs');
27
27
  const { profileProcessPids } = require('./lib/validation-process-ownership.cjs');
28
+ const { acquireBuildHandoff } = require('./build-handoff.cjs');
28
29
  const {
29
30
  automationRuntimeArgs,
30
31
  clearDetachedLaunchUnproven,
@@ -78,6 +79,8 @@ if (args['reset-profile'] !== undefined) assertResettableProfile(args.profile, r
78
79
  fs.mkdirSync(args.profile, { recursive: true });
79
80
  fs.mkdirSync(path.dirname(args['chrome-log']), { recursive: true });
80
81
  fs.mkdirSync(path.dirname(args['chrome-pid']), { recursive: true });
82
+ const releaseBuildHandoff = acquireBuildHandoff(runtimeDir);
83
+ process.on('exit', releaseBuildHandoff);
81
84
 
82
85
  // Ownership guard: only ever take over a CDP endpoint we provably launched. A pid
83
86
  // on this port is ours iff its command line loads our --user-data-dir; anything
@@ -228,6 +228,7 @@ NODE
228
228
  quoted_check_infura="$(printf '%q' "$SCRIPT_DIR/check-infura-readiness.cjs")"
229
229
  quoted_stamp_title="$(printf '%q' "$SCRIPT_DIR/stamp-runtime-title.cjs")"
230
230
  quoted_chrome_launcher="$(printf '%q' "$SCRIPT_DIR/launch-browser.cjs")"
231
+ quoted_build_handoff="$(printf '%q' "$SCRIPT_DIR/build-handoff.cjs")"
231
232
  quoted_artifact_state="$(printf '%q' "$SCRIPT_DIR/artifact-runtime-state.cjs")"
232
233
  quoted_fixture_state="$(printf '%q' "$FIXTURE_STATE_ABS")"
233
234
  quoted_fixture_validation="$(printf '%q' "$FIXTURE_VALIDATION_ABS")"
@@ -301,6 +302,9 @@ NODE
301
302
  quoted_chrome_log="$(printf '%q' "$ARTIFACTS/logs/chrome.log")"
302
303
  quoted_chrome_pid="$(printf '%q' "$ARTIFACTS/logs/chrome.pid")"
303
304
  prepare_parts=()
305
+ reset_profile_arg=""
306
+ $RESET_PROFILE && reset_profile_arg=" --reset-profile 1"
307
+ prepare_parts+=("node ${quoted_chrome_launcher} --stop-only 1${reset_profile_arg} --chrome-bin ${quoted_chrome} --profile ${quoted_profile} --cdp-port ${CDP_PORT} --extension-dir ${quoted_runtime_dist} --chrome-log ${quoted_chrome_log} --chrome-pid ${quoted_chrome_pid}")
304
308
  if $EXTERNAL_ARTIFACT; then
305
309
  quoted_artifact_provenance="$(printf '%q' "$ARTIFACT_PROVENANCE")"
306
310
  else
@@ -326,9 +330,6 @@ NODE
326
330
  if ! $EXTERNAL_ARTIFACT; then
327
331
  prepare_parts+=("node ${quoted_configure_manifest} --target ${quoted_target} --manifest ${quoted_runtime_dist}/manifest.json")
328
332
  fi
329
- reset_profile_arg=""
330
- $RESET_PROFILE && reset_profile_arg=" --reset-profile 1"
331
- prepare_parts+=("node ${quoted_chrome_launcher} --stop-only 1${reset_profile_arg} --chrome-bin ${quoted_chrome} --profile ${quoted_profile} --cdp-port ${CDP_PORT} --extension-dir ${quoted_runtime_dist} --chrome-log ${quoted_chrome_log} --chrome-pid ${quoted_chrome_pid}")
332
333
  if ! $EXTERNAL_ARTIFACT; then
333
334
  prepare_parts+=("node ${quoted_stamp_title} --target ${quoted_target} --runtime-dist ${quoted_runtime_dist} --runtime-dir ${quoted_runtime_dir}")
334
335
  fi
@@ -352,6 +353,7 @@ NODE
352
353
  fi
353
354
  prepare_parts+=("$chrome_launch_cmd")
354
355
  prepare_parts+=("for i in {1..60}; do curl -fsS --max-time 1 http://127.0.0.1:${CDP_PORT}/json/version >/dev/null 2>&1 && break; sleep 1; done; curl -fsS --max-time 1 http://127.0.0.1:${CDP_PORT}/json/version >/dev/null")
356
+ prepare_parts+=("node ${quoted_build_handoff} --target ${quoted_target} --runtime-dist ${quoted_runtime_dist} --cdp-port ${CDP_PORT}")
355
357
  if [ -n "$WALLET_FIXTURE_ABS" ]; then
356
358
  prepare_parts+=("bash ${quoted_seed_fixture} seed-cdp --target ${quoted_target} --fixture ${quoted_wallet_fixture} --state ${quoted_fixture_state} --cdp-port ${CDP_PORT} --extension-dir ${quoted_runtime_dist} --extension-id-file ${quoted_extension_id_file} --out ${quoted_fixture_validation}")
357
359
  fi
@@ -93,18 +93,13 @@ else
93
93
  RUNTIME_DIR="${RECIPE_RUNTIME_DIR:-temp/recipe/runtime}"
94
94
  fi
95
95
  RUNTIME_DIST_DIR="${RECIPE_RUNTIME_DIST_DIR:-runtime-dist}"
96
- # The running Chrome loads this unpacked extension directory. Do not recreate or
97
- # rsync-delete it while Chrome is live: Chrome can keep the intended
98
- # chrome-extension:// target URL but serve chrome-error://chromewebdata after its
99
- # loaded directory is replaced underneath it. Fresh snapshots belong to the clean
100
- # relaunch path; quick reattach only reloads the already-loaded runtime.
96
+ # Disable the extension before replacing its build so cached workers cannot
97
+ # request assets from a different compilation.
101
98
  RUNTIME_DIST_ABS="$TARGET/$RUNTIME_DIR/$RUNTIME_DIST_DIR"
102
99
  echo "[reattach] reusing loaded runtime-dist: $RUNTIME_DIST_ABS" >&2
103
100
  if [ -d "$DIST_ABS" ] && [ -d "$RUNTIME_DIST_ABS" ]; then
104
101
  echo "[reattach] refreshing loaded runtime-dist in place" >&2
105
- rsync -a --exclude _metadata "$DIST_ABS/" "$RUNTIME_DIST_ABS/" >&2
106
- node "$SCRIPT_DIR/configure-runtime-manifest.cjs" --target "$TARGET" --manifest "$RUNTIME_DIST_ABS/manifest.json" >&2
107
- node "$SCRIPT_DIR/stamp-runtime-title.cjs" --target "$TARGET" --runtime-dist "$RUNTIME_DIST_ABS" --runtime-dir "$RUNTIME_DIR" >&2
102
+ (cd "$TARGET" && node "$SCRIPT_DIR/build-handoff.cjs" --target "$TARGET" --dist "$DIST_ABS" --runtime-dist "$RUNTIME_DIST_ABS" --cdp-port "$CDP_PORT") >&2
108
103
  fi
109
104
 
110
105
  # Resolve the extension id: explicit flag, then the recorded id file, else CDP.
@@ -225,11 +220,8 @@ async function closePages(context, predicate) {
225
220
  }
226
221
 
227
222
  (async () => {
228
- // Phase 1: attach to the already-loaded extension page. Do not call
229
- // chrome.runtime.reload() here: on the slot Chrome version it can leave the
230
- // target URL as chrome-extension://.../home.html while the actual document is
231
- // chrome-error://chromewebdata. Clean relaunch owns loading a new unpacked
232
- // runtime-dist; quick reattach only refreshes/foregrounds the live UI.
223
+ // The build handoff has already replaced the background worker. Reattach only
224
+ // arranges the requested wallet and dapp pages.
233
225
  let { browser, context } = await connect();
234
226
  const providedExtId = extensionIdFromRuntimeDist() || validExtensionId(process.env.EXT_ID);
235
227
  const extId = extensionIdFrom(context, providedExtId);
@@ -1,10 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
- const { spawnSync } = require('node:child_process');
5
4
  const fs = require('node:fs');
6
5
  const path = require('node:path');
7
- const { stampRuntimeTitles } = require('./stamp-runtime-title.cjs');
6
+ const { handoffRuntimeBuild } = require('./build-handoff.cjs');
8
7
 
9
8
  const args = {};
10
9
  for (let index = 2; index < process.argv.length; index += 1) {
@@ -49,37 +48,16 @@ function removeOwnPidFile() {
49
48
  }
50
49
  }
51
50
 
52
- function syncRuntimeDist() {
51
+ async function syncRuntimeDist() {
53
52
  if (!fs.existsSync(path.join(dist, 'manifest.json')) || !fs.existsSync(runtimeDist)) return;
54
- const result = spawnSync('rsync', ['-a', '--delete', '--delay-updates', '--exclude', '_metadata', `${dist}${path.sep}`, `${runtimeDist}${path.sep}`], {
55
- encoding: 'utf8',
56
- });
57
- if (result.status !== 0) {
58
- process.stderr.write(`runtime-dist sync failed: ${(result.stderr || result.stdout || `rsync exited ${result.status}`).trim()}\n`);
59
- return;
53
+ if (await handoffRuntimeBuild({ target: path.resolve(args.target), dist, runtimeDist })) {
54
+ process.stdout.write(`runtime-dist updated with a fresh background worker (${new Date().toISOString()})\n`);
55
+ } else {
56
+ process.stdout.write('build ready; waiting for an owned browser launch\n');
60
57
  }
61
- const configure = spawnSync(
62
- process.execPath,
63
- [
64
- path.join(__dirname, 'configure-runtime-manifest.cjs'),
65
- '--target',
66
- path.resolve(args.target),
67
- '--manifest',
68
- path.join(runtimeDist, 'manifest.json'),
69
- ],
70
- { encoding: 'utf8' },
71
- );
72
- if (configure.status !== 0) {
73
- process.stderr.write(
74
- `runtime-dist configuration failed: ${(configure.stderr || configure.stdout || `node exited ${configure.status}`).trim()}\n`,
75
- );
76
- return;
77
- }
78
- stampRuntimeTitles({ target: path.resolve(args.target), runtimeDist });
79
- process.stdout.write(`runtime-dist updated after webpack compile (${new Date().toISOString()})\n`);
80
58
  }
81
59
 
82
- function poll() {
60
+ async function poll() {
83
61
  if (!processAlive(watcherPid)) {
84
62
  removeOwnPidFile();
85
63
  process.exit(0);
@@ -100,7 +78,7 @@ function poll() {
100
78
  const text = carry + chunk.subarray(0, bytesRead).toString('utf8');
101
79
  const lines = text.split(/\r?\n/u);
102
80
  carry = lines.pop() || '';
103
- if (lines.some((line) => compilePattern.test(line))) syncRuntimeDist();
81
+ if (lines.some((line) => compilePattern.test(line))) await syncRuntimeDist();
104
82
  }
105
83
  } catch (error) {
106
84
  if (error?.code !== 'ENOENT') process.stderr.write(`runtime-dist sync monitor: ${error.message}\n`);
@@ -266,6 +266,14 @@
266
266
  "inputs": "resolve|prefill|seed-cdp; --target --cdp-port --fixture --state --profile --extension-dir --extension-id-file --out --source-out --fixture-script --summary; env RECIPE_WALLET_FIXTURE",
267
267
  "outputs": "fixture path on stdout (resolve), provenance/state/parity JSON files; exit 0/1/2"
268
268
  },
269
+ {
270
+ "id": "extension/build-handoff",
271
+ "entry": "adapters/extension/build-handoff.cjs",
272
+ "kind": "node",
273
+ "purpose": "Replace builds with the owned extension disabled, then refresh its worker and restore native panels without clearing profile storage.",
274
+ "inputs": "--target --runtime-dist [--dist] [--cdp-port]",
275
+ "outputs": "fresh worker and optional build copy; exit 0 / non-zero"
276
+ },
269
277
  {
270
278
  "id": "extension/launch-browser",
271
279
  "entry": "adapters/extension/launch-browser.cjs",
@@ -431,7 +431,12 @@ function createCdpBroker({
431
431
  readyOnly: true,
432
432
  });
433
433
  if (existing.length > 0) return Promise.resolve(existing);
434
- requestDiscovery?.('');
434
+ const retained = targetList({ nameIncludes: params.nameIncludes });
435
+ requestDiscovery?.(
436
+ String(params.nameIncludes || '').trim() && retained.length === 1
437
+ ? retained[0].deviceId
438
+ : '',
439
+ );
435
440
  return new Promise((resolve, reject) => {
436
441
  const waiter = {
437
442
  nameIncludes: String(params.nameIncludes || ''),
@@ -23,9 +23,9 @@ done
23
23
 
24
24
  TARGET="$(cd "$TARGET" && pwd -P)"
25
25
 
26
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
26
+ MOBILE_STOP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
27
27
  # shellcheck disable=SC1091
28
- . "$SCRIPT_DIR/../shared/harness-path.sh"
28
+ . "$MOBILE_STOP_DIR/../shared/harness-path.sh"
29
29
  if ! command -v recipe_runtime_dir >/dev/null 2>&1; then
30
30
  echo "stop-metro: shared lib adapters/shared/harness-path.sh not found; reinstall the runner." >&2
31
31
  exit 1
@@ -35,7 +35,7 @@ fi
35
35
  # pool/formula fallbacks) so we stop THIS slot's Metro, not the 8081 default.
36
36
  if [ -z "$PORT" ]; then
37
37
  # shellcheck disable=SC1091
38
- . "$SCRIPT_DIR/../shared/resolve-slot-ports.sh"
38
+ . "$MOBILE_STOP_DIR/../shared/resolve-slot-ports.sh"
39
39
  resolved_port="$(resolve_mobile_runtime_ports "$TARGET" 2>/dev/null | sed -n 's/^WATCHER_PORT=//p' | head -1 || true)"
40
40
  PORT="${resolved_port:-8081}"
41
41
  fi
@@ -45,7 +45,7 @@ PID_FILE="$LOG_DIR/metro.pid"
45
45
  TMUX_FILE="$LOG_DIR/metro.tmux"
46
46
 
47
47
  # shellcheck disable=SC1091
48
- . "$SCRIPT_DIR/lib/metro-listener.sh"
48
+ . "$MOBILE_STOP_DIR/lib/metro-listener.sh"
49
49
 
50
50
  pids="$(metro_listener_pids 2>/dev/null || true)"
51
51
  if [ -n "$pids" ]; then
@@ -88,8 +88,8 @@ pkill -f "console-forwarder.cjs --port $PORT --out $TARGET/" 2>/dev/null
88
88
  # Reap a detached bundler for this exact port when its listener disappeared
89
89
  # before the normal stop path. Other ports in the same checkout are separate
90
90
  # runtime generations and must remain untouched.
91
- if [ -f "$SCRIPT_DIR/../shared/reap-checkout-metros.sh" ]; then
92
- . "$SCRIPT_DIR/../shared/reap-checkout-metros.sh"
91
+ if [ -f "$MOBILE_STOP_DIR/../shared/reap-checkout-metros.sh" ]; then
92
+ . "$MOBILE_STOP_DIR/../shared/reap-checkout-metros.sh"
93
93
  reap_checkout_metros_on_port "$TARGET" "$PORT" || true
94
94
  fi
95
95
 
@@ -1,6 +1,7 @@
1
1
  // Assert that selected Perps orders are open or absent using the Mobile controller.
2
2
  import { runAdapter } from '../platform/bridge.mjs';
3
3
  import { assertOrders } from './perps.mjs';
4
+ import { useOrderReceipt } from './order-receipt.mjs';
4
5
 
5
6
  function expectedOpen(input) {
6
7
  if (input.node?.state == null) throw new Error('metamask.perps.assert_orders requires state=open or state=none.');
@@ -10,4 +11,6 @@ function expectedOpen(input) {
10
11
  throw new Error(`metamask.perps.assert_orders received unsupported state: ${state}`);
11
12
  }
12
13
 
13
- runAdapter((input) => assertOrders(input, expectedOpen(input)));
14
+ runAdapter((input) => Object.hasOwn(input.node ?? {}, 'confirmation')
15
+ ? useOrderReceipt(input, 'orders')
16
+ : assertOrders(input, expectedOpen(input)));
@@ -1,6 +1,7 @@
1
1
  // Assert that selected Perps positions are open or absent using the Mobile controller.
2
2
  import { runAdapter } from '../platform/bridge.mjs';
3
3
  import { assertPositions } from './perps.mjs';
4
+ import { useOrderReceipt } from './order-receipt.mjs';
4
5
 
5
6
  function expectedOpen(input) {
6
7
  if (input.node?.state == null) throw new Error('metamask.perps.assert_positions requires state=open or state=none.');
@@ -10,4 +11,6 @@ function expectedOpen(input) {
10
11
  throw new Error(`metamask.perps.assert_positions received unsupported state: ${state}`);
11
12
  }
12
13
 
13
- runAdapter((input) => assertPositions(input, expectedOpen(input)));
14
+ runAdapter((input) => Object.hasOwn(input.node ?? {}, 'confirmation')
15
+ ? useOrderReceipt(input, 'positions')
16
+ : assertPositions(input, expectedOpen(input)));
@@ -1,8 +1,10 @@
1
1
  // Cancel selected Perps orders through the Mobile controller and assert their absence.
2
2
  import { runAdapter } from '../platform/bridge.mjs';
3
3
  import { assertOrders, closeOrders } from './perps.mjs';
4
+ import { useOrderReceipt } from './order-receipt.mjs';
4
5
 
5
6
  runAdapter(async (input) => {
7
+ if (Object.hasOwn(input.node ?? {}, 'confirmation')) return useOrderReceipt(input, 'cancel');
6
8
  const close = await closeOrders(input);
7
9
  const assertion = await assertOrders(input, false);
8
10
  return { ...assertion, close };
@@ -1,8 +1,10 @@
1
1
  // Close selected Perps positions through the Mobile controller and assert their absence.
2
2
  import { runAdapter } from '../platform/bridge.mjs';
3
3
  import { assertPositions, closePositions } from './perps.mjs';
4
+ import { useOrderReceipt } from './order-receipt.mjs';
4
5
 
5
6
  runAdapter(async (input) => {
7
+ if (Object.hasOwn(input.node ?? {}, 'confirmation')) return useOrderReceipt(input, 'close');
6
8
  const close = await closePositions(input);
7
9
  const assertion = await assertPositions(input, false);
8
10
  return { ...assertion, close };
@@ -0,0 +1,90 @@
1
+ // Set a visible leverage preset and USD notional, with independent native readback.
2
+ // Requires an already-open instrumented order form; never submits or creates an order.
3
+ import { bridgeCommand, runAdapter } from '../platform/bridge.mjs';
4
+ import { observeNativeUi } from '../platform/observe-ui.mjs';
5
+ import { readOrderInputState } from './order-input-state.mjs';
6
+
7
+ async function configureOrder(input) {
8
+ const amount = String(input.node?.amount ?? '');
9
+ const leverage = Number(input.node?.leverage ?? 1);
10
+ if (![1, 2, 3, 5, 10, 20, 40].includes(leverage)) {
11
+ throw new Error('Perps leverage must use a supported visible preset.');
12
+ }
13
+ if (!/^(?:0|[1-9][0-9]{0,8})(?:\.[0-9]{1,2})?$/u.test(amount)
14
+ || amount.replace('.', '').length > 9 || Number(amount) <= 0) {
15
+ throw new Error('Perps amount requires positive USD notional with at most nine digits and two decimals.');
16
+ }
17
+ const timeout = Number(input.node?.timeout_ms ?? 30000);
18
+ if (!Number.isFinite(timeout) || timeout <= 0) throw new Error('Perps amount timeout must be positive.');
19
+ const deadline = Date.now() + timeout;
20
+ input = { ...input, node: { ...input.node, _action_deadline_epoch_ms: deadline } };
21
+ const initial = await readOrderInputState(input);
22
+ const observe = async () => {
23
+ if (Date.now() >= deadline) throw new Error('Perps amount input exceeded its deadline.');
24
+ const result = await observeNativeUi({ refs: ['ui.visible'], node: input.node }, input.context);
25
+ const visible = result.observations?.['ui.visible'];
26
+ if (!visible || visible.truncated) throw new Error(`Complete native Perps input observation is unavailable: ${result.warnings?.map((warning) => warning.message).join('; ') || 'truncated hierarchy'}.\nNext: mm-harness call ui.screenshot --adapter mobile`);
27
+ return visible.items ?? [];
28
+ };
29
+ const press = async (testId, long = false) => {
30
+ const result = await bridgeCommand(input, [long ? 'long-press-test-id' : 'press-test-id', testId]);
31
+ if (result?.ok !== true) throw new Error(`Perps input press failed: ${testId}.`);
32
+ };
33
+ const pressText = async (label) => {
34
+ const result = await bridgeCommand(input, ['press-text', label]);
35
+ if (result?.ok !== true) throw new Error(`Perps input press failed: ${label}.`);
36
+ };
37
+ const waitAmount = async (expected, keypadClosed = false) => {
38
+ let lastLabel = '';
39
+ while (Date.now() < deadline) {
40
+ const items = await observe();
41
+ const labels = items.filter((item) => item.test_id === 'perps-amount-display-amount');
42
+ const values = labels.length ? labels : items.filter((item) => item.test_id === 'perps-amount-display-touchable');
43
+ const label = values.length === 1 ? String(values[0].label ?? '').trim() : '';
44
+ lastLabel = label;
45
+ const match = /^\$((?:[0-9]{1,3}(?:,[0-9]{3})+|[0-9]+)(?:\.[0-9]{0,2})?)(?:,\s|$)/u.exec(label);
46
+ if (match && Math.round(Number(match[1].replaceAll(',', '')) * 100) === Math.round(expected * 100)
47
+ && (!keypadClosed || (!items.some((item) => ['perps-order-view-keypad', 'perps-order-view-keypad-done'].includes(item.test_id))
48
+ && items.some(leverageMatches)))) {
49
+ return label;
50
+ }
51
+ await new Promise((resolve) => setTimeout(resolve, Math.min(100, Math.max(0, deadline - Date.now()))));
52
+ }
53
+ throw new Error(`Visible Perps USD amount did not settle at ${expected}; last display was ${JSON.stringify(lastLabel)}.`);
54
+ };
55
+
56
+ const leverageMatches = (item) => {
57
+ if (item.test_id !== 'perps-order-view-leverage-row') return false;
58
+ const value = /(?:^|\s)([1-9][0-9]*)x(?:\s|$)/u.exec(String(item.label ?? '').trim());
59
+ return value !== null && Number(value[1]) === leverage;
60
+ };
61
+ const originalItems = await observe();
62
+ if (Number(initial.form.amount) === Number(amount) && initial.form.leverage === leverage
63
+ && !originalItems.some((item) => item.test_id === 'perps-order-view-keypad-done')) {
64
+ const observedAmount = await waitAmount(Number(amount), true);
65
+ const final = await readOrderInputState(input, initial.identity);
66
+ if (Number(final.form.amount) !== Number(amount) || final.form.leverage !== leverage) throw new Error('Committed Perps input changed during readback.');
67
+ return { action: input.action, amount, leverage, observedAmount, ...final, changed: false, submitted: false, proofPath: 'perps-input-native-and-mounted-form-readback' };
68
+ }
69
+ if (initial.form.leverage !== leverage) {
70
+ await press('perps-order-view-leverage-row');
71
+ if (leverage === 1) await pressText('1x');
72
+ else await press(`leverage-quick-select-${leverage}`);
73
+ await pressText(`Set ${leverage}x`);
74
+ if ((await readOrderInputState(input, initial.identity)).form.leverage !== leverage) throw new Error('Perps leverage selection did not commit.');
75
+ }
76
+ await press('perps-amount-display-touchable');
77
+ await press('keypad-delete-button', true);
78
+ if (Number((await readOrderInputState(input, initial.identity)).form.amount) !== 0) throw new Error('Perps keypad reset did not clear the amount.');
79
+ for (const digit of amount) {
80
+ await press(digit === '.' ? 'keypad-key-dot' : `keypad-key-${digit}`);
81
+ }
82
+ if (Number((await readOrderInputState(input, initial.identity)).form.amount) !== Number(amount)) throw new Error('Perps keypad input did not match the entered amount.');
83
+ await press('perps-order-view-keypad-done');
84
+ const observedAmount = await waitAmount(Number(amount), true);
85
+ const final = await readOrderInputState(input, initial.identity);
86
+ if (Number(final.form.amount) !== Number(amount) || final.form.leverage !== leverage) throw new Error('Committed Perps amount or leverage differs after Done.');
87
+ return { action: input.action, amount, leverage, observedAmount, ...final, changed: true, submitted: false, proofPath: 'perps-input-native-and-mounted-form-readback' };
88
+ }
89
+
90
+ runAdapter(configureOrder);
@@ -0,0 +1,48 @@
1
+ // Read the mounted confirmation form and its unchanged unsigned funding request.
2
+ import { bridgeCommand } from '../platform/bridge.mjs';
3
+
4
+ export async function readOrderInputState(input, expectedIdentity) {
5
+ const state = await bridgeCommand(input, ['eval', `(() => {
6
+ const forms = [];
7
+ const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
8
+ function walk(fiber) {
9
+ if (!fiber) return;
10
+ const value = fiber.memoizedProps?.value;
11
+ if (value?.orderForm && typeof value.setAmount === 'function') forms.push(value.orderForm);
12
+ walk(fiber.child);
13
+ walk(fiber.sibling);
14
+ }
15
+ for (const id of hook?.renderers?.keys() ?? []) {
16
+ for (const root of hook.getFiberRoots(id) ?? []) walk(root.current);
17
+ }
18
+ const approvals = Object.values(Engine.context.ApprovalController.state.pendingApprovals);
19
+ const approval = approvals[0];
20
+ const transaction = Engine.context.TransactionController.state.transactions.find(t => t.id === approval?.id);
21
+ const route = __AGENTIC__.getRoute();
22
+ const account = Engine.context.AccountsController.getSelectedAccount();
23
+ const perps = Engine.context.PerpsController.state;
24
+ if (forms.length !== 1 || approvals.length !== 1 || approval?.type !== 'transaction'
25
+ || transaction?.type !== 'perpsDepositAndOrder' || transaction.status !== 'unapproved' || transaction.hash
26
+ || route.name !== 'RedesignedConfirmations' || route.params?.asset !== forms[0].asset
27
+ || route.params?.direction !== forms[0].direction || account.address.toLowerCase() !== transaction.txParams.from.toLowerCase()) {
28
+ throw new Error('Perps input requires one selected unsigned confirmation and one matching mounted form.');
29
+ }
30
+ return {
31
+ identity: { approvalId: approval.id, transactionTime: transaction.time, routeKey: route.key,
32
+ account: account.address, chainId: transaction.chainId, provider: perps.activeProvider,
33
+ isTestnet: perps.isTestnet, asset: forms[0].asset, direction: forms[0].direction },
34
+ form: { amount: forms[0].amount, leverage: forms[0].leverage, orderType: forms[0].type,
35
+ limitPrice: forms[0].limitPrice ?? null,
36
+ takeProfitPrice: forms[0].takeProfitPrice ?? null, stopLossPrice: forms[0].stopLossPrice ?? null }
37
+ };
38
+ })()`]);
39
+ if (!state?.identity || ['approvalId', 'account', 'chainId', 'provider', 'routeKey', 'asset', 'direction']
40
+ .some((key) => typeof state.identity[key] !== 'string' || !state.identity[key])
41
+ || !Number.isFinite(state.identity.transactionTime) || typeof state.identity.isTestnet !== 'boolean') {
42
+ throw new Error('Perps input confirmation identity is incomplete.');
43
+ }
44
+ if (expectedIdentity && JSON.stringify(state.identity) !== JSON.stringify(expectedIdentity)) {
45
+ throw new Error('Perps input confirmation identity changed.');
46
+ }
47
+ return state;
48
+ }
@@ -0,0 +1,241 @@
1
+ // Bind live order results and cleanup to the exact UI approval, request and accepted fills.
2
+ import { bridgeCommand } from '../platform/bridge.mjs';
3
+
4
+ function confirmationFor(input) {
5
+ const confirmation = input.node?.confirmation;
6
+ const identity = confirmation?.identity;
7
+ if (!identity || ['approvalId', 'account', 'provider', 'chainId', 'routeKey', 'asset', 'direction']
8
+ .some((field) => typeof identity[field] !== 'string' || !identity[field])
9
+ || !Number.isSafeInteger(identity.transactionTime) || identity.isTestnet !== true
10
+ || identity.provider !== 'hyperliquid' || confirmation.form?.leverage !== 1
11
+ || !(Number(confirmation.form?.amount) > 0 && Number(confirmation.form.amount) <= 20)) {
12
+ throw new Error('Mobile receipt requires a frozen Hyperliquid testnet confirmation at 1x and at most $20.');
13
+ }
14
+ return confirmation;
15
+ }
16
+
17
+ function receiptExpression(confirmation, allowMissing = false) {
18
+ return `
19
+ const expected = ${JSON.stringify(confirmation)};
20
+ const controller = Engine.context.PerpsController;
21
+ function assertIdentity() {
22
+ const state = controller.state;
23
+ const account = Engine.context.AccountsController.getSelectedAccount();
24
+ if (account.address.toLowerCase() !== expected.identity.account.toLowerCase()
25
+ || state.activeProvider !== expected.identity.provider || state.isTestnet !== true) {
26
+ throw new Error('Mobile receipt account, provider or network changed.');
27
+ }
28
+ }
29
+ assertIdentity();
30
+ if (typeof __AGENTIC__.readPerpsOrderReceipts !== 'function') throw new Error('Mobile UI receipt observer is unavailable.');
31
+ const receipts = __AGENTIC__.readPerpsOrderReceipts(expected.identity.approvalId);
32
+ if (${allowMissing} && Array.isArray(receipts) && receipts.length === 0) return null;
33
+ if (!Array.isArray(receipts) || receipts.length !== 1) throw new Error('Exactly one UI submission receipt is required.');
34
+ const receipt = receipts[0];
35
+ for (const key of ['approvalId', 'transactionTime', 'account', 'provider', 'isTestnet', 'chainId', 'routeKey']) {
36
+ if (receipt.identity[key] !== expected.identity[key]) throw new Error('UI receipt identity differs from the frozen confirmation.');
37
+ }
38
+ for (const key of ['asset', 'direction']) if (receipt.request[key] !== expected.identity[key]) throw new Error('UI receipt market or direction differs.');
39
+ for (const key of ['amount', 'leverage', 'orderType', 'limitPrice', 'takeProfitPrice', 'stopLossPrice']) {
40
+ if ((receipt.request[key] ?? null) !== (expected.form[key] ?? null)) throw new Error('UI receipt request differs from committed ' + key + '.');
41
+ }
42
+ const ids = [...new Set([receipt.result?.orderId, ...(receipt.result?.childOrderIds ?? [])].filter(id => typeof id === 'string' && id))];
43
+ `;
44
+ }
45
+
46
+ export async function readOrderReceipt(input) {
47
+ const confirmation = confirmationFor(input);
48
+ const deadline = Date.now() + Number(input.node.timeout_ms ?? 30000);
49
+ input = { ...input, node: { ...input.node, _action_deadline_epoch_ms: deadline } };
50
+ for (;;) {
51
+ const receipt = await bridgeCommand(input, ['eval', `(() => { ${receiptExpression(confirmation, true)} return receipt; })()`]);
52
+ if (receipt?.status === 'settled') {
53
+ const protectionCount = ['takeProfitPrice', 'stopLossPrice'].filter((key) => receipt.request[key] != null).length;
54
+ if (receipt.result?.success === true && protectionCount) {
55
+ if (receipt.request.orderType !== 'market') throw new Error('Protected limit receipts remain unsupported.');
56
+ if (!receipt.protectionResult) {
57
+ if (Date.now() >= deadline) throw new Error('Mobile protection result is unknown; do not submit again.');
58
+ await new Promise((resolve) => setTimeout(resolve, 250));
59
+ continue;
60
+ }
61
+ const protectionIds = receipt.protectionResult.childOrderIds;
62
+ if (receipt.protectionResult.success !== true || !Array.isArray(protectionIds)
63
+ || protectionIds.length !== protectionCount || new Set(protectionIds).size !== protectionCount
64
+ || protectionIds.some((id) => typeof id !== 'string' || !/^[1-9][0-9]*$/u.test(id)
65
+ || id === receipt.result.orderId || receipt.result.childOrderIds?.includes(id))) {
66
+ throw new Error(`Mobile protection result lacks complete accepted IDs: ${JSON.stringify(receipt.protectionResult)}.`);
67
+ }
68
+ }
69
+ return { action: input.action, receipt };
70
+ }
71
+ if (Date.now() >= deadline) throw new Error('Mobile UI submission result is unknown; do not submit again.');
72
+ await new Promise((resolve) => setTimeout(resolve, 250));
73
+ }
74
+ }
75
+
76
+ export async function useOrderReceipt(input, operation) {
77
+ const confirmation = confirmationFor(input);
78
+ const deadline = Date.now() + Number(input.node.timeout_ms ?? 30000);
79
+ input = { ...input, node: { ...input.node, _action_deadline_epoch_ms: deadline } };
80
+ if ((input.node.market && input.node.market !== confirmation.identity.asset)
81
+ || (input.node.side && input.node.side !== confirmation.identity.direction)
82
+ || (['positions', 'orders'].includes(operation) && input.node.state !== 'open')) {
83
+ throw new Error('Receipt assertion requires the same market/direction and state=open.');
84
+ }
85
+ const mutation = operation === 'cancel' || operation === 'close';
86
+ const result = await bridgeCommand(input, ['eval-async', `(() => {
87
+ ${receiptExpression(confirmation)}
88
+ if (receipt.status !== 'settled' || receipt.result?.success !== true || !ids.length) throw new Error('Accepted UI receipt IDs are required before state assertion or cleanup.');
89
+ if (receipt.result.providerId && receipt.result.providerId !== expected.identity.provider) throw new Error('Accepted provider differs from UI receipt identity.');
90
+ if (!['market', 'limit'].includes(receipt.request.orderType)) throw new Error('Receipt cleanup supports ordinary market and limit orders only.');
91
+ const market = receipt.request.asset;
92
+ const protectionKeys = ['takeProfitPrice', 'stopLossPrice'].filter(key => receipt.request[key] != null);
93
+ const protectionIds = protectionKeys.length ? receipt.protectionResult?.childOrderIds : [];
94
+ if (protectionKeys.length && (receipt.request.orderType !== 'market' || receipt.protectionResult?.success !== true
95
+ || !Array.isArray(protectionIds) || protectionIds.length !== protectionKeys.length
96
+ || new Set(protectionIds).size !== protectionKeys.length
97
+ || protectionIds.some(id => typeof id !== 'string' || !/^[1-9][0-9]*$/.test(id) || ids.includes(id)))) {
98
+ throw new Error('Protected cleanup requires complete distinct accepted market protection IDs.');
99
+ }
100
+ const ownedIds = ids.concat(protectionIds);
101
+ const sign = receipt.request.direction === 'long' ? 1n : -1n;
102
+ function decimal(value) {
103
+ const match = /^(-?)(0|[1-9][0-9]{0,19})(?:\\.([0-9]{1,8}))?$/.exec(String(value));
104
+ if (!match) throw new Error('Receipt fill decimal is invalid.');
105
+ return BigInt(match[1] + match[2] + (match[3] ?? '').padEnd(8, '0'));
106
+ }
107
+ function readSnapshot() {
108
+ assertIdentity();
109
+ if (typeof controller.getUserDataSnapshot !== 'function') throw new Error('Complete user-data snapshots are unavailable.');
110
+ return controller.getUserDataSnapshot().then(snapshot => {
111
+ assertIdentity();
112
+ const identity = snapshot?.identity;
113
+ if (!Array.isArray(snapshot?.positions) || !Array.isArray(snapshot?.orders)
114
+ || typeof identity?.address !== 'string' || identity.address.toLowerCase() !== expected.identity.account.toLowerCase()
115
+ || identity.provider !== expected.identity.provider || identity.network !== 'testnet') {
116
+ throw new Error('Receipt snapshot is incomplete or belongs to another identity.');
117
+ }
118
+ const orders = snapshot.orders.filter(order => order.symbol === market);
119
+ if (orders.some(order => !ownedIds.includes(String(order.orderId)))
120
+ || new Set(orders.map(order => String(order.orderId))).size !== orders.length) {
121
+ throw new Error('Extra or unowned market orders prevent receipt assertion or cleanup.');
122
+ }
123
+ return {orders, positions: snapshot.positions.filter(position => position.symbol === market)};
124
+ });
125
+ }
126
+ function verifyProtection(position, orders, present) {
127
+ const matchedIds = [];
128
+ for (const [priceKey, ordersKey] of [['takeProfitPrice','takeProfitOrders'],['stopLossPrice','stopLossOrders']]) {
129
+ const wanted = present ? receipt.request[priceKey] : null;
130
+ const triggers = position[ordersKey];
131
+ const count = position[priceKey === 'takeProfitPrice' ? 'takeProfitCount' : 'stopLossCount'];
132
+ if (!Array.isArray(triggers)) throw new Error('Exact position protection arrays are unavailable.');
133
+ if (wanted == null) {
134
+ if (triggers.length || count !== 0 || position[priceKey] != null) throw new Error('Unexpected position protection remains.');
135
+ continue;
136
+ }
137
+ const direction = priceKey === 'takeProfitPrice' ? 'take_profit' : 'stop';
138
+ const type = direction === 'take_profit' ? 'take_profit_limit' : 'stop_market';
139
+ if (triggers.length !== 1 || count !== 1) throw new Error('Position protection count differs from the frozen UI request.');
140
+ const trigger = triggers[0];
141
+ const order = orders.find(candidate => String(candidate.orderId) === trigger.orderId);
142
+ if (!protectionIds.includes(trigger.orderId) || matchedIds.includes(trigger.orderId)
143
+ || trigger.direction !== direction || trigger.orderType !== type || decimal(trigger.triggerPrice) !== decimal(wanted)
144
+ || decimal(trigger.size) !== sign * decimal(position.size) || trigger.reduceOnly !== true || trigger.isPartial !== false
145
+ || !order || order.side !== (sign === 1n ? 'sell' : 'buy') || order.reduceOnly !== true
146
+ || order.isTrigger !== true || order.isPositionTpsl !== true || order.triggerOrderType !== type
147
+ || decimal(order.triggerPrice) !== decimal(wanted) || decimal(order.size) !== decimal(trigger.size)) {
148
+ throw new Error('Exact protection ID, price, size, side or type differs from the UI receipt.');
149
+ }
150
+ matchedIds.push(trigger.orderId);
151
+ }
152
+ if (present && matchedIds.length !== protectionIds.length) throw new Error('Accepted protection IDs are not all represented in the position.');
153
+ }
154
+ function verifyLineage(position) {
155
+ return controller.getOrderFills({aggregateByTime: false, startTime: receipt.identity.transactionTime, endTime: Date.now()}).then(fills => {
156
+ assertIdentity();
157
+ if (!Array.isArray(fills) || fills.length >= 2000) throw new Error('Receipt fill history is incomplete.');
158
+ const marketFills = fills.filter(fill => fill.symbol === market);
159
+ if (!marketFills.length || marketFills.some(fill => !ids.includes(String(fill.orderId)))) throw new Error('Another order changed the market or parent accepted fills are unavailable.');
160
+ marketFills.sort((a,b) => { const difference = sign * (decimal(a.startPosition) - decimal(b.startPosition)); return difference < 0n ? -1 : difference > 0n ? 1 : 0; });
161
+ let size = 0n;
162
+ for (const fill of marketFills) {
163
+ if (fill.side !== (sign === 1n ? 'buy' : 'sell') || decimal(fill.size) <= 0n || decimal(fill.startPosition) !== size) throw new Error('Parent accepted fills do not prove a position starting from zero.');
164
+ size += sign * decimal(fill.size);
165
+ }
166
+ if (size !== decimal(position.size) || position.leverage?.value !== receipt.request.leverage) throw new Error('Current position differs from exact parent fills or leverage.');
167
+ return marketFills;
168
+ });
169
+ }
170
+ function cancelOwned(orders, baseline) {
171
+ const results = [];
172
+ function waitForCancellation() {
173
+ return readSnapshot().then(after => {
174
+ if (baseline && (after.positions.length !== 1 || after.positions[0].size !== baseline.size
175
+ || after.positions[0].entryPrice !== baseline.entryPrice || after.positions[0].leverage?.value !== baseline.leverage.value)) {
176
+ throw new Error('Owned position changed during protective cancellation.');
177
+ }
178
+ if (!after.orders.length) {
179
+ if (baseline) verifyProtection(after.positions[0], after.orders, false);
180
+ return {after, results};
181
+ }
182
+ if (Date.now() >= ${deadline}) throw new Error('Accepted order cancellation is not independently confirmed.');
183
+ return new Promise(resolve => setTimeout(resolve, 250)).then(waitForCancellation);
184
+ });
185
+ }
186
+ return orders.reduce((previous, order) => previous.then(() => {
187
+ if (Date.now() >= ${deadline}) throw new Error('Receipt cleanup deadline elapsed before cancellation.');
188
+ assertIdentity();
189
+ return controller.cancelOrder({symbol: market, orderId: String(order.orderId)}).then(result => {
190
+ results.push({orderId: order.orderId, result});
191
+ if (result?.success !== true) throw new Error('Exact receipt cancellation failed: ' + JSON.stringify(results));
192
+ });
193
+ }), Promise.resolve()).then(waitForCancellation);
194
+ }
195
+ return readSnapshot().then(snapshot => {
196
+ const orders = snapshot.orders;
197
+ if (${JSON.stringify(operation)} === 'orders') {
198
+ if (!orders.length || orders.some(order => !ids.includes(String(order.orderId))
199
+ || order.side !== (sign === 1n ? 'buy' : 'sell'))) throw new Error('No matching exact accepted UI order remains open.');
200
+ return {receipt, orders};
201
+ }
202
+ if (${JSON.stringify(operation)} === 'cancel' && !protectionKeys.length) {
203
+ return cancelOwned(orders).then(({results}) => ({receipt, results, remainingOwnedOrders: 0}));
204
+ }
205
+ if (!snapshot.positions.length && ${JSON.stringify(operation)} === 'close' && !orders.length) return {receipt, closed: false, alreadyAbsent: true};
206
+ if (snapshot.positions.length !== 1) throw new Error('Receipt position is absent or ambiguous.');
207
+ const position = snapshot.positions[0];
208
+ if (protectionKeys.length && orders.some(order => !protectionIds.includes(String(order.orderId)))) throw new Error('Extra parent orders prevent protected position assertion or cleanup.');
209
+ return verifyLineage(position).then(fills => {
210
+ const protectionPresent = protectionIds.some(id => orders.some(order => String(order.orderId) === id));
211
+ verifyProtection(position, orders, ${JSON.stringify(operation)} === 'positions' || protectionPresent);
212
+ if (${JSON.stringify(operation)} === 'positions') return {receipt, position, fills};
213
+ if (orders.some(order => !protectionIds.includes(String(order.orderId)))) throw new Error('Parent order remainder prevents owned position cleanup.');
214
+ return cancelOwned(orders, position).then(({after, results}) => {
215
+ if (${JSON.stringify(operation)} === 'cancel') return {receipt, results, remainingOwnedOrders: 0};
216
+ const unchanged = after.positions[0];
217
+ return verifyLineage(unchanged).then(readSnapshot).then(beforeClose => {
218
+ if (beforeClose.positions.length !== 1 || beforeClose.orders.length
219
+ || beforeClose.positions[0].size !== unchanged.size || beforeClose.positions[0].entryPrice !== unchanged.entryPrice
220
+ || beforeClose.positions[0].leverage?.value !== unchanged.leverage.value) throw new Error('Owned position changed before close.');
221
+ verifyProtection(beforeClose.positions[0], beforeClose.orders, false);
222
+ if (Date.now() >= ${deadline}) throw new Error('Receipt cleanup deadline elapsed before close.');
223
+ assertIdentity();
224
+ return controller.closePosition({symbol: market, size: String(sign === 1n ? unchanged.size : String(unchanged.size).slice(1)), orderType: 'market', providerId: 'hyperliquid', position: unchanged}).then(closeResult => {
225
+ if (closeResult?.success !== true || !closeResult.orderId) throw new Error('Receipt-owned close has no accepted result: ' + JSON.stringify(closeResult));
226
+ function waitForClose() {
227
+ return readSnapshot().then(final => {
228
+ if (!final.positions.length && !final.orders.length) return {receipt, results, closeResult, closed: true};
229
+ if (Date.now() >= ${deadline}) throw new Error('Receipt-owned close is not independently confirmed.');
230
+ return new Promise(resolve => setTimeout(resolve, 250)).then(waitForClose);
231
+ });
232
+ }
233
+ return waitForClose();
234
+ });
235
+ });
236
+ });
237
+ });
238
+ });
239
+ })()`]);
240
+ return { action: input.action, ...result, mutation };
241
+ }
@@ -0,0 +1,33 @@
1
+ // Freeze the visible unsigned order inputs and refuse a repeated or unobservable submission.
2
+ import { bridgeCommand, runAdapter } from '../platform/bridge.mjs';
3
+ import { readOrderInputState } from './order-input-state.mjs';
4
+ import { observeNativeUi } from '../platform/observe-ui.mjs';
5
+
6
+ runAdapter(async (input) => {
7
+ const confirmation = await readOrderInputState(input, input.node?.identity);
8
+ const { identity, form } = confirmation;
9
+ const observation = await observeNativeUi({ refs: ['ui.visible'], node: input.node }, input.context);
10
+ const visible = observation.observations?.['ui.visible'];
11
+ const payRows = visible?.items?.filter((item) => item.test_id === 'pay-with') ?? [];
12
+ if (!visible || visible.truncated || payRows.length !== 1 || payRows[0].label !== 'Pay with, Perps balance') {
13
+ throw new Error('Receipt proof requires the visible Perps balance payment source; funding is not permitted.');
14
+ }
15
+ if (identity.provider !== 'hyperliquid' || identity.isTestnet !== true
16
+ || form.leverage !== 1 || !(Number(form.amount) > 0 && Number(form.amount) <= 20)
17
+ || !['market', 'limit'].includes(form.orderType)) {
18
+ throw new Error('Mobile receipt proof requires Hyperliquid testnet, 1x and at most $20 on a market or limit order.');
19
+ }
20
+ if (Number(form.amount) !== Number(input.node.amount) || form.leverage !== Number(input.node.leverage)) {
21
+ throw new Error('Committed Mobile order inputs differ from the requested amount or leverage.');
22
+ }
23
+ const protection = input.node.protection ?? 'none';
24
+ for (const [field, required] of [['takeProfitPrice', ['take_profit', 'both'].includes(protection)], ['stopLossPrice', ['stop_loss', 'both'].includes(protection)]]) {
25
+ if (required ? !(Number(form[field]) > 0) : form[field] !== null) throw new Error(`Committed Mobile ${field} does not match requested protection.`);
26
+ }
27
+ const prior = await bridgeCommand(input, ['eval', `(() => {
28
+ if (typeof __AGENTIC__.readPerpsOrderReceipts !== 'function') throw new Error('Mobile UI receipt observer is unavailable.');
29
+ return __AGENTIC__.readPerpsOrderReceipts(${JSON.stringify(identity.approvalId)});
30
+ })()`]);
31
+ if (!Array.isArray(prior) || prior.length) throw new Error('This unsigned confirmation already has a submission attempt; do not submit it again.');
32
+ return { action: input.action, confirmation, submitted: false };
33
+ });
@@ -0,0 +1,5 @@
1
+ // Observe the actual result of one UI submission without dispatching an order.
2
+ import { runAdapter } from '../platform/bridge.mjs';
3
+ import { readOrderReceipt } from './order-receipt.mjs';
4
+
5
+ runAdapter(readOrderReceipt);
@@ -2399,6 +2399,7 @@
2399
2399
  "schema": {
2400
2400
  "type": "object",
2401
2401
  "properties": {
2402
+ "confirmation": { "type": "object", "description": "Frozen read_order_input output; requires exact live UI receipt identity." },
2402
2403
  "market": {
2403
2404
  "type": "string",
2404
2405
  "description": "Single market symbol, e.g. BTC or ETH. Alias: symbol."
@@ -2490,6 +2491,7 @@
2490
2491
  "schema": {
2491
2492
  "type": "object",
2492
2493
  "properties": {
2494
+ "confirmation": { "type": "object", "description": "Frozen read_order_input output; requires exact live UI receipt identity." },
2493
2495
  "market": {
2494
2496
  "type": "string",
2495
2497
  "description": "Single market symbol, e.g. BTC or ETH. Alias: symbol."
@@ -2576,6 +2578,53 @@
2576
2578
  ],
2577
2579
  "execution_capabilities": ["app-mutation", "external-mutation"]
2578
2580
  },
2581
+ "metamask.perps.read_order_input": {
2582
+ "description": "Freeze committed unsigned Mobile inputs and require a receipt observer before UI submission.",
2583
+ "examples": [{ "action": "metamask.perps.read_order_input", "amount": "10", "leverage": 1, "protection": "none", "intent": "Freeze the exact unsigned order before its UI submission", "next": "submit-order" }],
2584
+ "schema": {
2585
+ "type": "object", "required": ["amount", "leverage"], "additionalProperties": false,
2586
+ "properties": {
2587
+ "amount": { "type": ["string", "number"] },
2588
+ "leverage": { "type": "number" },
2589
+ "identity": { "type": "object" },
2590
+ "protection": { "type": "string", "enum": ["none", "take_profit", "stop_loss", "both"], "default": "none" }
2591
+ }
2592
+ },
2593
+ "execution_capabilities": []
2594
+ },
2595
+ "metamask.perps.read_order_receipt": {
2596
+ "description": "Observe the actual Mobile UI submission result including failure and accepted IDs; never dispatch.",
2597
+ "examples": [{ "action": "metamask.perps.read_order_receipt", "confirmation": "{{outputs.capture-input.confirmation}}", "intent": "Observe the result of the frozen UI submission", "next": "done" }],
2598
+ "schema": {
2599
+ "type": "object", "required": ["confirmation"], "additionalProperties": false,
2600
+ "properties": {
2601
+ "confirmation": { "type": "object" },
2602
+ "timeout_ms": { "type": "number", "minimum": 1, "default": 30000 }
2603
+ }
2604
+ },
2605
+ "execution_capabilities": []
2606
+ },
2607
+ "metamask.perps.configure_order": {
2608
+ "description": "Set USD notional and visible leverage on an already-open instrumented Mobile confirmation; verify exact mounted values and native readback against its unchanged unsigned request, without submitting.",
2609
+ "schema": {
2610
+ "type": "object",
2611
+ "required": ["amount"],
2612
+ "properties": {
2613
+ "amount": { "type": ["string", "number"] },
2614
+ "leverage": { "type": "integer", "enum": [1, 2, 3, 5, 10, 20, 40], "default": 1 },
2615
+ "timeout_ms": { "type": "number", "minimum": 1, "default": 30000 }
2616
+ },
2617
+ "additionalProperties": false
2618
+ },
2619
+ "examples": [{
2620
+ "action": "metamask.perps.configure_order",
2621
+ "amount": "10",
2622
+ "leverage": 1,
2623
+ "intent": "Prepare the requested USD notional without submitting",
2624
+ "next": "done"
2625
+ }],
2626
+ "execution_capabilities": ["app-mutation"]
2627
+ },
2579
2628
  "metamask.perps.place_order": {
2580
2629
  "description": "mobile Place a Perps order through a supported app/API path. Default to testnet; mainnet order placement requires an explicit user request.",
2581
2630
  "schema": {
@@ -2685,6 +2734,7 @@
2685
2734
  "schema": {
2686
2735
  "type": "object",
2687
2736
  "properties": {
2737
+ "confirmation": { "type": "object", "description": "Frozen read_order_input output; requires exact live UI receipt identity." },
2688
2738
  "market": {
2689
2739
  "type": "string",
2690
2740
  "description": "Single market symbol, e.g. BTC or ETH. Alias: symbol."
@@ -2777,6 +2827,7 @@
2777
2827
  "schema": {
2778
2828
  "type": "object",
2779
2829
  "properties": {
2830
+ "confirmation": { "type": "object", "description": "Frozen read_order_input output; requires exact live UI receipt identity." },
2780
2831
  "market": {
2781
2832
  "type": "string",
2782
2833
  "description": "Single market symbol, e.g. BTC or ETH. Alias: symbol."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.49.1",
3
+ "version": "0.50.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"