@deeeed/metamask-harness 0.79.0 → 0.81.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 (252) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +1 -1
  3. package/adapters/extension/ensure-browser.sh +2 -2
  4. package/adapters/extension/inject.mjs +0 -1
  5. package/adapters/extension/launch-browser.cjs +39 -613
  6. package/adapters/extension/readiness.mjs +2 -5
  7. package/adapters/extension/reattach.sh +2 -2
  8. package/adapters/extension/stamp-runtime-title.cjs +1 -1
  9. package/adapters/manifest.json +16 -64
  10. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +57 -1358
  11. package/adapters/mobile/bridge-runtime/teaching.cjs +20 -0
  12. package/adapters/mobile/inject.sh +16 -0
  13. package/adapters/mobile/launch-console-forwarder.cjs +1 -2
  14. package/adapters/mobile/open-device.sh +2 -2
  15. package/adapters/mobile/reload-app.mjs +4 -4
  16. package/adapters/mobile/reset-app-data.sh +1 -1
  17. package/adapters/mobile/start-console-forwarder.sh +3 -2
  18. package/adapters/mobile/start-metro.sh +17 -12
  19. package/adapters/mobile/wait-for-bridge.cjs +2 -2
  20. package/adapters/shared/harness-source-fingerprint.mjs +13 -8
  21. package/adapters/shared/open-debug.mjs +2 -2
  22. package/adapters/shared/sync-wallet-fixture.sh +8 -5
  23. package/adapters/terminal/cleanup.mjs +2 -1
  24. package/adapters/terminal/inject.mjs +2 -1
  25. package/adapters/terminal/launch.mjs +17 -13
  26. package/adapters/terminal/lib/mainnet-guard.mjs +9 -0
  27. package/adapters/terminal/lib/readiness.mjs +20 -12
  28. package/adapters/terminal/policy.mjs +35 -0
  29. package/adapters/terminal/verify.mjs +10 -3
  30. package/adapters/terminal/wallet-host.mjs +43 -225
  31. package/bin/mm-harness +9 -28
  32. package/dist/adapters/checkout-files.js +16 -0
  33. package/dist/adapters/console-capture.js +14 -1
  34. package/dist/adapters/core/actions.js +8 -4
  35. package/dist/adapters/core/overlay.js +1 -1
  36. package/dist/adapters/core/readiness.js +28 -1
  37. package/dist/adapters/core/run.js +2 -2
  38. package/dist/adapters/core/surface.js +16 -1
  39. package/dist/adapters/extension/actions.js +6 -2
  40. package/dist/adapters/extension/browser-pid.js +56 -0
  41. package/dist/adapters/extension/console-capture.js +1 -1
  42. package/dist/adapters/extension/doctor-checks.js +1 -1
  43. package/dist/adapters/extension/ensure-ready.js +5 -4
  44. package/dist/adapters/extension/extension-id.js +1 -1
  45. package/dist/adapters/extension/feature-flags.js +1 -1
  46. package/dist/adapters/extension/fixtures.js +1 -2
  47. package/dist/adapters/extension/launch.js +4 -5
  48. package/dist/adapters/extension/launched-browser.js +1 -2
  49. package/dist/adapters/extension/network-observer.js +14 -1
  50. package/dist/adapters/extension/overlay.js +1 -1
  51. package/dist/adapters/extension/product-config.js +1 -1
  52. package/dist/adapters/extension/readiness.js +1 -1
  53. package/dist/adapters/extension/reload.js +1 -1
  54. package/dist/adapters/extension/run.js +135 -14
  55. package/dist/adapters/extension/runtime-decision.js +2 -1
  56. package/dist/adapters/extension/runtime.js +9 -5
  57. package/dist/adapters/extension/surface.js +15 -2
  58. package/dist/adapters/extension/verify.js +2 -2
  59. package/dist/adapters/harness-freshness.js +2 -1
  60. package/dist/adapters/mobile/actions.js +4 -1
  61. package/dist/adapters/mobile/android-gfxinfo-performance.js +1 -3
  62. package/dist/adapters/mobile/app-identity.js +10 -0
  63. package/dist/adapters/mobile/doctor-checks.js +1 -1
  64. package/dist/adapters/mobile/fixtures.js +1 -1
  65. package/dist/adapters/mobile/launch.js +27 -28
  66. package/dist/adapters/mobile/lifecycle.js +1 -1
  67. package/dist/adapters/mobile/metro-env.js +9 -59
  68. package/dist/adapters/mobile/network-observer.js +68 -0
  69. package/dist/adapters/mobile/performance-observer.js +2 -4
  70. package/dist/adapters/mobile/prepare-target.js +1 -1
  71. package/dist/adapters/mobile/prepare.js +3 -4
  72. package/dist/adapters/mobile/provision.js +1 -1
  73. package/dist/adapters/mobile/readiness.js +1 -1
  74. package/dist/adapters/mobile/recording-target.js +15 -0
  75. package/dist/adapters/mobile/release-artifact-state.js +1 -1
  76. package/dist/adapters/mobile/reload.js +1 -1
  77. package/dist/adapters/mobile/run.js +39 -15
  78. package/dist/adapters/mobile/runtime-decision.js +2 -4
  79. package/dist/adapters/mobile/source-freshness.js +22 -126
  80. package/dist/adapters/mobile/surface.js +26 -3
  81. package/dist/adapters/mobile/ui.js +16 -5
  82. package/dist/adapters/mobile/verify.js +2 -3
  83. package/dist/adapters/performance/cdp-trace.js +1 -1
  84. package/dist/adapters/performance/device-platform.js +1 -1
  85. package/dist/adapters/perps-order-inputs.js +36 -0
  86. package/dist/adapters/proof-failure.js +11 -0
  87. package/dist/adapters/slot-ports.js +1 -1
  88. package/dist/adapters/surface.js +1 -0
  89. package/dist/adapters/terminal/actions.js +4 -1
  90. package/dist/adapters/terminal/console-capture.js +1 -1
  91. package/dist/adapters/terminal/doctor-checks.js +2 -0
  92. package/dist/adapters/terminal/launch.js +4 -1
  93. package/dist/adapters/terminal/overlay.js +8 -1
  94. package/dist/adapters/terminal/run.js +33 -5
  95. package/dist/adapters/terminal/surface.js +9 -1
  96. package/dist/adapters.js +27 -7
  97. package/dist/advisor-policy.js +1 -1
  98. package/dist/checkout-view.js +1 -2
  99. package/dist/cli.js +59 -21
  100. package/dist/command-contract.js +1 -26
  101. package/dist/commands/check.js +33 -10
  102. package/dist/commands/checklist.js +1 -1
  103. package/dist/commands/completion-candidates.js +6 -14
  104. package/dist/commands/config.js +2 -2
  105. package/dist/commands/core-readiness.js +12 -7
  106. package/dist/commands/debug.js +3 -3
  107. package/dist/commands/device-target.js +5 -5
  108. package/dist/commands/doctor.js +2 -14
  109. package/dist/commands/domain.js +2 -2
  110. package/dist/commands/ensure-ready.js +1 -7
  111. package/dist/commands/fixtures.js +5 -6
  112. package/dist/commands/help.js +3 -4
  113. package/dist/commands/launch/device.js +10 -17
  114. package/dist/commands/launch/extension.js +2 -8
  115. package/dist/commands/launch/mobile.js +1 -1
  116. package/dist/commands/logs.js +3 -3
  117. package/dist/commands/mobile-device-view.js +4 -2
  118. package/dist/commands/overlay.js +110 -0
  119. package/dist/commands/pin.js +1 -1
  120. package/dist/commands/pr-body.js +2 -2
  121. package/dist/commands/prepare.js +4 -9
  122. package/dist/commands/provision.js +3 -12
  123. package/dist/commands/recipe-advice.js +1 -2
  124. package/dist/commands/recipe-quality.js +2 -3
  125. package/dist/commands/resolve-extension.js +1 -7
  126. package/dist/commands/review.js +4 -4
  127. package/dist/commands/runtime-decision.js +1 -6
  128. package/dist/commands/runtime-health.js +1 -7
  129. package/dist/commands/runtime-launch.js +1 -13
  130. package/dist/commands/self-test.js +5 -8
  131. package/dist/commands/status-watch.js +1 -2
  132. package/dist/commands/status.js +2 -5
  133. package/dist/commands/task-init.js +2 -3
  134. package/dist/commands/tutorial.js +2 -2
  135. package/dist/commands/update.js +1 -1
  136. package/dist/completions-cache.js +1 -1
  137. package/dist/console-classifier.js +19 -0
  138. package/dist/doctor.js +2 -3
  139. package/dist/engine.js +57 -0
  140. package/dist/funding-execution-context.js +96 -1
  141. package/dist/host.js +32 -0
  142. package/dist/index.js +1 -6
  143. package/dist/live-tsx.js +49 -0
  144. package/dist/mm-harness-cli.js +4 -6
  145. package/dist/paths.js +34 -64
  146. package/dist/performance-observation.js +3 -5
  147. package/dist/progress.js +1 -1
  148. package/dist/recipe-assistance-context.js +6 -9
  149. package/dist/recipe-security.js +2 -59
  150. package/dist/review/knowledge.js +2 -2
  151. package/dist/runner.js +7 -4
  152. package/dist/runtime-context.js +1 -1
  153. package/dist/task-view.js +2 -3
  154. package/docs/CONTRIBUTING.md +5 -3
  155. package/docs/RECIPES.md +31 -3
  156. package/docs/cli-contract.md +2 -2
  157. package/docs/feature-guard.md +90 -0
  158. package/library/README.md +3 -1
  159. package/library/actions/core/perps/_controller.mjs +153 -134
  160. package/library/actions/core/perps/_rewards.mjs +186 -0
  161. package/library/actions/core/perps/close_positions.mjs +1 -1
  162. package/library/actions/core/perps/place_order.mjs +167 -45
  163. package/library/actions/core/perps/quote_fees.mjs +61 -0
  164. package/library/actions/core/wallet/list_accounts.mjs +14 -57
  165. package/library/actions/extension/feature_flags/state.mjs +1 -1
  166. package/library/actions/extension/perps/close_visible_position.mjs +5 -0
  167. package/library/actions/extension/perps/edit_margin.mjs +4 -0
  168. package/library/actions/extension/perps/mutation-receipt.mjs +95 -3
  169. package/library/actions/extension/perps/perps.mjs +320 -3
  170. package/library/actions/extension/perps/place_order.mjs +3 -1
  171. package/library/actions/extension/perps/update_position_tpsl.mjs +4 -0
  172. package/library/actions/extension/platform/cdp.mjs +73 -11
  173. package/library/actions/harness-exports.mjs +1 -6
  174. package/library/actions/mobile/app/network-control.mjs +6 -8
  175. package/library/actions/mobile/perps/perps.mjs +216 -11
  176. package/library/actions/mobile/perps/place_order.mjs +3 -1
  177. package/library/actions/mobile/perps/prepare_local_snapshot_endpoint.mjs +1 -1
  178. package/library/actions/mobile/platform/bridge.mjs +8 -8
  179. package/library/actions/mobile/platform/native-session-name.mjs +21 -1
  180. package/library/actions/mobile/platform/native-session.mjs +3 -4
  181. package/library/actions/mobile/platform/observe-ui.mjs +12 -6
  182. package/library/actions/mobile/ui/native-navigation.mjs +4 -3
  183. package/library/actions/mobile/wallet/native-ui.mjs +3 -2
  184. package/library/actions/shared/analytics/_adapter.mjs +1 -1
  185. package/library/actions/shared/console-findings.mjs +1 -1
  186. package/library/actions/shared/perps/order-policy.mjs +177 -0
  187. package/library/actions/shared/wallet/fixture-accounts.mjs +84 -0
  188. package/library/actions/terminal/app/launch.mjs +3 -2
  189. package/library/actions/terminal/platform/hud.mjs +2 -1
  190. package/library/actions/terminal/platform/page.mjs +2 -1
  191. package/library/actions/terminal/platform/runtime.mjs +2 -1
  192. package/library/actions/terminal/wallet/_signature-log.mjs +40 -161
  193. package/library/actions/terminal/wallet/assert_signatures.mjs +6 -12
  194. package/library/actions/terminal/wallet/read_signatures.mjs +10 -9
  195. package/library/manifests/core.action-manifest.json +87 -13
  196. package/library/manifests/extension.action-manifest.json +4 -0
  197. package/library/manifests/mobile.action-manifest.json +12 -0
  198. package/package.json +8 -9
  199. package/site/assets/help-recipes.json +1 -0
  200. package/adapters/extension/lib/slot-title.cjs +0 -292
  201. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +0 -472
  202. package/adapters/mobile/bridge-runtime/lib/bridge-errors.cjs +0 -125
  203. package/adapters/mobile/bridge-runtime/lib/cdp-broker.cjs +0 -1055
  204. package/adapters/mobile/bridge-runtime/lib/cdp-eval.cjs +0 -110
  205. package/adapters/mobile/bridge-runtime/lib/config.cjs +0 -61
  206. package/adapters/mobile/bridge-runtime/lib/console-format.cjs +0 -53
  207. package/adapters/mobile/bridge-runtime/lib/devtools-proxy.cjs +0 -177
  208. package/adapters/mobile/bridge-runtime/lib/issue-capture.cjs +0 -446
  209. package/adapters/mobile/bridge-runtime/lib/match-bridge-target.cjs +0 -84
  210. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +0 -403
  211. package/adapters/mobile/bridge-runtime/lib/ws-client.cjs +0 -116
  212. package/adapters/mobile/coalesce-metro-log.cjs +0 -24
  213. package/adapters/mobile/launch-metro.cjs +0 -77
  214. package/adapters/mobile/metro-config.cjs +0 -98
  215. package/adapters/mobile/metro-log-generation.cjs +0 -106
  216. package/adapters/terminal/lib/origin.mjs +0 -30
  217. package/adapters/terminal/lib/page-script.mjs +0 -246
  218. package/adapters/terminal/lib/strict-wallet.mjs +0 -138
  219. package/dist/adapters/mobile/frame-metrics.js +0 -118
  220. package/dist/adapters/mobile/video-recorder.js +0 -289
  221. package/dist/artifact-files.js +0 -92
  222. package/dist/checkout-lock.js +0 -149
  223. package/dist/cli-color.js +0 -98
  224. package/dist/command-journal.js +0 -308
  225. package/dist/commands/call.js +0 -685
  226. package/dist/commands/last.js +0 -60
  227. package/dist/commands/launch/index.js +0 -109
  228. package/dist/commands/list-executables.js +0 -247
  229. package/dist/commands/manifest.js +0 -488
  230. package/dist/commands/parse-args.js +0 -279
  231. package/dist/commands/reload.js +0 -41
  232. package/dist/commands/run-engine.js +0 -1340
  233. package/dist/commands/run-report.js +0 -241
  234. package/dist/commands/run.js +0 -751
  235. package/dist/commands/shared.js +0 -323
  236. package/dist/commands/stop.js +0 -60
  237. package/dist/devices.js +0 -100
  238. package/dist/discovery.js +0 -15
  239. package/dist/execution-provenance.js +0 -332
  240. package/dist/harness.js +0 -451
  241. package/dist/heal-bounds.js +0 -137
  242. package/dist/json-stream.js +0 -60
  243. package/dist/leaf-invoke.js +0 -19
  244. package/dist/library-provenance.js +0 -33
  245. package/dist/live-adapter-contract.js +0 -380
  246. package/dist/metamask-action-validation.js +0 -68
  247. package/dist/network-observation.js +0 -290
  248. package/dist/recording-target.js +0 -120
  249. package/dist/run-diagnostics.js +0 -364
  250. package/dist/run-recording.js +0 -481
  251. package/dist/runtime-proof.js +0 -90
  252. package/library/actions/mobile/platform/tool-paths.mjs +0 -123
