@deeeed/metamask-harness 0.50.0 → 0.50.2

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,14 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.50.2 - 2026-09-08
6
+
7
+ - Remove Mobile receipt actions that require unpublished app/SDK APIs; reject stale receipt-based assertion and cleanup inputs before execution.
8
+
9
+ ## 0.50.1 - 2026-09-08
10
+
11
+ - 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.
12
+
5
13
  ## 0.50.0 - 2026-09-08
6
14
 
7
15
  - 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.
@@ -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",
@@ -1,7 +1,6 @@
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';
5
4
 
6
5
  function expectedOpen(input) {
7
6
  if (input.node?.state == null) throw new Error('metamask.perps.assert_orders requires state=open or state=none.');
@@ -11,6 +10,7 @@ function expectedOpen(input) {
11
10
  throw new Error(`metamask.perps.assert_orders received unsupported state: ${state}`);
12
11
  }
13
12
 
14
- runAdapter((input) => Object.hasOwn(input.node ?? {}, 'confirmation')
15
- ? useOrderReceipt(input, 'orders')
16
- : assertOrders(input, expectedOpen(input)));
13
+ runAdapter((input) => {
14
+ if (Object.hasOwn(input.node ?? {}, 'confirmation')) throw new Error('Receipt-based Mobile assertions are unsupported. Next: mm-harness help');
15
+ return assertOrders(input, expectedOpen(input));
16
+ });
@@ -1,7 +1,6 @@
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';
5
4
 
6
5
  function expectedOpen(input) {
7
6
  if (input.node?.state == null) throw new Error('metamask.perps.assert_positions requires state=open or state=none.');
@@ -11,6 +10,7 @@ function expectedOpen(input) {
11
10
  throw new Error(`metamask.perps.assert_positions received unsupported state: ${state}`);
12
11
  }
13
12
 
14
- runAdapter((input) => Object.hasOwn(input.node ?? {}, 'confirmation')
15
- ? useOrderReceipt(input, 'positions')
16
- : assertPositions(input, expectedOpen(input)));
13
+ runAdapter((input) => {
14
+ if (Object.hasOwn(input.node ?? {}, 'confirmation')) throw new Error('Receipt-based Mobile assertions are unsupported. Next: mm-harness help');
15
+ return assertPositions(input, expectedOpen(input));
16
+ });
@@ -1,10 +1,9 @@
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';
5
4
 
6
5
  runAdapter(async (input) => {
7
- if (Object.hasOwn(input.node ?? {}, 'confirmation')) return useOrderReceipt(input, 'cancel');
6
+ if (Object.hasOwn(input.node ?? {}, 'confirmation')) throw new Error('Receipt-based Mobile cleanup is unsupported; no orders were cancelled. Next: mm-harness help');
8
7
  const close = await closeOrders(input);
9
8
  const assertion = await assertOrders(input, false);
10
9
  return { ...assertion, close };
@@ -1,10 +1,9 @@
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';
5
4
 
6
5
  runAdapter(async (input) => {
7
- if (Object.hasOwn(input.node ?? {}, 'confirmation')) return useOrderReceipt(input, 'close');
6
+ if (Object.hasOwn(input.node ?? {}, 'confirmation')) throw new Error('Receipt-based Mobile cleanup is unsupported; no positions were closed. Next: mm-harness help');
8
7
  const close = await closePositions(input);
9
8
  const assertion = await assertPositions(input, false);
10
9
  return { ...assertion, close };
@@ -2399,7 +2399,6 @@
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." },
2403
2402
  "market": {
2404
2403
  "type": "string",
2405
2404
  "description": "Single market symbol, e.g. BTC or ETH. Alias: symbol."
@@ -2491,7 +2490,6 @@
2491
2490
  "schema": {
2492
2491
  "type": "object",
2493
2492
  "properties": {
2494
- "confirmation": { "type": "object", "description": "Frozen read_order_input output; requires exact live UI receipt identity." },
2495
2493
  "market": {
2496
2494
  "type": "string",
2497
2495
  "description": "Single market symbol, e.g. BTC or ETH. Alias: symbol."
@@ -2578,32 +2576,6 @@
2578
2576
  ],
2579
2577
  "execution_capabilities": ["app-mutation", "external-mutation"]
2580
2578
  },
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
2579
  "metamask.perps.configure_order": {
2608
2580
  "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
2581
  "schema": {
@@ -2734,7 +2706,6 @@
2734
2706
  "schema": {
2735
2707
  "type": "object",
2736
2708
  "properties": {
2737
- "confirmation": { "type": "object", "description": "Frozen read_order_input output; requires exact live UI receipt identity." },
2738
2709
  "market": {
2739
2710
  "type": "string",
2740
2711
  "description": "Single market symbol, e.g. BTC or ETH. Alias: symbol."
@@ -2827,7 +2798,6 @@
2827
2798
  "schema": {
2828
2799
  "type": "object",
2829
2800
  "properties": {
2830
- "confirmation": { "type": "object", "description": "Frozen read_order_input output; requires exact live UI receipt identity." },
2831
2801
  "market": {
2832
2802
  "type": "string",
2833
2803
  "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.50.0",
3
+ "version": "0.50.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -1,241 +0,0 @@
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
- }
@@ -1,33 +0,0 @@
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
- });
@@ -1,5 +0,0 @@
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);