@deeeed/metamask-harness 0.80.0 → 0.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/adapters/manifest.json +48 -64
  3. package/adapters/{terminal → web-dapp}/cleanup.mjs +9 -9
  4. package/adapters/{terminal → web-dapp}/inject.mjs +14 -14
  5. package/adapters/{terminal → web-dapp}/launch.mjs +27 -24
  6. package/adapters/web-dapp/lib/blocked-hosts.mjs +24 -0
  7. package/adapters/{terminal → web-dapp}/lib/cdp-client.mjs +1 -1
  8. package/adapters/{terminal → web-dapp}/lib/display.mjs +1 -1
  9. package/adapters/{terminal → web-dapp}/lib/hud.mjs +1 -1
  10. package/adapters/web-dapp/lib/policy.mjs +79 -0
  11. package/adapters/{terminal → web-dapp}/lib/processes.mjs +8 -8
  12. package/adapters/{terminal → web-dapp}/lib/readiness.mjs +32 -34
  13. package/adapters/{terminal → web-dapp}/stop.mjs +5 -4
  14. package/adapters/{terminal → web-dapp}/verify.mjs +9 -6
  15. package/adapters/{terminal → web-dapp}/wallet-host.mjs +44 -227
  16. package/dist/adapters/core/actions.js +4 -1
  17. package/dist/adapters/extension/actions.js +6 -2
  18. package/dist/adapters/extension/network-observer.js +13 -0
  19. package/dist/adapters/extension/run.js +115 -31
  20. package/dist/adapters/mobile/actions.js +4 -1
  21. package/dist/adapters/mobile/network-observer.js +68 -0
  22. package/dist/adapters/mobile/performance-observer.js +1 -3
  23. package/dist/adapters/mobile/run.js +32 -9
  24. package/dist/adapters/perps-order-inputs.js +36 -0
  25. package/dist/adapters/plugin-surface.js +98 -0
  26. package/dist/adapters/proof-failure.js +11 -0
  27. package/dist/adapters/surface.js +1 -1
  28. package/dist/adapters/{terminal → web-dapp}/actions.js +11 -8
  29. package/dist/adapters/web-dapp/console-capture.js +63 -0
  30. package/dist/adapters/web-dapp/doctor-checks.js +33 -0
  31. package/dist/adapters/{terminal → web-dapp}/launch.js +21 -19
  32. package/dist/adapters/web-dapp/overlay.js +21 -0
  33. package/dist/adapters/web-dapp/readiness.js +10 -0
  34. package/dist/adapters/{terminal → web-dapp}/run.js +22 -17
  35. package/dist/adapters/{terminal → web-dapp}/stop.js +5 -4
  36. package/dist/adapters/web-dapp/surface.js +149 -0
  37. package/dist/adapters.js +20 -4
  38. package/dist/cli.js +34 -6
  39. package/dist/command-contract.js +29 -7
  40. package/dist/commands/check.js +32 -8
  41. package/dist/commands/completion-candidates.js +5 -5
  42. package/dist/commands/doctor.js +19 -2
  43. package/dist/commands/farmslot-ready.js +1 -0
  44. package/dist/commands/help.js +1 -1
  45. package/dist/commands/launch/device.js +5 -5
  46. package/dist/commands/self-test.js +10 -5
  47. package/dist/engine.js +47 -0
  48. package/dist/funding-execution-context.js +81 -0
  49. package/dist/manifest.js +48 -24
  50. package/dist/mm-harness-cli.js +20 -3
  51. package/dist/performance-observation.js +7 -15
  52. package/dist/recipe-assistance-context.js +6 -7
  53. package/dist/runner.js +28 -8
  54. package/docs/CONTRIBUTING.md +4 -2
  55. package/docs/RECIPES.md +31 -3
  56. package/docs/cli-contract.md +5 -3
  57. package/library/actions/core/perps/_controller.mjs +24 -28
  58. package/library/actions/core/perps/_rewards.mjs +186 -0
  59. package/library/actions/core/perps/close_positions.mjs +1 -1
  60. package/library/actions/core/perps/place_order.mjs +167 -45
  61. package/library/actions/core/perps/quote_fees.mjs +7 -4
  62. package/library/actions/core/wallet/list_accounts.mjs +1 -1
  63. package/library/actions/extension/perps/close_visible_position.mjs +5 -0
  64. package/library/actions/extension/perps/edit_margin.mjs +4 -0
  65. package/library/actions/extension/perps/mutation-receipt.mjs +95 -3
  66. package/library/actions/extension/perps/perps.mjs +320 -3
  67. package/library/actions/extension/perps/place_order.mjs +3 -1
  68. package/library/actions/extension/perps/update_position_tpsl.mjs +4 -0
  69. package/library/actions/extension/platform/cdp.mjs +71 -3
  70. package/library/actions/mobile/perps/perps.mjs +216 -11
  71. package/library/actions/mobile/perps/place_order.mjs +3 -1
  72. package/library/actions/shared/console-collector.mjs +1 -1
  73. package/library/actions/shared/perps/order-policy.mjs +177 -0
  74. package/library/actions/{core/wallet/_fixture-accounts.mjs → shared/wallet/fixture-accounts.mjs} +18 -3
  75. package/library/actions/{terminal → web-dapp}/app/assert_no_console_errors.mjs +4 -4
  76. package/library/actions/{terminal → web-dapp}/app/launch.mjs +8 -7
  77. package/library/actions/{terminal → web-dapp}/platform/hud.mjs +7 -6
  78. package/library/actions/{terminal → web-dapp}/platform/page.mjs +13 -12
  79. package/library/actions/{terminal → web-dapp}/platform/runtime.mjs +16 -12
  80. package/library/actions/web-dapp/ui/navigate.mjs +24 -0
  81. package/library/actions/web-dapp/wallet/_signature-log.mjs +32 -0
  82. package/library/actions/{terminal → web-dapp}/wallet/assert_signatures.mjs +8 -14
  83. package/library/actions/{terminal → web-dapp}/wallet/list_accounts.mjs +2 -2
  84. package/library/actions/web-dapp/wallet/read_signatures.mjs +38 -0
  85. package/library/manifests/core.action-manifest.json +6 -2
  86. package/library/manifests/extension.action-manifest.json +4 -0
  87. package/library/manifests/mobile.action-manifest.json +12 -0
  88. package/library/manifests/{terminal.action-manifest.json → web-dapp.action-manifest.json} +18 -702
  89. package/package.json +6 -6
  90. package/site/assets/help-recipes.json +1 -0
  91. package/adapters/terminal/lib/mainnet-guard.mjs +0 -177
  92. package/adapters/terminal/lib/origin.mjs +0 -30
  93. package/adapters/terminal/lib/page-script.mjs +0 -246
  94. package/adapters/terminal/lib/strict-wallet.mjs +0 -138
  95. package/dist/adapters/terminal/console-capture.js +0 -59
  96. package/dist/adapters/terminal/doctor-checks.js +0 -22
  97. package/dist/adapters/terminal/overlay.js +0 -18
  98. package/dist/adapters/terminal/readiness.js +0 -11
  99. package/dist/adapters/terminal/surface.js +0 -133
  100. package/dist/commands/call.js +0 -671
  101. package/dist/commands/list-executables.js +0 -245
  102. package/dist/commands/manifest.js +0 -477
  103. package/dist/commands/run-engine.js +0 -1301
  104. package/dist/commands/run.js +0 -730
  105. package/dist/discovery.js +0 -15
  106. package/dist/metamask-action-validation.js +0 -68
  107. package/dist/network-observation.js +0 -290
  108. package/library/actions/terminal/perps/_venue.mjs +0 -242
  109. package/library/actions/terminal/perps/assert_orders.mjs +0 -54
  110. package/library/actions/terminal/perps/assert_positions.mjs +0 -40
  111. package/library/actions/terminal/perps/read_orders.mjs +0 -26
  112. package/library/actions/terminal/perps/read_positions.mjs +0 -25
  113. package/library/actions/terminal/perps/read_snapshot.mjs +0 -40
  114. package/library/actions/terminal/perps/revoke_agent.mjs +0 -58
  115. package/library/actions/terminal/perps/teardown_state.mjs +0 -104
  116. package/library/actions/terminal/ui/navigate.mjs +0 -31
  117. package/library/actions/terminal/wallet/_signature-log.mjs +0 -177
  118. package/library/actions/terminal/wallet/read_signatures.mjs +0 -37