@@ -13,7 +13,16 @@ import {
13
13
  redactPosition,
14
14
  runAdapter,
15
15
  selectedItems,
16
+ symbolForItem,
16
17
  } from './_controller.mjs';
18
+ import { waitForPositionsAbsent } from './close_positions.mjs';
19
+ import {
20
+ assertFlatBeforeRoundTrip,
21
+ assertHouseLimitPosition,
22
+ closeImmediatelyRequested,
23
+ requireHouseNotional,
24
+ requirePerpsOrderAllowed,
25
+ } from '../../shared/perps/order-policy.mjs';
17
26
 
18
27
  // LIMIT orders (orderType=limit) place a RESTING order on the book instead of
19
28
  // filling: the provider sends tif=Gtc and uses the provided `price` verbatim
@@ -359,7 +368,7 @@ export async function placeOrder(input) {
359
368
  if (usdAmount === undefined) {
360
369
  throw new Error('metamask.perps.place_order requires amount=<usd> or notional=<usd>.');
361
370
  }
362
- const { controller, accountAddress, network } = await getCoreControllerWithSigner(input);
371
+ const { controller, accountAddress, network, signerAddress } = await getCoreControllerWithSigner(input);
363
372
  if (side !== 'long' && side !== 'short') {
364
373
  throw new Error(
365
374
  `metamask.perps.place_order received invalid side: ${side} (expected long or short).`,
@@ -421,7 +430,37 @@ export async function placeOrder(input) {
421
430
  throw new Error(`metamask.perps.place_order received invalid leverage: ${leverage}.`);
422
431
  }
423
432
 
433
+ const closeImmediately = closeImmediatelyRequested(input.node);
434
+ const policy = await requirePerpsOrderAllowed({
435
+ network,
436
+ amount: usdAmount,
437
+ leverage,
438
+ orderType,
439
+ closeImmediately,
440
+ projectRoot: input.context?.projectRoot,
441
+ selectedAddress: signerAddress,
442
+ });
443
+ if (closeImmediately) {
444
+ assertFlatBeforeRoundTrip(
445
+ await controller.getPositions({ standalone: true, userAddress: accountAddress }),
446
+ symbol,
447
+ symbolForItem,
448
+ await controller.getOpenOrders({ standalone: true, userAddress: accountAddress }),
449
+ );
450
+ }
451
+
424
452
  const currentPrice = await currentMarketPrice(controller, symbol);
453
+ const houseNotional = requireHouseNotional({
454
+ network,
455
+ symbol,
456
+ amount: usdAmount,
457
+ leverage,
458
+ price: currentPrice,
459
+ szDecimals: network === 'mainnet'
460
+ ? (await controller.getMarkets({ symbols: [symbol] }))?.find((market) => market?.name === symbol)?.szDecimals
461
+ : null,
462
+ maxSlippageBps,
463
+ });
425
464
 
426
465
  const reduceOnly = optionalBooleanParam(
427
466
  input.node ?? {},
@@ -524,59 +563,86 @@ export async function placeOrder(input) {
524
563
  };
525
564
  }
526
565
 
527
- const result = await controller.placeOrder(orderParams);
528
- if (!result || result.success !== true) {
529
- throw controllerRejection({
530
- action: 'core placeOrder',
531
- detail: `${symbol} (${orderType}), currentPrice=${currentPrice}${limitPrice ? `, limitPrice=${limitPrice}` : ''}`,
532
- code: result?.error ?? 'unknown error',
533
- });
566
+ // close_immediately started from a flat market (checked above), so a position
567
+ // in it after a failed placement is this order's: close it rather than leave it open.
568
+ const restsOnBook = orderType === 'limit' || isTriggerOrderType(orderType);
569
+ const placeAndVerify = async () => {
570
+ const result = await controller.placeOrder(orderParams);
571
+ if (!result || result.success !== true) {
572
+ throw controllerRejection({
573
+ action: 'core placeOrder',
574
+ detail: `${symbol} (${orderType}), currentPrice=${currentPrice}${limitPrice ? `, limitPrice=${limitPrice}` : ''}`,
575
+ code: result?.error ?? 'unknown error',
576
+ });
577
+ }
578
+
579
+ // For limit orders, confirm a RESTING open order exists (not a filled position);
580
+ // for market orders, confirm the position opened. Both read the real
581
+ // provider/exchange state (not the submit ack) before reporting success.
582
+ let matchingOrders = [];
583
+ let positions = [];
584
+ if (restsOnBook) {
585
+ matchingOrders = await waitForPlacedOrder(
586
+ controller,
587
+ accountAddress,
588
+ input,
589
+ {
590
+ orderId: result.orderId,
591
+ triggerOrderType: isTriggerOrderType(orderType) ? orderType : null,
592
+ triggerPrice,
593
+ limitPrice,
594
+ size: result.submittedSize ?? orderParams.size,
595
+ reduceOnly: orderParams.reduceOnly ?? false,
596
+ },
597
+ Number(optionalParam(input.node ?? {}, 'timeout_ms', 'timeoutMs') ?? 30000),
598
+ );
599
+ } else {
600
+ positions = await controller.getPositions({
601
+ standalone: true,
602
+ userAddress: accountAddress,
603
+ });
604
+ }
605
+ const matchingPositions = selectedItems(input, positions, 'position');
606
+
607
+ if (restsOnBook && matchingOrders.length === 0) {
608
+ throw new Error(
609
+ `core placeOrder (${orderType}) for ${symbol} reported success but no resting open order is visible (orderId=${result.orderId ?? 'null'}, limitPrice=${limitPrice}, triggerPrice=${triggerPrice}).`,
610
+ );
611
+ }
612
+ if (!restsOnBook && matchingPositions.length === 0) {
613
+ throw new Error(
614
+ `core placeOrder (market) for ${symbol} reported success but no matching position is visible (orderId=${result.orderId ?? 'null'}).`,
615
+ );
616
+ }
617
+ return { result, matchingOrders, matchingPositions };
618
+ };
619
+ let placed;
620
+ try {
621
+ placed = await placeAndVerify();
622
+ } catch (error) {
623
+ if (!closeImmediately) throw error;
624
+ throw await closeAfterFailedPlacement({ controller, accountAddress, symbol, maxSlippageBps, error });
534
625
  }
626
+ const { result, matchingOrders, matchingPositions } = placed;
535
627
 
536
- // For limit orders, confirm a RESTING open order exists (not a filled position);
537
- // for market orders, confirm the position opened. Both read the real
538
- // provider/exchange state (not the submit ack) before reporting success.
539
- const restsOnBook = orderType === 'limit' || isTriggerOrderType(orderType);
540
- let matchingOrders = [];
541
- let positions = [];
542
- if (restsOnBook) {
543
- matchingOrders = await waitForPlacedOrder(
628
+ const roundTrip = closeImmediately
629
+ ? await closeOpenedPosition({
544
630
  controller,
545
631
  accountAddress,
546
- input,
547
- {
548
- orderId: result.orderId,
549
- triggerOrderType: isTriggerOrderType(orderType) ? orderType : null,
550
- triggerPrice,
551
- limitPrice,
552
- size: result.submittedSize ?? orderParams.size,
553
- reduceOnly: orderParams.reduceOnly ?? false,
554
- },
555
- Number(optionalParam(input.node ?? {}, 'timeout_ms', 'timeoutMs') ?? 30000),
556
- );
557
- } else {
558
- positions = await controller.getPositions({
559
- standalone: true,
560
- userAddress: accountAddress,
561
- });
562
- }
563
- const matchingPositions = selectedItems(input, positions, 'position');
564
-
565
- if (restsOnBook && matchingOrders.length === 0) {
566
- throw new Error(
567
- `core placeOrder (${orderType}) for ${symbol} reported success but no resting open order is visible (orderId=${result.orderId ?? 'null'}, limitPrice=${limitPrice}, triggerPrice=${triggerPrice}).`,
568
- );
569
- }
570
- if (!restsOnBook && matchingPositions.length === 0) {
571
- throw new Error(
572
- `core placeOrder (market) for ${symbol} reported success but no matching position is visible (orderId=${result.orderId ?? 'null'}).`,
573
- );
574
- }
632
+ symbol,
633
+ network,
634
+ maxSlippageBps,
635
+ timeoutMs: Number(optionalParam(input.node ?? {}, 'timeout_ms', 'timeoutMs') ?? 30000),
636
+ })
637
+ : null;
575
638
 
576
639
  return {
577
640
  action: input.action,
578
641
  source: 'perps-controller-placeOrder',
579
642
  network,
643
+ policy: policy.lane,
644
+ ...(houseNotional ? { houseNotional } : {}),
645
+ ...(roundTrip ? { roundTrip } : {}),
580
646
  account: accountAddress,
581
647
  market: symbol,
582
648
  side,
@@ -602,4 +668,60 @@ export async function placeOrder(input) {
602
668
  };
603
669
  }
604
670
 
671
+ // close_immediately: close the position this order opened and prove the market
672
+ // is flat. On mainnet the opened position must be isolated 1x; it is closed
673
+ // either way before that check fails the action.
674
+ async function closeOpenedPosition({ controller, accountAddress, symbol, network, maxSlippageBps, timeoutMs }) {
675
+ const positions = await controller.getPositions({ standalone: true, userAddress: accountAddress });
676
+ const position = positions.find((candidate) => symbolForItem(candidate) === symbol);
677
+ if (!position) throw new Error(`close_immediately could not find the ${symbol} position the order opened.`);
678
+ let limitError = null;
679
+ if (network === 'mainnet') {
680
+ try {
681
+ assertHouseLimitPosition(position, symbol);
682
+ } catch (error) {
683
+ limitError = error;
684
+ }
685
+ }
686
+ const currentPrice = await currentMarketPrice(controller, symbol);
687
+ const result = await controller.closePosition({
688
+ symbol,
689
+ orderType: 'market',
690
+ currentPrice,
691
+ priceAtCalculation: currentPrice,
692
+ maxSlippageBps,
693
+ position,
694
+ });
695
+ const after = await waitForPositionsAbsent(controller, accountAddress, [symbol], timeoutMs);
696
+ if (result?.success !== true || after.some((candidate) => symbolForItem(candidate) === symbol)) {
697
+ throw new Error(
698
+ `close_immediately left the ${symbol} position open (${result?.error ?? 'still visible after close'}): ${JSON.stringify(redactPosition(position))}. Close it now.`,
699
+ );
700
+ }
701
+ if (limitError) throw limitError;
702
+ return { opened: redactPosition(position), closed: true, result };
703
+ }
704
+
705
+ async function closeAfterFailedPlacement({ controller, accountAddress, symbol, maxSlippageBps, error }) {
706
+ const message = error instanceof Error ? error.message : String(error);
707
+ let outcome;
708
+ try {
709
+ const positions = await controller.getPositions({ standalone: true, userAddress: accountAddress });
710
+ const position = positions.find((candidate) => symbolForItem(candidate) === symbol);
711
+ if (!position) {
712
+ outcome = `no ${symbol} position is open`;
713
+ } else {
714
+ const currentPrice = await currentMarketPrice(controller, symbol);
715
+ await controller.closePosition({ symbol, orderType: 'market', currentPrice, priceAtCalculation: currentPrice, maxSlippageBps, position });
716
+ const after = await waitForPositionsAbsent(controller, accountAddress, [symbol], 30000);
717
+ outcome = after.some((candidate) => symbolForItem(candidate) === symbol)
718
+ ? `the ${symbol} position is STILL OPEN after a close; close it now`
719
+ : `the ${symbol} position it opened was closed`;
720
+ }
721
+ } catch (closeError) {
722
+ outcome = `the ${symbol} position could not be checked or closed (${closeError instanceof Error ? closeError.message : String(closeError)}); check it now`;
723
+ }
724
+ return new Error(`${message} close_immediately: ${outcome}.`);
725
+ }
726
+
605
727
  if (isDirectRun(import.meta.url)) runAdapter(placeOrder);
@@ -0,0 +1,61 @@
1
+ // Quote an order's fees through the controller's calculateFees: the preview the
2
+ // clients show, with the rewards and subscription sources the fee resolver uses.
3
+ // Read-only: nothing is placed, and the only signature is the rewards API login.
4
+
5
+ import {
6
+ configuredSymbols,
7
+ getCoreController,
8
+ isDirectRun,
9
+ optionalBooleanParam,
10
+ optionalParam,
11
+ requestedRewardsDiscountBips,
12
+ runAdapter,
13
+ } from './_controller.mjs';
14
+ import { rewardsApiUrl } from './_rewards.mjs';
15
+
16
+ const ORDER_TYPES = ['market', 'limit'];
17
+
18
+ export async function quoteFees(input) {
19
+ const symbols = configuredSymbols(input, []);
20
+ if (symbols.length !== 1) {
21
+ throw new Error(`metamask.perps.quote_fees requires exactly one market; got ${JSON.stringify(symbols)}.`);
22
+ }
23
+ const orderType = (optionalParam(input.node, 'order_type', 'orderType') ?? 'market').toLowerCase();
24
+ if (!ORDER_TYPES.includes(orderType)) {
25
+ throw new Error(`metamask.perps.quote_fees order_type must be ${ORDER_TYPES.join(' or ')}; got ${orderType}.`);
26
+ }
27
+ const isMaker = optionalBooleanParam(input.node, 'is_maker', 'isMaker');
28
+ const amount = optionalParam(input.node, 'amount');
29
+ if (amount !== undefined && !(Number(amount) > 0)) {
30
+ throw new Error(`metamask.perps.quote_fees amount must be a positive USD notional; got ${amount}.`);
31
+ }
32
+
33
+ const { controller, accountAddress, network, rewardsClient } = await getCoreController(input);
34
+ // calculateFees routes to the active provider, which init() brings up.
35
+ await controller.init();
36
+ const fees = await controller.calculateFees({
37
+ symbol: symbols[0],
38
+ orderType,
39
+ ...(isMaker === undefined ? {} : { isMaker }),
40
+ ...(amount === undefined ? {} : { amount }),
41
+ });
42
+ return {
43
+ action: input.action,
44
+ source: 'perps-controller-calculateFees',
45
+ network,
46
+ account: accountAddress,
47
+ market: symbols[0],
48
+ orderType,
49
+ isMaker: isMaker ?? null,
50
+ amount: amount ?? null,
51
+ rewardsDiscountBips: requestedRewardsDiscountBips(input) ?? null,
52
+ // The node's discount, or the rewards API's answer for the account.
53
+ rewardsSource: requestedRewardsDiscountBips(input) === undefined ? 'rewards-api' : 'node.rewards_discount_bips',
54
+ // Each discount the controller asked the rewards API for (no token).
55
+ rewardsApi: { url: rewardsApiUrl(), calls: rewardsClient.calls },
56
+ fees,
57
+ proofPath: 'perps-controller-calculateFees',
58
+ };
59
+ }
60
+
61
+ if (isDirectRun(import.meta.url)) runAdapter(quoteFees);
@@ -3,72 +3,29 @@
3
3
 
4
4
  import { readFile, writeFile } from 'node:fs/promises';
5
5
  import { pathToFileURL } from 'node:url';
6
- import { mnemonicToAccount, privateKeyToAccount } from 'viem/accounts';
7
6
 
8
7
  import { walletFixturePath } from '../../harness-exports.mjs';
9
-
10
- function fixtureAccountName(base, index, count) {
11
- if (count === 1) return base || 'account1';
12
- const match = /^(.*?)(\d+)$/u.exec(base || 'account1');
13
- return match
14
- ? `${match[1]}${Number(match[2]) + index}`
15
- : `${base || 'account'}${index + 1}`;
16
- }
17
-
18
- function mnemonicCount(entry, fixturePath, index) {
19
- const count = Number(entry.count ?? entry.numberOfAccounts ?? 1);
20
- if (!Number.isInteger(count) || count < 1 || count > 100) {
21
- throw new Error(`${fixturePath} accounts[${index}] mnemonic count must be an integer from 1 through 100.`);
22
- }
23
- return count;
24
- }
25
-
26
- function deriveFixtureAccounts(fixture, fixturePath) {
27
- if (!Array.isArray(fixture.accounts) || fixture.accounts.length === 0) {
28
- throw new Error(`wallet fixture at ${fixturePath} has no accounts array.`);
29
- }
30
- return fixture.accounts.flatMap((entry, fixtureIndex) => {
31
- if (!entry || typeof entry !== 'object' || typeof entry.value !== 'string' || !entry.value.trim()) {
32
- throw new Error(`${fixturePath} accounts[${fixtureIndex}] must contain a non-empty value.`);
33
- }
34
- if (entry.type === 'mnemonic') {
35
- const count = mnemonicCount(entry, fixturePath, fixtureIndex);
36
- return Array.from({ length: count }, (_, addressIndex) => {
37
- const account = mnemonicToAccount(entry.value.trim(), { addressIndex });
38
- return {
39
- id: `fixture:${fixtureAccountName(entry.name, addressIndex, count)}`,
40
- address: account.address,
41
- name: fixtureAccountName(entry.name, addressIndex, count),
42
- type: 'eip155:eoa',
43
- };
44
- });
45
- }
46
- if (entry.type === 'privateKey') {
47
- const raw = entry.value.trim();
48
- const normalized = raw.startsWith('0x') ? raw : `0x${raw}`;
49
- if (!/^0x[0-9a-fA-F]{64}$/u.test(normalized)) {
50
- throw new Error(`${fixturePath} accounts[${fixtureIndex}] privateKey is not a 32-byte hex key.`);
51
- }
52
- const account = privateKeyToAccount(normalized);
53
- const name = entry.name || `account${fixtureIndex + 1}`;
54
- return [{ id: `fixture:${name}`, address: account.address, name, type: 'eip155:eoa' }];
55
- }
56
- throw new Error(`${fixturePath} accounts[${fixtureIndex}].type must be mnemonic or privateKey.`);
57
- });
58
- }
8
+ import { readFixtureAccounts } from '../../shared/wallet/fixture-accounts.mjs';
59
9
 
60
10
  async function listAccounts(input) {
61
11
  const scope = input.node?.scope == null ? 'named' : String(input.node.scope);
62
12
  if (scope !== 'selected' && scope !== 'named' && scope !== 'evm' && scope !== 'all') {
63
13
  throw new Error('metamask.wallet.list_accounts scope must be selected, named, evm, or all.');
64
14
  }
65
- const fixturePath = walletFixturePath(input.context.projectRoot);
66
- const fixture = JSON.parse(await readFile(fixturePath, 'utf8'));
15
+ const loaded = await readFixtureAccounts(input.context.projectRoot);
16
+ if (!loaded) {
17
+ throw new Error(
18
+ `no wallet fixture at ${walletFixturePath(input.context.projectRoot)}.\nNext: run mm-harness fixtures set`,
19
+ );
20
+ }
67
21
  const selectedName = String(input.node?.account_name ?? input.node?.account ?? 'dev1');
68
- const accounts = deriveFixtureAccounts(fixture, fixturePath)
69
- .map((account) => ({
70
- ...account,
71
- selected: account.name === selectedName,
22
+ const accounts = loaded.accounts
23
+ .map(({ name, address }) => ({
24
+ id: `fixture:${name}`,
25
+ address,
26
+ name,
27
+ type: 'eip155:eoa',
28
+ selected: name === selectedName,
72
29
  }))
73
30
  .filter((account) => {
74
31
  if (scope === 'selected') return account.selected;
@@ -15,7 +15,7 @@
15
15
  import fs from 'node:fs';
16
16
  import path from 'node:path';
17
17
 
18
- import { recipeRuntimeDir } from '../../harness-exports.mjs';
18
+ import { recipeRuntimeDir } from '@farmslot/recipe-cli/harness';
19
19
 
20
20
  export const EXTENSION_SET_UNSUPPORTED_REASON =
21
21
  'The extension exposes no runtime remote feature-flag override RPC: RemoteFeatureFlagController.setFlagOverride is never registered in metamask-controller.js getApi(), so stateHooks.submitRequestToBackground cannot reach it. Overrides are pinned into the per-run dist snapshot manifest at launch.';
@@ -11,6 +11,7 @@ import {
11
11
  trustedExternalMutationBudget,
12
12
  trustedExternalMutationIdentity,
13
13
  trustedExternalMutationPlanIsExact,
14
+ withFixturePolicyContext,
14
15
  withExternalMutationRecovery,
15
16
  } from './mutation-receipt.mjs';
16
17
  import { observeHyperliquidSubmission, placementReceiptSelection, requireCurrentVisibleFundingBalance, requirePerpsTradingContext } from './perps.mjs';
@@ -248,6 +249,10 @@ export async function closeVisiblePosition(input) {
248
249
  if (input.node?.confirm_live !== true) {
249
250
  throw new Error('metamask.perps.close_visible_position requires confirm_live=true.');
250
251
  }
252
+ ({ input } = await withFixturePolicyContext(input, page, {
253
+ operations: [{ operation: 'perps-close-position', amount: String(input.node?.funding_amount ?? '') }],
254
+ mainnetAllowed: true,
255
+ }));
251
256
  const fundingSide = String(input.node?.funding_side ?? '').toLowerCase();
252
257
  const fundingAmount = String(input.node?.funding_amount ?? '');
253
258
  const fundingPositionSize = String(input.node?.funding_position_size ?? '');
@@ -8,6 +8,7 @@ import {
8
8
  readPriorMutationReceipt,
9
9
  runGuardedExternalMutation,
10
10
  trustedExternalMutationIdentity,
11
+ withFixturePolicyContext,
11
12
  withExternalMutationRecovery,
12
13
  } from './mutation-receipt.mjs';
13
14
  import { requireCurrentVisibleFundingBalance, requirePerpsTradingContext } from './perps.mjs';
@@ -182,6 +183,9 @@ export async function editMargin(input) {
182
183
  if (input.node?.confirm_live !== true) {
183
184
  throw new Error('metamask.perps.edit_margin requires confirm_live=true for live submission.');
184
185
  }
186
+ ({ input } = await withFixturePolicyContext(input, page, {
187
+ operations: [{ operation: 'perps-margin-adjust', amount, count: 2 }],
188
+ }));
185
189
  const identity = trustedExternalMutationIdentity(input, page, {
186
190
  operation: 'perps-margin-adjust',
187
191
  authorizationCount: 2,
@@ -17,6 +17,11 @@ import {
17
17
  } from 'node:fs/promises';
18
18
  import path from 'node:path';
19
19
 
20
+ import { isFixtureAccount } from '../../shared/wallet/fixture-accounts.mjs';
21
+ import { MAINNET_HOUSE_LIMITS, orderNetwork, requirePerpsOrderAllowed } from '../../shared/perps/order-policy.mjs';
22
+ import { readWalletStateExpression } from '../wallet/state.mjs';
23
+ import { readPerpsRuntimeStateExpression } from './state.mjs';
24
+
20
25
  const SHA256 = /^[a-f0-9]{64}$/u;
21
26
  const EXECUTION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
22
27
  const NODE_ID = /^(?=.{1,256}$)[A-Za-z0-9][A-Za-z0-9._-]{0,63}(?:\/[A-Za-z0-9][A-Za-z0-9._-]{0,63})*$/u;
@@ -185,6 +190,90 @@ function trustedString(value, label, pattern) {
185
190
  return text;
186
191
  }
187
192
 
193
+ // Without funding files the runner gives Extension mutations a fixture-policy
194
+ // context (F21) that names no account, runtime or operation. Before its receipt
195
+ // identity, a mutating action completes it from the live app: the network
196
+ // (checked with the node's), the selected account, the CDP runtime, and the
197
+ // one or two operations it is about to run, after the shared order policy
198
+ // (library/actions/shared/perps/order-policy.mjs) allows them. Everything after
199
+ // that (identity, budget, receipts, ledger, resume) is the funded path's code.
200
+ export const FIXTURE_POLICY_LANE = 'fixture-policy';
201
+
202
+ export function inFixturePolicyLane(input) {
203
+ return input.context?.trustedMutation?.proofLane === FIXTURE_POLICY_LANE;
204
+ }
205
+
206
+ /**
207
+ * @param input - Action input carrying the runner's fixture-policy context.
208
+ * @param page - The Extension page (runtimeIdentity, evaluate).
209
+ * @param options.operations - [{ operation, amount, count?, order? }]: what this action may run
210
+ * (order: true caps it at the mainnet house limit on mainnet).
211
+ * @param options.order - Perps order to check with the shared policy, or null for a
212
+ * non-order mutation (allowed on testnet; on mainnet only a close from a fixture account).
213
+ * @returns {{ input, network, policy }} input with the completed context.
214
+ */
215
+ export async function withFixturePolicyContext(input, page, { operations, order = null, mainnetAllowed = false }) {
216
+ // mainnetAllowed: a non-order mutation (a close) a fixture account may run on mainnet.
217
+ if (!inFixturePolicyLane(input)) return { input, network: null, policy: null };
218
+ const state = await page.evaluate(readPerpsRuntimeStateExpression());
219
+ if (!state?.available || !state?.networkKnown) {
220
+ throw new Error('Perps mutation cannot verify the current provider and network.');
221
+ }
222
+ const network = orderNetwork({ declared: input.node?.network, liveIsTestnet: state.isTestnet });
223
+ const wallet = await page.evaluate(readWalletStateExpression());
224
+ const address = String(wallet?.selectedAccount?.address ?? '').toLowerCase();
225
+ if (!/^0x[0-9a-f]{40}$/u.test(address)) throw new Error('Perps mutation cannot read the selected account.');
226
+ const projectRoot = input.context?.projectRoot;
227
+ const policy = order
228
+ ? await requirePerpsOrderAllowed({ ...order, network, projectRoot, selectedAddress: address })
229
+ : null;
230
+ if (!order && network === 'mainnet'
231
+ && (!mainnetAllowed || !(await isFixtureAccount(projectRoot, address)))) {
232
+ throw Object.assign(
233
+ new Error(`PERPS_MAINNET_ORDER_REFUSED: ${input.action} on mainnet needs the funding-file release lane${mainnetAllowed ? ' or a synced test account' : ''}; refused before dispatch.`),
234
+ { code: 'PERPS_MAINNET_ORDER_REFUSED' },
235
+ );
236
+ }
237
+ const runtime = page?.runtimeIdentity ?? {};
238
+ const requirements = [{
239
+ kind: 'metric',
240
+ id: 'perps-available-margin-usdc',
241
+ minBaseUnits: '1',
242
+ actualBaseUnits: '1',
243
+ evidenceSha256: digest(`${FIXTURE_POLICY_LANE}:no-funding-minimum`),
244
+ }];
245
+ const trustedMutation = {
246
+ ...input.context.trustedMutation,
247
+ selectedIdentitySha256: digest(address),
248
+ runtimeSessionId: runtime.runtimeSessionId,
249
+ cdpPort: runtime.cdpPort,
250
+ cdpTargetId: runtime.cdpTargetId,
251
+ extensionOrigin: runtime.extensionOrigin,
252
+ operations: operations.map((entry) => ({
253
+ operation: entry.operation,
254
+ unit: 'usdc-base-units',
255
+ amountBaseUnits: decimalBaseUnits(entry.amount),
256
+ count: entry.count ?? 1,
257
+ })),
258
+ budgets: operations.map((entry) => ({
259
+ operation: entry.operation,
260
+ unit: 'usdc-base-units',
261
+ // A mainnet order's exposure is capped at the house limit; testnet orders
262
+ // and closes are bounded by the policy alone.
263
+ maxBaseUnits: entry.order && network === 'mainnet'
264
+ ? `${MAINNET_HOUSE_LIMITS.maxNotionalBaseUnits}`
265
+ : MAX_POLICY_BUDGET_BASE_UNITS,
266
+ maxCount: entry.count ?? 1,
267
+ })),
268
+ requirements,
269
+ fundingRequirementsSha256: digest(canonical(requirements)),
270
+ };
271
+ return { input: { ...input, context: { ...input.context, trustedMutation } }, network, policy };
272
+ }
273
+
274
+ // Testnet and close budgets: the policy, not a funding request, bounds them.
275
+ const MAX_POLICY_BUDGET_BASE_UNITS = '999999999999999999999999999999';
276
+
188
277
  export function trustedExternalMutationIdentity(input, page, operation) {
189
278
  const trusted = input.context?.trustedMutation;
190
279
  if (!trusted || typeof trusted !== 'object' || Array.isArray(trusted)) {
@@ -803,9 +892,12 @@ export async function runGuardedExternalMutation({ input, identity, dispatch, ob
803
892
  } catch (error) {
804
893
  if (error instanceof ExternalMutationNotDispatchedError) {
805
894
  await markExternalMutationNotDispatched(input, receipt, error, receiptIo);
806
- throw new Error(
807
- `External mutation was safely not dispatched (${error.message}). ` +
808
- 'Next: correct the observed state and create a fresh consent request, claim, and artifact root.',
895
+ throw Object.assign(
896
+ new Error(
897
+ `External mutation was safely not dispatched (${error.message}). ` +
898
+ 'Next: correct the observed state and create a fresh consent request, claim, and artifact root.',
899
+ ),
900
+ { knownNotDispatched: true },
809
901
  );
810
902
  }
811
903
  throw recoveryError(receipt, `pointer dispatch may have occurred (${error instanceof Error ? error.message : String(error)})`);