@@ -195,6 +195,55 @@ function captureHelperPath() {
195
195
  return process.env.CAPTURE_HELPER_PATH || 'capture-helper';
196
196
  }
197
197
 
198
+ // A snapshot helper that outlived its harness process (a crashed run, or a
199
+ // timeout from before timed-out helpers were killed) is reparented to launchd
200
+ // and makes later snapshots hang. Kill those, and what they started (the npm
201
+ // wrapper `node …/capture-helper snapshot` runs the native binary as its
202
+ // child), before starting a new one. Only this user's processes are touched.
203
+ async function reapOrphanedCaptureHelpers(context) {
204
+ const ps = await runProcess('ps', ['-axo', 'pid=,ppid=,uid=,command='], {
205
+ cwd: context.projectRoot,
206
+ env: process.env,
207
+ timeoutMs: 5000,
208
+ });
209
+ if (ps.exitCode !== 0) throw new Error(`ps failed while reaping capture-helper: ${ps.stderr}`);
210
+ const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
211
+ const configured = captureHelperPath();
212
+ // The executable (or the script node runs) and its subcommand, never an
213
+ // argument. The configured path may contain spaces; other install paths may not.
214
+ const dir = configured.includes('/') ? `${escape(path.dirname(configured))}/|` : '';
215
+ const snapshotHelper = new RegExp(
216
+ `^(?:(?:\\S*/)?node\\s+)?(?:${dir}\\S*/)?${escape(path.basename(configured))} snapshot(?:\\s|$)`,
217
+ 'u',
218
+ );
219
+ const processes = ps.stdout.split('\n').flatMap((line) => {
220
+ const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/u.exec(line);
221
+ return match && Number(match[3]) === process.getuid()
222
+ ? [{ pid: Number(match[1]), ppid: Number(match[2]), command: match[4] }]
223
+ : [];
224
+ });
225
+ const doomed = new Set(
226
+ processes.filter((entry) => entry.ppid === 1 && snapshotHelper.test(entry.command)).map((entry) => entry.pid),
227
+ );
228
+ for (let grew = true; grew;) {
229
+ grew = false;
230
+ for (const entry of processes) {
231
+ if (doomed.has(entry.ppid) && !doomed.has(entry.pid)) {
232
+ doomed.add(entry.pid);
233
+ grew = true;
234
+ }
235
+ }
236
+ }
237
+ for (const pid of doomed) {
238
+ try {
239
+ process.kill(pid, 'SIGKILL');
240
+ } catch (error) {
241
+ // It exited between ps and kill: already reaped.
242
+ if (error?.code !== 'ESRCH') throw error;
243
+ }
244
+ }
245
+ }
246
+
198
247
  function parsePositivePid(value) {
199
248
  const pid = Number(String(value ?? '').trim());
200
249
  return Number.isInteger(pid) && pid > 0 ? pid : null;
@@ -280,7 +329,7 @@ async function captureCdpSnapshot(page, context, relPath, metadata, captureHelpe
280
329
  provider: 'cdp',
281
330
  mode: 'Page.captureScreenshot',
282
331
  fullPage: metadata?.fullPage === true,
283
- ...(captureHelperError ? { fallbackFrom: 'capture-helper', captureHelperError } : {}),
332
+ ...(captureHelperError ? { fallbackFrom: 'capture-helper', fallbackReason: captureHelperError, captureHelperError } : {}),
284
333
  },
285
334
  };
286
335
  }
@@ -318,6 +367,7 @@ export async function captureHelperSnapshot(page, context, relPath, metadata) {
318
367
  'native snapshot skipped while full-run recording owns the browser window',
319
368
  );
320
369
  } else {
370
+ await reapOrphanedCaptureHelpers(context);
321
371
  const result = await runProcess(
322
372
  captureHelperPath(),
323
373
  ['snapshot', '--pid', String(pid), '--output', destination.stagedPath],
@@ -413,17 +463,31 @@ async function execNodeScript(script, args, options) {
413
463
 
414
464
  function runProcess(command, args, options) {
415
465
  return new Promise((resolve, reject) => {
466
+ // A bounded command runs in its own process group so a timeout kills
467
+ // everything it started: the npm capture-helper wrapper runs the native
468
+ // binary as a child, which would otherwise keep our pipes open.
416
469
  const child = spawn(command, args, {
417
470
  cwd: options.cwd,
418
471
  env: options.env || process.env,
419
472
  stdio: ['ignore', 'pipe', 'pipe'],
473
+ detached: Boolean(options.timeoutMs),
420
474
  });
421
475
  let timer = null;
422
476
  let stdout = '';
423
477
  let stderr = '';
424
478
  if (options.timeoutMs) {
425
479
  timer = setTimeout(() => {
426
- child.kill('SIGTERM');
480
+ timer = null;
481
+ // SIGKILL: capture-helper can ignore SIGTERM while it holds a window capture.
482
+ try {
483
+ process.kill(-child.pid, 'SIGKILL');
484
+ } catch (error) {
485
+ // ESRCH: the whole group already exited; only the pipe close is pending.
486
+ if (error?.code !== 'ESRCH') {
487
+ reject(error);
488
+ return;
489
+ }
490
+ }
427
491
  reject(new Error(`Command timed out after ${options.timeoutMs}ms: ${command} ${args.join(' ')}`));
428
492
  }, options.timeoutMs);
429
493
  }
@@ -650,7 +714,11 @@ export async function openExtensionHomePage(port, extensionId) {
650
714
 
651
715
  async function ensureExtensionTarget(input, port) {
652
716
  let launch = null;
653
- const trustedTargetId = input.context?.trustedMutation?.cdpTargetId ?? null;
717
+ // A fixture-policy run (F21) has no target until its first mutation pins the
718
+ // live one; every later page in that action uses it.
719
+ const trusted = input.context?.trustedMutation;
720
+ const unpinnedPolicyLane = trusted?.proofLane === 'fixture-policy' && trusted.cdpTargetId === '';
721
+ const trustedTargetId = unpinnedPolicyLane ? null : trusted?.cdpTargetId ?? null;
654
722
  if (trustedTargetId !== null && !/^[A-Za-z0-9._:-]{1,128}$/u.test(String(trustedTargetId))) {
655
723
  throw new Error('Funded Extension action has no valid trusted CDP target id.');
656
724
  }
@@ -14,6 +14,14 @@ import {
14
14
  import { ensureUnlocked } from '../wallet/ensure_unlocked.mjs';
15
15
  import { selectAccount } from '../wallet/select_account.mjs';
16
16
  import { observeNativeUi } from '../platform/observe-ui.mjs';
17
+ import {
18
+ assertFlatBeforeRoundTrip,
19
+ assertHouseLimitPosition,
20
+ closeImmediatelyRequested,
21
+ orderNetwork,
22
+ requireHouseNotional,
23
+ requirePerpsOrderAllowed,
24
+ } from '../../shared/perps/order-policy.mjs';
17
25
 
18
26
  function sleep(ms) {
19
27
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -1281,7 +1289,7 @@ function closePositionParams(input, position) {
1281
1289
  return params;
1282
1290
  }
1283
1291
 
1284
- async function closePositionItem(input, position) {
1292
+ async function closePositionItem(input, position, bound = null) {
1285
1293
  const maxAttempts = Number(input.node?.close_attempts ?? 3);
1286
1294
  const retryDelayMs = Number(input.node?.close_retry_delay_ms ?? 1000);
1287
1295
  const deadline = deadlineFromTimeout(
@@ -1293,7 +1301,7 @@ async function closePositionItem(input, position) {
1293
1301
  let lastResult = null;
1294
1302
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
1295
1303
  if (Date.now() >= deadline) break;
1296
- const currentPositions = await readPositions(input);
1304
+ const currentPositions = bound ? await readBoundPositions(input, bound) : await readPositions(input);
1297
1305
  const currentPosition = currentPositions.find((candidate) => symbolForItem(candidate) === baseParams.symbol);
1298
1306
  if (!currentPosition) return { symbol: baseParams.symbol, result: { success: true, alreadyClosed: true }, attempts };
1299
1307
 
@@ -1301,8 +1309,12 @@ async function closePositionItem(input, position) {
1301
1309
  const params = { ...baseParams, position: currentPosition, priceAtCalculation };
1302
1310
  const result = await evalAsync(
1303
1311
  input,
1304
- `Engine.context.PerpsController.closePosition(${JSON.stringify(params)}).then(function(r){return JSON.stringify(r)})`,
1312
+ `(function(){ ${boundContextGuard(bound)}
1313
+ return Engine.context.PerpsController.closePosition(${sourceLiteral(params)}).then(function(r){return JSON.stringify(r)}); })()`,
1305
1314
  );
1315
+ if (result?.contextChanged) {
1316
+ throw new Error(`Close of ${baseParams.symbol} stopped: the selected account or network changed; nothing more was sent.`);
1317
+ }
1306
1318
  attempts.push({ attempt, priceAtCalculation, result });
1307
1319
  lastResult = result;
1308
1320
  if (result?.success === false) {
@@ -1394,7 +1406,37 @@ export async function placeOrder(input) {
1394
1406
  const maxSlippageBps = Number(input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300);
1395
1407
  const isBuy = orderSideIsBuy(side);
1396
1408
  const orderType = resolveOrderType(input);
1409
+ const closeImmediately = closeImmediatelyRequested(input.node);
1410
+ const runtime = await readPerpsRuntimeState(input);
1411
+ const network = orderNetwork({ declared: input.node?.network, liveIsTestnet: runtime?.isTestnet });
1412
+ // The account and network the rule allows, rechecked at dispatch and before any close.
1413
+ const bound = { network, address: String((await readSelectedAddress(input)) ?? '').toLowerCase() };
1414
+ const policy = await requirePerpsOrderAllowed({
1415
+ network,
1416
+ amount,
1417
+ leverage,
1418
+ orderType,
1419
+ closeImmediately,
1420
+ projectRoot: input.context?.projectRoot,
1421
+ selectedAddress: bound.address,
1422
+ });
1423
+ if ((closeImmediately || network === 'mainnet') && input.node?.size != null) {
1424
+ throw new Error('close_immediately and mainnet orders are sized from amount; drop size.');
1425
+ }
1426
+ if (closeImmediately) {
1427
+ const book = await readBoundBook(input, bound);
1428
+ assertFlatBeforeRoundTrip(book.positions, symbol, symbolForItem, book.orders);
1429
+ }
1397
1430
  const currentPrice = await currentPriceForOrder(input, symbol);
1431
+ const houseNotional = requireHouseNotional({
1432
+ network,
1433
+ symbol,
1434
+ amount,
1435
+ leverage,
1436
+ price: currentPrice,
1437
+ szDecimals: network === 'mainnet' ? await readSizeDecimals(input, symbol) : null,
1438
+ maxSlippageBps,
1439
+ });
1398
1440
  const limitPrice = orderType === 'limit'
1399
1441
  ? resolveLimitPrice(input, isBuy, currentPrice)
1400
1442
  : null;
@@ -1426,16 +1468,179 @@ export async function placeOrder(input) {
1426
1468
  priceAtCalculation: currentPrice,
1427
1469
  maxSlippageBps,
1428
1470
  };
1429
- const result = await evalAsync(input, `Engine.context.PerpsController.placeOrder(${JSON.stringify(orderParams)}).then(function(r){return JSON.stringify(r)})`);
1430
- if (result?.success === false || result == null) {
1431
- throw new Error(`Failed to place ${symbol} ${side}: ${result?.error || JSON.stringify(result)}`);
1471
+ if (await tradingContextChanged(input, bound)) {
1472
+ throw new Error('Perps order not dispatched: the selected account or network changed after the order rule allowed it.');
1473
+ }
1474
+ let result;
1475
+ try {
1476
+ result = await evalAsync(input, `(function(){ ${boundContextGuard(bound)}
1477
+ return Engine.context.PerpsController.placeOrder(${sourceLiteral(orderParams)}).then(function(r){return JSON.stringify(r)}); })()`);
1478
+ if (result?.contextChanged) {
1479
+ throw Object.assign(
1480
+ new Error('Perps order not dispatched: the selected account or network changed after the order rule allowed it.'),
1481
+ { knownNotDispatched: true },
1482
+ );
1483
+ }
1484
+ if (result?.success === false || result == null) {
1485
+ throw new Error(`Failed to place ${symbol} ${side}: ${result?.error || JSON.stringify(result)}`);
1486
+ }
1487
+ if (orderType === 'limit') {
1488
+ await waitForSelectedState(input, readOpenOrders, true, Number(input.node?.timeout_ms ?? 30000));
1489
+ } else {
1490
+ await waitForPositionPresent(input, symbol, Number(input.node?.timeout_ms ?? 30000));
1491
+ }
1492
+ } catch (error) {
1493
+ if (!closeImmediately || error?.knownNotDispatched) throw error;
1494
+ throw await closeAfterFailedPlacement(input, symbol, error, bound);
1495
+ }
1496
+ const placed = { action: input.action, market: symbol, side, orderType, amount, size, leverage, limitPrice, submitted: true, result, network, policy: policy.lane, ...(houseNotional ? { houseNotional } : {}), proofPath: 'mobile-perps-controller-place-order' };
1497
+ if (!closeImmediately) return placed;
1498
+ return { ...placed, roundTrip: await closeOpenedPosition(input, symbol, bound) };
1499
+ }
1500
+
1501
+ // close_immediately started from a flat market (checked before dispatch), so a
1502
+ // position in it after a failed placement is this order's: close it rather
1503
+ // than leave it open, and say what happened.
1504
+ async function closeAfterFailedPlacement(input, symbol, error, bound) {
1505
+ const message = error instanceof Error ? error.message : String(error);
1506
+ let outcome;
1507
+ try {
1508
+ if (await tradingContextChanged(input, bound)) {
1509
+ return new Error(`${message} close_immediately: the selected account or network changed, so nothing was closed; check ${symbol} on the original account now.`);
1510
+ }
1511
+ const position = (await readBoundPositions(input, bound)).find((candidate) => symbolForItem(candidate) === symbol);
1512
+ if (!position) {
1513
+ outcome = `no ${symbol} position is open`;
1514
+ } else {
1515
+ await closePositionItem(fullCloseInput(input), position, bound);
1516
+ outcome = await waitForBoundPositionAbsent(input, bound, symbol, Number(input.node?.timeout_ms ?? 30000))
1517
+ ? `the ${symbol} position it opened was closed`
1518
+ : `the ${symbol} position is STILL OPEN after a close; close it now`;
1519
+ }
1520
+ } catch (closeError) {
1521
+ outcome = `the ${symbol} position could not be checked or closed (${closeError instanceof Error ? closeError.message : String(closeError)}); check it now`;
1522
+ }
1523
+ return new Error(`${message} close_immediately: ${outcome}.`);
1524
+ }
1525
+
1526
+ async function readSizeDecimals(input, symbol) {
1527
+ const markets = await evalAsync(
1528
+ input,
1529
+ `Engine.context.PerpsController.getMarkets({ symbols: [${sourceLiteral(symbol)}] }).then(function(r){return JSON.stringify((r||[]).map(function(m){return { name: m.name, szDecimals: m.szDecimals };}))})`,
1530
+ );
1531
+ return (Array.isArray(markets) ? markets : []).find((market) => normalizeMarketSymbol(market?.name ?? '') === symbol)?.szDecimals ?? null;
1532
+ }
1533
+
1534
+ // The close takes the whole position: the order's own size or amount never
1535
+ // becomes a partial close size.
1536
+ function fullCloseInput(input) {
1537
+ const { size: _size, amount: _amount, notional: _notional, ...node } = input.node ?? {};
1538
+ return { ...input, node };
1539
+ }
1540
+
1541
+ // Bridge sources embed values as JSON literals. JSON.stringify leaves `<`, `>`,
1542
+ // `/` and U+2028/U+2029 raw; escape them so no value can end a script context
1543
+ // or a statement in the evaluated source.
1544
+ const UNSAFE_SOURCE_CHARS = { '<': '\\u003C', '>': '\\u003E', '/': '\\u002F', '\u2028': '\\u2028', '\u2029': '\\u2029' };
1545
+ export function sourceLiteral(value) {
1546
+ return JSON.stringify(value).replace(/[<>/\u2028\u2029]/gu, (char) => UNSAFE_SOURCE_CHARS[char]);
1547
+ }
1548
+
1549
+ // Bridge source that resolves to { contextChanged: true } unless the selected
1550
+ // account and Perps network are still the bound ones. It runs in the same
1551
+ // evaluation as the controller call it guards, so a switch can't slip between.
1552
+ function boundContextGuard(bound) {
1553
+ if (!bound) return '';
1554
+ return `var __account = Engine.context.AccountsController.getSelectedAccount();
1555
+ var __address = String((__account && __account.address) || '').toLowerCase();
1556
+ var __network = Engine.context.PerpsController.state.isTestnet ? 'testnet' : 'mainnet';
1557
+ if (__address !== ${sourceLiteral(bound.address)} || __network !== ${sourceLiteral(bound.network)}) {
1558
+ return Promise.resolve(JSON.stringify({ success: false, contextChanged: true }));
1559
+ }`;
1560
+ }
1561
+
1562
+ // The positions of the bound account on the bound network, read in one guarded
1563
+ // evaluation; a changed context throws instead of reading another account.
1564
+ async function readBoundPositions(input, bound) {
1565
+ const positions = await evalAsync(input, `(function(){ ${boundContextGuard(bound)}
1566
+ return Engine.context.PerpsController.getPositions().then(function(r){return JSON.stringify(r)}); })()`);
1567
+ if (positions?.contextChanged) {
1568
+ throw new Error('Perps positions not read: the selected account or network changed after the order rule allowed it. Check the order\'s market on the original account now.');
1569
+ }
1570
+ if (!Array.isArray(positions)) throw new Error('PerpsController.getPositions returned a non-array result.');
1571
+ return positions;
1572
+ }
1573
+
1574
+ // The flat-market baseline: the bound account's positions and resting orders,
1575
+ // read in one evaluation with the context guard before, between and after the
1576
+ // reads, so a switch to another account's book and back can't pass as flat.
1577
+ async function readBoundBook(input, bound) {
1578
+ const guard = `(function(){ ${boundContextGuard(bound)} return null; })()`;
1579
+ const book = await evalAsync(input, `(function(){
1580
+ var controller = Engine.context.PerpsController;
1581
+ var positions;
1582
+ return ${guard} || controller.getPositions().then(function(r){
1583
+ positions = r;
1584
+ return ${guard} || controller.getOpenOrders().then(function(orders){
1585
+ return ${guard} || JSON.stringify({ positions: positions, orders: orders });
1586
+ });
1587
+ }); })()`);
1588
+ if (book?.contextChanged) {
1589
+ throw new Error('Perps order not dispatched: the selected account or network changed while the market was checked flat.');
1590
+ }
1591
+ if (!Array.isArray(book?.positions) || !Array.isArray(book?.orders)) {
1592
+ throw new Error('PerpsController.getPositions or getOpenOrders returned a non-array result.');
1593
+ }
1594
+ return book;
1595
+ }
1596
+
1597
+ async function waitForBoundPositionAbsent(input, bound, symbol, timeoutMs) {
1598
+ const deadline = Date.now() + timeoutMs;
1599
+ for (;;) {
1600
+ const positions = await readBoundPositions(input, bound);
1601
+ if (!positions.some((position) => symbolForItem(position) === symbol)) return true;
1602
+ if (Date.now() >= deadline) return false;
1603
+ await sleep(1000);
1604
+ }
1605
+ }
1606
+
1607
+ async function tradingContextChanged(input, bound) {
1608
+ const runtime = await readPerpsRuntimeState(input);
1609
+ const network = runtime?.isTestnet === true ? 'testnet' : runtime?.isTestnet === false ? 'mainnet' : null;
1610
+ const address = String((await readSelectedAddress(input)) ?? '').toLowerCase();
1611
+ return network !== bound.network || address !== bound.address;
1612
+ }
1613
+
1614
+ async function readSelectedAddress(input) {
1615
+ return evalAsync(input, 'JSON.stringify(Engine.context.AccountsController.getSelectedAccount()?.address ?? null)');
1616
+ }
1617
+
1618
+ // close_immediately: close the position this order opened and prove the market
1619
+ // is flat. On mainnet the opened position must be isolated 1x; it is closed
1620
+ // either way before that check fails the action.
1621
+ async function closeOpenedPosition(input, symbol, bound) {
1622
+ const { network } = bound;
1623
+ if (await tradingContextChanged(input, bound)) {
1624
+ throw new Error(`close_immediately did not close ${symbol}: the selected account or network changed after the order. The order's position is still open on the original account; close it now.`);
1625
+ }
1626
+ const position = (await readBoundPositions(input, bound)).find((candidate) => symbolForItem(candidate) === symbol);
1627
+ if (!position) {
1628
+ throw new Error(`close_immediately could not find the ${symbol} position the order opened; it may still fill. Check ${symbol} now and close it if it opened.`);
1629
+ }
1630
+ let limitError = null;
1631
+ if (network === 'mainnet') {
1632
+ try {
1633
+ assertHouseLimitPosition(position, symbol);
1634
+ } catch (error) {
1635
+ limitError = error;
1636
+ }
1432
1637
  }
1433
- if (orderType === 'limit') {
1434
- await waitForSelectedState(input, readOpenOrders, true, Number(input.node?.timeout_ms ?? 30000));
1435
- } else {
1436
- await waitForPositionPresent(input, symbol, Number(input.node?.timeout_ms ?? 30000));
1638
+ const closed = await closePositionItem(fullCloseInput(input), position, bound);
1639
+ if (!(await waitForBoundPositionAbsent(input, bound, symbol, Number(input.node?.timeout_ms ?? 30000)))) {
1640
+ throw new Error(`close_immediately left the ${symbol} position open: ${JSON.stringify(redactPosition(position))}. Close it now.`);
1437
1641
  }
1438
- return { action: input.action, market: symbol, side, orderType, amount, size, leverage, limitPrice, submitted: true, result, proofPath: 'mobile-perps-controller-place-order' };
1642
+ if (limitError) throw limitError;
1643
+ return { opened: redactPosition(position), closed: true, result: closed.result };
1439
1644
  }
1440
1645
 
1441
1646
  export async function ensurePositions(input) {
@@ -1,8 +1,10 @@
1
1
  // Submit a Perps order through the Mobile controller and assert a matching open position.
2
+ // With close_immediately the order's position is already closed again, so the market must be flat.
2
3
  import { runAdapter } from '../platform/bridge.mjs';
4
+ import { closeImmediatelyRequested } from '../../shared/perps/order-policy.mjs';
3
5
  import { assertPositions, placeOrder } from './perps.mjs';
4
6
  runAdapter(async (input) => {
5
7
  const order = await placeOrder(input);
6
- const assertion = await assertPositions(input, true);
8
+ const assertion = await assertPositions(input, !closeImmediatelyRequested(input.node));
7
9
  return { ...assertion, submitted: order.submitted === true, order };
8
10
  });
@@ -15,7 +15,7 @@ export const COLLECTOR_SCRIPT_NAME = 'console-tail.mjs';
15
15
  export const CAPTURE_CONTROL_MARK = '[mm-harness-capture-control]';
16
16
 
17
17
  // Same derivation as ownerMarker('console-collector', …) in
18
- // adapters/terminal/lib/processes.mjs, so the terminal stop path finds it.
18
+ // adapters/web-dapp/lib/processes.mjs, so the web-dapp stop path finds it.
19
19
  export function collectorOwnerMarker(runtimeDir) {
20
20
  const id = createHash('sha256').update(path.resolve(runtimeDir)).digest('hex').slice(0, 32);
21
21
  return `--mm-harness-owner=console-collector-${id}`;
@@ -0,0 +1,177 @@
1
+ // The Perps order rule every adapter applies before dispatch (F21).
2
+ // Testnet orders need no funding proof. A mainnet order is allowed only from a
3
+ // synced fixture account within the house limits: 1x isolated, notional at
4
+ // most $15, closed again inside the same action (close_immediately: true).
5
+
6
+ import { isFixtureAccount } from '../wallet/fixture-accounts.mjs';
7
+
8
+ export const MAINNET_HOUSE_LIMITS = Object.freeze({
9
+ maxNotionalUsd: '15',
10
+ maxNotionalBaseUnits: 15_000_000n,
11
+ leverage: 1,
12
+ marginType: 'isolated',
13
+ });
14
+
15
+ const USDC_DECIMAL = /^(?:0|[1-9][0-9]{0,11})(?:\.([0-9]{1,6}))?$/u;
16
+
17
+ // USDC base units (6 decimals) of a non-negative decimal amount.
18
+ export function usdcBaseUnits(value) {
19
+ const text = String(value ?? '').trim();
20
+ const match = USDC_DECIMAL.exec(text);
21
+ if (!match) throw new Error(`Perps order amount must be a USDC decimal with at most 6 decimals, got ${JSON.stringify(text)}.`);
22
+ const [whole, fraction = ''] = text.split('.');
23
+ return BigInt(whole) * 1_000_000n + BigInt(fraction.padEnd(6, '0'));
24
+ }
25
+
26
+ // The order's network: the live app's, which must agree with the node's
27
+ // declared one. A mainnet order must declare network: mainnet, so a recipe
28
+ // written for testnet never trades real funds because the app moved.
29
+ export function orderNetwork({ declared, liveIsTestnet }) {
30
+ const live = liveIsTestnet === true ? 'testnet' : liveIsTestnet === false ? 'mainnet' : null;
31
+ if (!live) throw new Error('Perps order cannot verify whether the app is on testnet or mainnet.');
32
+ const wanted = declared == null || declared === '' ? null : String(declared).toLowerCase();
33
+ if (wanted && wanted !== 'testnet' && wanted !== 'mainnet') {
34
+ throw new Error(`Perps order network must be testnet or mainnet, got ${JSON.stringify(declared)}.`);
35
+ }
36
+ if (wanted && wanted !== live) throw new Error(`Perps order expected ${wanted}, but the app is on ${live}.`);
37
+ if (live === 'mainnet' && wanted !== 'mainnet') {
38
+ throw Object.assign(
39
+ new Error('PERPS_MAINNET_ORDER_REFUSED: Perps app is on mainnet but the order does not declare network: mainnet. Next: switch to testnet, or declare network: mainnet within the house limits.'),
40
+ { code: 'PERPS_MAINNET_ORDER_REFUSED' },
41
+ );
42
+ }
43
+ return live;
44
+ }
45
+
46
+ // Whether the node asks the action to close the position it opens.
47
+ export function closeImmediatelyRequested(node) {
48
+ return node?.close_immediately === true || node?.closeImmediately === true;
49
+ }
50
+
51
+ /**
52
+ * Decide whether a Perps order may be dispatched.
53
+ * @param {object} order
54
+ * @param {'testnet'|'mainnet'} order.network - The live app network, already cross-checked with the node.
55
+ * @param {string|number} order.amount - Margin in USDC.
56
+ * @param {number} order.leverage
57
+ * @param {'market'|'limit'} [order.orderType]
58
+ * @param {boolean} order.closeImmediately
59
+ * @param {boolean} order.fixtureAccount - The selected account is a synced fixture account.
60
+ * @returns {{ lane: 'testnet' } | { lane: 'mainnet-house-limits', notionalBaseUnits: string }}
61
+ */
62
+ export function decidePerpsOrder({ network, amount, leverage, orderType = 'market', closeImmediately, fixtureAccount }) {
63
+ if (closeImmediately && orderType !== 'market') {
64
+ throw new Error('close_immediately needs a market order: a resting limit order is not a position the action can close.');
65
+ }
66
+ // Testnet keeps whatever amount format the adapter accepts; only mainnet sizes are parsed strictly.
67
+ if (network === 'testnet') return { lane: 'testnet' };
68
+ if (network !== 'mainnet') throw new Error(`Perps order network must be testnet or mainnet, got ${JSON.stringify(network)}.`);
69
+ const leverageValue = Number(leverage);
70
+ const amountBaseUnits = usdcBaseUnits(amount);
71
+ const notionalBaseUnits = amountBaseUnits * BigInt(Number.isInteger(leverageValue) && leverageValue > 0 ? leverageValue : 1);
72
+ const broken = [];
73
+ if (amountBaseUnits <= 0n) broken.push('the amount is not positive');
74
+ if (!fixtureAccount) broken.push('the selected account is not one of the synced test accounts');
75
+ if (leverageValue !== MAINNET_HOUSE_LIMITS.leverage) broken.push(`leverage is ${leverage}, not 1`);
76
+ if (notionalBaseUnits > MAINNET_HOUSE_LIMITS.maxNotionalBaseUnits) {
77
+ broken.push(`notional ${formatUsd(notionalBaseUnits)} is above $${MAINNET_HOUSE_LIMITS.maxNotionalUsd}`);
78
+ }
79
+ if (orderType !== 'market') broken.push(`order type is ${orderType}, not market`);
80
+ if (!closeImmediately) broken.push('close_immediately is not true');
81
+ if (broken.length > 0) {
82
+ throw Object.assign(
83
+ new Error(
84
+ // The code leads the message: it is what survives a live adapter's process
85
+ // boundary, and the wording avoids the words bounded healing reads as wallet state.
86
+ `PERPS_MAINNET_ORDER_REFUSED: Mainnet Perps order refused before dispatch: ${broken.join('; ')}. ` +
87
+ `Mainnet orders need a synced test account, a 1x isolated market order, notional at most $${MAINNET_HOUSE_LIMITS.maxNotionalUsd} and close_immediately: true. ` +
88
+ 'Next: use testnet, or stay within those limits.',
89
+ ),
90
+ { code: 'PERPS_MAINNET_ORDER_REFUSED' },
91
+ );
92
+ }
93
+ return { lane: 'mainnet-house-limits', notionalBaseUnits: `${notionalBaseUnits}` };
94
+ }
95
+
96
+ // decidePerpsOrder with the fixture check resolved from the checkout's fixture.
97
+ export async function requirePerpsOrderAllowed({ projectRoot, selectedAddress, ...order }) {
98
+ const fixtureAccount = order.network === 'mainnet'
99
+ ? await isFixtureAccount(projectRoot, selectedAddress)
100
+ : false;
101
+ return decidePerpsOrder({ ...order, fixtureAccount });
102
+ }
103
+
104
+ // close_immediately closes the market's whole position (positions are netted
105
+ // per market), so it must start flat: no position and no resting order that
106
+ // could fill into the position it then closes.
107
+ export function assertFlatBeforeRoundTrip(positions, symbol, symbolOf, orders = []) {
108
+ if (positions.some((position) => symbolOf(position) === symbol)) {
109
+ throw new Error(`close_immediately needs no open ${symbol} position before the order; close it first.`);
110
+ }
111
+ if (orders.some((order) => symbolOf(order) === symbol)) {
112
+ throw new Error(`close_immediately needs no resting ${symbol} order before the order; cancel it first.`);
113
+ }
114
+ }
115
+
116
+ // The most a market order for `amount` USDC margin can fill at. The controller
117
+ // rounds the size to the market's szDecimals and adds one step when that falls
118
+ // short of the USD amount (perps-controller utils/orderCalculations.ts), and the
119
+ // fill may slip by maxSlippageBps: (amount × leverage + one step × price) × (1 + slippage).
120
+ export function worstCaseNotionalUsd({ amount, leverage, price, szDecimals, maxSlippageBps }) {
121
+ const usd = Number(amount) * Number(leverage);
122
+ const step = 10 ** -Number(szDecimals);
123
+ return (usd + step * Number(price)) * (1 + Number(maxSlippageBps) / 10_000);
124
+ }
125
+
126
+ // Mainnet only: refuse before dispatch when the size the controller would submit
127
+ // could fill above the house notional limit. Testnet needs no bound.
128
+ export function requireHouseNotional({ network, symbol, amount, leverage, price, szDecimals, maxSlippageBps }) {
129
+ if (network !== 'mainnet') return null;
130
+ // A missing value is unknown, never zero (Number(null) is 0).
131
+ const known = (value) => (value == null || value === '' ? Number.NaN : Number(value));
132
+ const inputs = { price: known(price), szDecimals: known(szDecimals), maxSlippageBps: known(maxSlippageBps) };
133
+ if (!(inputs.price > 0) || !Number.isInteger(inputs.szDecimals) || inputs.szDecimals < 0
134
+ || !Number.isFinite(inputs.maxSlippageBps) || inputs.maxSlippageBps < 0) {
135
+ throw Object.assign(
136
+ new Error(`PERPS_MAINNET_ORDER_REFUSED: Mainnet Perps order refused before dispatch: cannot bound the ${symbol} order size (price, size decimals or slippage unknown).`),
137
+ { code: 'PERPS_MAINNET_ORDER_REFUSED' },
138
+ );
139
+ }
140
+ const worst = worstCaseNotionalUsd({ amount, leverage, ...inputs });
141
+ if (worst > Number(MAINNET_HOUSE_LIMITS.maxNotionalUsd)) {
142
+ throw Object.assign(
143
+ new Error(
144
+ `PERPS_MAINNET_ORDER_REFUSED: Mainnet Perps order refused before dispatch: a ${symbol} order for $${Number(amount) * Number(leverage)} can fill at up to $${worst.toFixed(2)} ` +
145
+ `(size step ${10 ** -inputs.szDecimals} at $${inputs.price}, max slippage ${inputs.maxSlippageBps} bps), above $${MAINNET_HOUSE_LIMITS.maxNotionalUsd}. ` +
146
+ 'Next: order less, or lower max_slippage_bps.',
147
+ ),
148
+ { code: 'PERPS_MAINNET_ORDER_REFUSED' },
149
+ );
150
+ }
151
+ return { worstCaseNotionalUsd: Number(worst.toFixed(6)) };
152
+ }
153
+
154
+ // The opened position must be the isolated 1x one the policy allowed, worth at
155
+ // most the house limit, before the action closes it (mainnet), so a drift is
156
+ // reported, not hidden. The caller closes the position first either way.
157
+ export function assertHouseLimitPosition(position, symbol) {
158
+ const type = String(position?.leverage?.type ?? position?.marginType ?? '').toLowerCase();
159
+ const value = Number(position?.leverage?.value ?? position?.leverage);
160
+ if (type !== MAINNET_HOUSE_LIMITS.marginType || value !== MAINNET_HOUSE_LIMITS.leverage) {
161
+ throw new Error(
162
+ `Mainnet ${symbol} position opened as ${type || 'unknown'} ${Number.isFinite(value) ? `${value}x` : 'unknown leverage'}, not isolated 1x; it was closed.`,
163
+ );
164
+ }
165
+ const opened = Math.abs(Number(position?.size ?? position?.szi)) * Number(position?.entryPrice ?? position?.entryPx);
166
+ if (!Number.isFinite(opened) || opened > Number(MAINNET_HOUSE_LIMITS.maxNotionalUsd) + 1e-6) {
167
+ throw new Error(
168
+ `Mainnet ${symbol} position opened at ${Number.isFinite(opened) ? `$${opened.toFixed(2)}` : 'an unknown value'}, above $${MAINNET_HOUSE_LIMITS.maxNotionalUsd}; it was closed.`,
169
+ );
170
+ }
171
+ }
172
+
173
+ function formatUsd(baseUnits) {
174
+ const whole = baseUnits / 1_000_000n;
175
+ const fraction = `${baseUnits % 1_000_000n}`.padStart(6, '0').replace(/0+$/u, '');
176
+ return `$${whole}${fraction ? `.${fraction}` : ''}`;
177
+ }
@@ -1,6 +1,8 @@
1
- // The checkout's wallet fixture expanded into accounts: a mnemonic entry with
2
- // count N yields N accounts at address indexes 0..N-1, named dev1..devN from the
3
- // entry's name. list_accounts shows them; Core Perps actions select from them.
1
+ // The checkout's synced wallet fixture (temp/recipe/runtime/wallet-fixture.json)
2
+ // expanded into accounts: a mnemonic entry with count N yields N accounts at
3
+ // address indexes 0..N-1, named dev1..devN from the entry's name. list_accounts
4
+ // shows them, Core Perps actions sign with them, and the Perps order rule
5
+ // (../perps/order-policy.mjs) checks a mainnet order's account against them.
4
6
 
5
7
  import { readFile } from 'node:fs/promises';
6
8
  import { mnemonicToAccount, privateKeyToAccount } from 'viem/accounts';
@@ -67,3 +69,16 @@ export async function readFixtureAccounts(projectRoot) {
67
69
  }
68
70
  return { fixturePath, accounts: deriveFixtureAccounts(JSON.parse(raw), fixturePath) };
69
71
  }
72
+
73
+ // Whether an address is one of the fixture's accounts. A missing or unreadable
74
+ // fixture is "not a fixture account": the caller refuses, it never assumes.
75
+ export async function isFixtureAccount(projectRoot, address) {
76
+ const wanted = String(address ?? '').toLowerCase();
77
+ if (!/^0x[0-9a-f]{40}$/u.test(wanted) || !projectRoot) return false;
78
+ try {
79
+ const loaded = await readFixtureAccounts(projectRoot);
80
+ return Boolean(loaded?.accounts.some((account) => account.address.toLowerCase() === wanted));
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
@@ -1,4 +1,4 @@
1
- // Fail the recipe when the terminal page logged an error or uncaught exception
1
+ // Fail the recipe when the app page logged an error or uncaught exception
2
2
  // that the recipe libraries' console allowlist does not mark as known
3
3
  // third-party noise. Reads the slot console capture (app-console.log, and
4
4
  // extension-console.log when asked) from where the current slot browser's
@@ -20,7 +20,7 @@ import {
20
20
  loadConsoleAllowlist,
21
21
  } from '../../shared/console-findings.mjs';
22
22
  import { collectorOwnerMarker, collectorStatus, verifyCapture } from '../../shared/console-collector.mjs';
23
- import { isDirectRun, readBrowserState, runAdapter, terminalRuntimeDir } from '../platform/runtime.mjs';
23
+ import { isDirectRun, readBrowserState, runAdapter, webDappPolicy, webDappRuntimeDir } from '../platform/runtime.mjs';
24
24
 
25
25
  const LOGS = Object.freeze({ page: 'app-console.log', extension: 'extension-console.log' });
26
26
 
@@ -55,7 +55,7 @@ export async function assertNoConsoleErrors(input, env = process.env, deps = {})
55
55
  const projectRoot = input.context?.projectRoot;
56
56
  const sources = Array.isArray(node.sources) && node.sources.length ? node.sources.map(String) : ['page', 'browser'];
57
57
  const levels = new Set(Array.isArray(node.levels) && node.levels.length ? node.levels.map(String) : ['error', 'exception']);
58
- const runtime = terminalRuntimeDir(projectRoot);
58
+ const runtime = webDappRuntimeDir(projectRoot);
59
59
  const state = await readBrowserState(projectRoot);
60
60
  if (!state?.cdpPort || !state.appOrigin) throw unavailable('no slot browser state (browser.json)');
61
61
  const capture = (deps.collectorStatus ?? collectorStatus)({
@@ -107,7 +107,7 @@ export async function assertNoConsoleErrors(input, env = process.env, deps = {})
107
107
  // The recorded launch offset was past the end of a replaced capture
108
108
  // file; that file was read from its start.
109
109
  offsetResets,
110
- proofPath: 'terminal-console-capture',
110
+ proofPath: `${webDappPolicy().adapterId}-console-capture`,
111
111
  };
112
112
  if (failing.size > 0) {
113
113
  const list = [...failing.values()].map((item) => `${item.level} [${item.source}] ${item.example} ×${item.count}`).join('\n ');