@deeeed/metamask-harness 0.87.0 → 0.88.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 (38) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/adapters/extension/ensure-browser.sh +6 -10
  3. package/adapters/extension/inject.mjs +1 -0
  4. package/adapters/extension/launch-browser.cjs +31 -4
  5. package/adapters/extension/profile-extension-state.cjs +156 -0
  6. package/adapters/extension/readiness.mjs +21 -2
  7. package/adapters/manifest.json +8 -0
  8. package/adapters/mobile/wait-for-bridge.cjs +9 -3
  9. package/dist/adapters/extension/actions.js +2 -1
  10. package/dist/adapters/extension/run.js +6 -0
  11. package/dist/adapters/mobile/android-screenrecord.js +10 -6
  12. package/dist/adapters/mobile/recording-outcome.js +2 -51
  13. package/dist/adapters/mobile/run.js +10 -4
  14. package/dist/adapters/perps-mainnet-cleanup.js +122 -0
  15. package/dist/engine.js +11 -2
  16. package/dist/funding-execution-context.js +13 -8
  17. package/dist/host.js +6 -2
  18. package/dist/index.js +3 -0
  19. package/dist/mm-harness-cli.js +4 -2
  20. package/dist/provider.js +72 -0
  21. package/dist/recipe-security.js +1 -0
  22. package/docs/CONTRIBUTING.md +7 -0
  23. package/docs/RECIPES.md +38 -13
  24. package/docs/RELEASE-QA-CAPABILITY-MAP.md +5 -1
  25. package/docs/cli-contract.md +1 -1
  26. package/library/actions/extension/perps/mutation-receipt.mjs +4 -3
  27. package/library/actions/extension/perps/perps.mjs +200 -9
  28. package/library/actions/extension/perps/set_network.mjs +6 -0
  29. package/library/actions/mobile/perps/perps.mjs +159 -11
  30. package/library/actions/mobile/perps/place_order.mjs +7 -4
  31. package/library/actions/shared/perps/mainnet-order-ledger.mjs +378 -0
  32. package/library/actions/shared/perps/order-policy.mjs +56 -19
  33. package/library/actions/shared/wallet/fixture-accounts.mjs +9 -3
  34. package/library/manifests/extension.action-manifest.json +34 -4
  35. package/library/manifests/mobile.action-manifest.json +18 -5
  36. package/package.json +14 -9
  37. package/scripts/validate-release-report.mjs +14 -2
  38. package/site/assets/help-recipes.json +1 -1
@@ -33,6 +33,18 @@ import {
33
33
  } from '../../shared/perps/order-policy.mjs';
34
34
  import { liveAdapterDeadline } from '../../shared/app/live-adapter-deadline.mjs';
35
35
  import { withRateLimitRetry } from '../../shared/perps/rate-limit.mjs';
36
+ import {
37
+ clearMainnetSwitch,
38
+ dropMainnetOrder,
39
+ ledgerOrderTargets,
40
+ markMainnetOrderCancelled,
41
+ ownedMainnetOrder,
42
+ recordMainnetSwitch,
43
+ recordPendingMainnetOrder,
44
+ recordPlacedMainnetOrder,
45
+ requireNoOpenMainnetOrders,
46
+ requirePendingMainnetOrder,
47
+ } from '../../shared/perps/mainnet-order-ledger.mjs';
36
48
  import {
37
49
  orderIdentityMatches,
38
50
  parseOrderCard,
@@ -240,8 +252,13 @@ export async function closePositions(input) {
240
252
  }
241
253
 
242
254
  async function closeOrderThroughBackground(input, page, timeoutMs) {
243
- ({ input } = await withFixturePolicyContext(input, page, {
255
+ // On mainnet the fixture lane cancels only an order this checkout's runs placed.
256
+ const owned = await ownedMainnetOrder(input.context?.projectRoot, input.node?.order_id);
257
+ let network;
258
+ ({ input, network } = await withFixturePolicyContext(input, page, {
244
259
  operations: [{ operation: 'perps-cancel-order', amount: String(input.node?.amount ?? '') }],
260
+ mainnetAllowed: Boolean(owned),
261
+ mainnetRefusal: 'cancels only orders this run placed',
245
262
  }));
246
263
  await requirePerpsTradingContext(input, page);
247
264
  const symbol = marketSymbol(input);
@@ -335,6 +352,10 @@ async function closeOrderThroughBackground(input, page, timeoutMs) {
335
352
  throw new Error('Exact background order did not become absent after cancellation.');
336
353
  },
337
354
  });
355
+ // The run's teardown settles it once the market's position is checked too.
356
+ if (network === 'mainnet' && owned) {
357
+ await markMainnetOrderCancelled(input.context.projectRoot, owned.id, { via: input.action, canceled: [{ orderId, receipt: mutation.receipt }] });
358
+ }
338
359
  return {
339
360
  action: input.action,
340
361
  canceled: true,
@@ -886,6 +907,7 @@ async function dispatchBackgroundPlace(page, {
886
907
  orderType,
887
908
  resolvedPrice,
888
909
  symbol,
910
+ timeInForce = 'GTC',
889
911
  }) {
890
912
  return page.evaluate(`(async () => {
891
913
  const request = globalThis.stateHooks?.submitRequestToBackground;
@@ -902,7 +924,7 @@ async function dispatchBackgroundPlace(page, {
902
924
  size: ((Number(usdAmount) * Number(leverage)) / Number(limitPrice)).toString(),
903
925
  orderType,
904
926
  price: String(limitPrice),
905
- timeInForce: 'GTC',
927
+ timeInForce: ${JSON.stringify(timeInForce)},
906
928
  leverage,
907
929
  currentPrice,
908
930
  priceAtCalculation: currentPrice,
@@ -1306,6 +1328,7 @@ async function verifyNothingPlacedOnVenue(input, page, identity, sinceMs) {
1306
1328
  async function placeOrderThroughBackground(input, page, {
1307
1329
  amount,
1308
1330
  bound = null,
1331
+ mainnetLimit = false,
1309
1332
  onDispatch = () => {},
1310
1333
  expected,
1311
1334
  identity,
@@ -1354,6 +1377,20 @@ async function placeOrderThroughBackground(input, page, {
1354
1377
  // A resume checks the fill against the lot sized at publication, not one
1355
1378
  // recomputed at a price that has since moved.
1356
1379
  const marketLot = existing?.record?.facts?.marketLot ?? expected.marketLot ?? null;
1380
+ const projectRoot = input.context?.projectRoot;
1381
+ // A mainnet limit order is tracked for the run's forced cancel (mainnet-order-ledger.mjs).
1382
+ const ledgerFields = mainnetLimit ? {
1383
+ platform: 'extension',
1384
+ provider: String(input.node?.provider ?? '').toLowerCase(),
1385
+ symbol,
1386
+ side: expected.side,
1387
+ limitPrice,
1388
+ address: bound.address,
1389
+ baselineOrderIds: [...baselineIds],
1390
+ artifactsDir: input.context.artifactsDir,
1391
+ connection: { cdpPort: page.runtimeIdentity?.cdpPort },
1392
+ } : null;
1393
+ let ledgerId = null;
1357
1394
  const mutation = await runGuardedExternalMutation({
1358
1395
  input,
1359
1396
  identity,
@@ -1382,12 +1419,16 @@ async function placeOrderThroughBackground(input, page, {
1382
1419
  );
1383
1420
  await requirePerpsTradingContext(input, page);
1384
1421
  assertTrustedExternalMutationTarget(input, page, identity);
1422
+ // A 429 retry (proven not placed) reuses the pending entry and its pre-order
1423
+ // baseline, unless the run began aborting or its cleanup settled the entry.
1424
+ if (ledgerId) await requirePendingMainnetOrder(projectRoot, ledgerId);
1385
1425
  if (deadline != null && Date.now() >= deadline) {
1386
1426
  throw new Error('it reached the action deadline before dispatch');
1387
1427
  }
1388
1428
  } catch (error) {
1389
1429
  throw externalMutationNotDispatched(error instanceof Error ? error.message : String(error));
1390
1430
  }
1431
+ if (ledgerFields && !ledgerId) ledgerId = await recordPendingMainnetOrder(projectRoot, ledgerFields);
1391
1432
  onDispatch();
1392
1433
  const accepted = await dispatchBackgroundPlace(page, {
1393
1434
  amount,
@@ -1399,8 +1440,11 @@ async function placeOrderThroughBackground(input, page, {
1399
1440
  orderType,
1400
1441
  resolvedPrice,
1401
1442
  symbol,
1443
+ // Post-only on mainnet: the venue rejects a limit that would fill now.
1444
+ timeInForce: mainnetLimit ? 'ALO' : 'GTC',
1402
1445
  });
1403
1446
  if (accepted?.changed) {
1447
+ if (ledgerId) await dropMainnetOrder(projectRoot, ledgerId);
1404
1448
  throw externalMutationNotDispatched('the selected account or Perps network changed after the order rule allowed it');
1405
1449
  }
1406
1450
  // The venue's filled order ID goes into the receipt, so cleanup can prove the
@@ -1425,6 +1469,14 @@ async function placeOrderThroughBackground(input, page, {
1425
1469
  receipt?.acceptance,
1426
1470
  ),
1427
1471
  });
1472
+ const placedOrderId = mutation.observation?.order?.id;
1473
+ if (ledgerFields && placedOrderId) {
1474
+ // A resumed placement dispatched in an earlier attempt: track it if that attempt did not.
1475
+ if (ledgerId) await recordPlacedMainnetOrder(projectRoot, ledgerId, placedOrderId);
1476
+ else if (!(await ownedMainnetOrder(projectRoot, placedOrderId))) {
1477
+ await recordPlacedMainnetOrder(projectRoot, await recordPendingMainnetOrder(projectRoot, ledgerFields), placedOrderId);
1478
+ }
1479
+ }
1428
1480
  return {
1429
1481
  action: input.action,
1430
1482
  market: symbol,
@@ -1786,11 +1838,15 @@ export async function placeOrder(input) {
1786
1838
  // The account and network the order rule allowed, rechecked before any close.
1787
1839
  let bound = null;
1788
1840
  const place = () => withExtensionPage(input, async (page) => {
1841
+ const hasBackgroundRequest = await page.evaluate(
1842
+ `typeof globalThis.stateHooks?.submitRequestToBackground === 'function'`,
1843
+ );
1789
1844
  const lane = await withFixturePolicyContext(input, page, {
1790
1845
  operations: [
1791
1846
  { operation: orderType === 'limit' ? 'perps-limit-order' : 'perps-market-order', amount, order: true },
1792
1847
  ],
1793
- order: { amount, leverage, orderType, closeImmediately },
1848
+ // Only the background placement tracks a limit order for the run's forced cancel.
1849
+ order: { amount, leverage, orderType, closeImmediately, runCancelsLimit: hasBackgroundRequest && !fromCurrentForm },
1794
1850
  });
1795
1851
  input = lane.input;
1796
1852
  if (lane.network) {
@@ -1856,9 +1912,6 @@ export async function placeOrder(input) {
1856
1912
  } : {}),
1857
1913
  ...(fromCurrentForm && orderType === 'limit' ? { maximumNotionalBaseUnits: placementBudget.maxBaseUnits } : {}),
1858
1914
  };
1859
- const hasBackgroundRequest = await page.evaluate(
1860
- `typeof globalThis.stateHooks?.submitRequestToBackground === 'function'`,
1861
- );
1862
1915
  if (closeImmediately) {
1863
1916
  // The close goes through the background request, so the order must too.
1864
1917
  if (!hasBackgroundRequest) throw new Error('close_immediately needs the background Perps request; it is unavailable.');
@@ -1873,6 +1926,9 @@ export async function placeOrder(input) {
1873
1926
  }
1874
1927
  if (hasBackgroundRequest && !fromCurrentForm) {
1875
1928
  await requirePerpsTradingContext(input, page);
1929
+ if (lane.network === 'mainnet' && (input.node?.current_price ?? input.node?.currentPrice) != null) {
1930
+ throw new Error('Mainnet orders are bounded at the live market price; drop current_price.');
1931
+ }
1876
1932
  const resolvedPrice = await readCurrentMarketPrice(
1877
1933
  page,
1878
1934
  symbol,
@@ -1880,6 +1936,9 @@ export async function placeOrder(input) {
1880
1936
  );
1881
1937
  const szDecimals = lane.network === 'mainnet' || orderType === 'market'
1882
1938
  ? await readSizeDecimals(page, symbol) : null;
1939
+ const backgroundLimitPrice = orderType === 'limit'
1940
+ ? resolveLimitPrice(input, isBuy, resolvedPrice)
1941
+ : null;
1883
1942
  houseNotional = requireHouseNotional({
1884
1943
  network: lane.network,
1885
1944
  symbol,
@@ -1888,14 +1947,13 @@ export async function placeOrder(input) {
1888
1947
  price: resolvedPrice,
1889
1948
  szDecimals,
1890
1949
  maxSlippageBps: Number(input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300),
1950
+ limit: orderType === 'limit' ? { price: backgroundLimitPrice, isBuy } : null,
1891
1951
  });
1892
1952
  const marketLot = orderType === 'market' ? marketOrderLot({ amount, leverage, price: resolvedPrice, szDecimals }) : null;
1893
- const backgroundLimitPrice = orderType === 'limit'
1894
- ? resolveLimitPrice(input, isBuy, resolvedPrice)
1895
- : null;
1896
1953
  return placeOrderThroughBackground(input, page, {
1897
1954
  amount,
1898
1955
  bound,
1956
+ mainnetLimit: lane.network === 'mainnet' && orderType === 'limit',
1899
1957
  onDispatch: () => {
1900
1958
  dispatchMayHaveHappened = true;
1901
1959
  },
@@ -2550,6 +2608,139 @@ async function ensureNetwork(input, config) {
2550
2608
  return withExtensionPage(input, (page) => assertNetwork(page, config.network));
2551
2609
  }
2552
2610
 
2611
+ // metamask.perps.set_network network=<testnet|mainnet> (default testnet): switch
2612
+ // the Perps network when it differs; the fixture loads testnet. A switch to
2613
+ // mainnet is recorded so the run's teardown switches back to testnet
2614
+ // (mainnet-order-ledger.mjs): mainnet lasts one run.
2615
+ export async function setNetwork(input) {
2616
+ const network = String(input.node?.network ?? 'testnet').toLowerCase();
2617
+ if (network !== 'testnet' && network !== 'mainnet') throw new Error(`${input.action} network must be testnet or mainnet, got ${JSON.stringify(input.node?.network)}.`);
2618
+ await applyStateNavigation(input, { page: 'home' });
2619
+ return withExtensionPage(input, async (page) => {
2620
+ if (network === 'testnet') {
2621
+ const state = await readPerpsRuntimeState(page);
2622
+ if (state.networkKnown && !state.isTestnet) await requireNoOpenMainnetOrders(input.context.projectRoot, input.action);
2623
+ }
2624
+ // Recorded before the toggle, so an abort mid-switch still switches back.
2625
+ if (network === 'mainnet') {
2626
+ await recordMainnetSwitch(input.context.projectRoot, {
2627
+ platform: 'extension',
2628
+ artifactsDir: input.context.artifactsDir,
2629
+ connection: { cdpPort: page.runtimeIdentity?.cdpPort },
2630
+ });
2631
+ }
2632
+ const switched = await togglePerpsNetwork(page, network, Number(input.node?.timeout_ms ?? 30_000));
2633
+ if (network === 'testnet' && switched.changed) await clearMainnetSwitch(input.context.projectRoot);
2634
+ return { action: input.action, ...switched, proofPath: 'background-perpsToggleTestnet' };
2635
+ });
2636
+ }
2637
+
2638
+ async function togglePerpsNetwork(page, network, timeoutMs) {
2639
+ const before = await readPerpsRuntimeState(page);
2640
+ if (!before.available || !before.networkKnown) {
2641
+ throw new Error('Cannot verify the current Perps network.\nNext: mm-harness verify');
2642
+ }
2643
+ if ((before.isTestnet ? 'testnet' : 'mainnet') === network) return { requested: network, isTestnet: before.isTestnet, changed: false };
2644
+ await requestBackground(page, 'perpsToggleTestnet', []);
2645
+ const deadline = Date.now() + timeoutMs;
2646
+ for (;;) {
2647
+ const after = await readPerpsRuntimeState(page);
2648
+ if (after.networkKnown && (after.isTestnet ? 'testnet' : 'mainnet') === network) {
2649
+ return { requested: network, isTestnet: after.isTestnet, changed: true };
2650
+ }
2651
+ if (Date.now() >= deadline) throw new Error(`Perps network did not switch to ${network} within ${timeoutMs} ms of perpsToggleTestnet.`);
2652
+ await sleep(250);
2653
+ }
2654
+ }
2655
+
2656
+ function cleanupInput(projectRoot, artifactsDir, cdpPort) {
2657
+ return {
2658
+ action: 'metamask.perps.close_orders',
2659
+ node: { cdp_port: cdpPort },
2660
+ context: { nodeId: 'mainnet-order-cleanup', projectRoot, artifactsDir },
2661
+ };
2662
+ }
2663
+
2664
+ // The order book fields the mainnet order ledger matches on.
2665
+ const LEDGER_ORDER_FIELDS = {
2666
+ idOf: (order) => order?.orderId ?? order?.id,
2667
+ symbolOf: symbolForItem,
2668
+ sideOf: sideForItem,
2669
+ priceOf: (order) => order?.price ?? order?.limitPrice ?? order?.limitPx ?? order?.px,
2670
+ };
2671
+
2672
+ // The account's open orders and the market's signed position size, from the
2673
+ // background requests only: a stream cache can be stale or empty after a
2674
+ // reload, which would read as "cancelled".
2675
+ async function readAuthoritativeBook(page, symbol) {
2676
+ const orders = await readOpenOrdersState(page);
2677
+ const positions = await page.readPositions();
2678
+ if (orders.source !== 'background-perpsGetOpenOrders' || positions.source !== 'background-perpsGetPositions') {
2679
+ throw new Error(`cannot read the ${symbol} book from the Extension background (got ${orders.source} and ${positions.source})`);
2680
+ }
2681
+ return {
2682
+ orders: orders.orders,
2683
+ positionSize: positions.positions
2684
+ .filter((position) => symbolForItem(position) === symbol)
2685
+ .reduce((total, position) => total + Number(position.size ?? position.szi ?? 0), 0),
2686
+ };
2687
+ }
2688
+
2689
+ // The run's forced cancel of one mainnet limit order it opened (see
2690
+ // mainnet-order-ledger.mjs), from the run's process: on the account that placed
2691
+ // it, still on mainnet, cancel it and read the book until it is gone. The read
2692
+ // also carries the market's position, which the caller compares with the one
2693
+ // before the order (a fill is reported, never closed here).
2694
+ export async function cancelMainnetLedgerOrder(entry, { projectRoot }) {
2695
+ const bound = { address: entry.address, network: 'mainnet' };
2696
+ // Only the proof: withExtensionPage adds the page's runtime identity to its result.
2697
+ const { canceled, read } = await withExtensionPage(cleanupInput(projectRoot, entry.artifactsDir, entry.connection?.cdpPort), async (page) => {
2698
+ const guard = await page.evaluate(`(async () => { ${boundContextGuard(bound)} return { changed: false }; })()`, { awaitPromise: true });
2699
+ const runtime = await readPerpsRuntimeState(page);
2700
+ if (guard?.changed || String(runtime.activeProvider ?? '').toLowerCase() !== entry.provider) {
2701
+ throw new Error(`the Extension is not on mainnet ${entry.provider} with the placing account ${entry.address} selected`);
2702
+ }
2703
+ const bookRead = (book, outcome) => ({
2704
+ source: 'background-perpsGetOpenOrders+perpsGetPositions',
2705
+ openOrderIds: book.orders.map((order) => String(LEDGER_ORDER_FIELDS.idOf(order) ?? '')),
2706
+ positionSize: book.positionSize,
2707
+ ...outcome,
2708
+ });
2709
+ const before = await readAuthoritativeBook(page, entry.symbol);
2710
+ const targets = ledgerOrderTargets(entry, before.orders, LEDGER_ORDER_FIELDS);
2711
+ if (targets.length === 0) return { canceled: [], read: bookRead(before, { notOpen: true }) };
2712
+ const canceled = [];
2713
+ for (const order of targets) {
2714
+ const orderId = String(LEDGER_ORDER_FIELDS.idOf(order));
2715
+ const result = await page.evaluate(`(async () => {
2716
+ ${boundContextGuard(bound)}
2717
+ return globalThis.stateHooks.submitRequestToBackground('perpsCancelOrder', [${JSON.stringify({ orderId, symbol: entry.symbol })}]);
2718
+ })()`, { awaitPromise: true });
2719
+ if (result?.changed) throw new Error(`the Extension left mainnet or account ${entry.address} before the cancel`);
2720
+ if (!result || result.success === false) throw new Error(`perpsCancelOrder ${orderId} failed: ${result?.error ?? JSON.stringify(result)}`);
2721
+ canceled.push({ orderId, result });
2722
+ }
2723
+ const deadline = Date.now() + 30_000;
2724
+ for (;;) {
2725
+ const after = await readAuthoritativeBook(page, entry.symbol);
2726
+ const open = after.orders.map((order) => String(LEDGER_ORDER_FIELDS.idOf(order))).filter((id) => canceled.some((item) => item.orderId === id));
2727
+ if (open.length === 0) return { canceled, read: bookRead(after, { absent: true }) };
2728
+ if (Date.now() >= deadline) throw new Error(`order(s) ${open.join(', ')} still open 30 s after the cancel`);
2729
+ await sleep(500);
2730
+ }
2731
+ });
2732
+ return { canceled, read };
2733
+ }
2734
+
2735
+ // The run's teardown: switch Perps back to the fixture's testnet after a
2736
+ // start_state network=mainnet.
2737
+ export async function restoreTestnetAfterRun(networkSwitch, { projectRoot }) {
2738
+ return withExtensionPage(
2739
+ cleanupInput(projectRoot, networkSwitch.artifactsDir, networkSwitch.connection?.cdpPort),
2740
+ (page) => togglePerpsNetwork(page, 'testnet', 30_000),
2741
+ );
2742
+ }
2743
+
2553
2744
  async function assertReadyToTrade(input, config) {
2554
2745
  if (config.readyToTrade === undefined || config.readyToTrade === false) return { skipped: true };
2555
2746
  return withExtensionPage(input, async (page) => {
@@ -0,0 +1,6 @@
1
+ // Switch the Extension's Perps network (default testnet). A switch to mainnet
2
+ // lasts one run: the run's teardown switches back to testnet.
3
+ import { runAdapter } from '../platform/cdp.mjs';
4
+ import { setNetwork } from './perps.mjs';
5
+
6
+ runAdapter(setNetwork);
@@ -27,6 +27,16 @@ import {
27
27
  requireHouseNotional,
28
28
  requirePerpsOrderAllowed,
29
29
  } from '../../shared/perps/order-policy.mjs';
30
+ import {
31
+ dropMainnetOrder,
32
+ ledgerOrderTargets,
33
+ markMainnetOrderCancelled,
34
+ ownedMainnetOrder,
35
+ readMainnetOrderLedger,
36
+ recordPendingMainnetOrder,
37
+ recordPlacedMainnetOrder,
38
+ requireNoOpenMainnetOrders,
39
+ } from '../../shared/perps/mainnet-order-ledger.mjs';
30
40
  import { isRateLimited, rateLimitedError, withRateLimitRetry } from '../../shared/perps/rate-limit.mjs';
31
41
 
32
42
  function sleep(ms) {
@@ -1354,6 +1364,7 @@ export async function closeOrders(input) {
1354
1364
  if (invalid) {
1355
1365
  throw new Error(`Cannot cancel selected Perps order without orderId and symbol: ${JSON.stringify(invalid)}`);
1356
1366
  }
1367
+ const owned = await mainnetOrdersOwned(input, orders);
1357
1368
  const result = await evalAsync(
1358
1369
  input,
1359
1370
  `(function(){
@@ -1385,10 +1396,105 @@ export async function closeOrders(input) {
1385
1396
  const after = await waitForOrdersAbsent(input, symbols, Number(input.node?.timeout_ms ?? 30000));
1386
1397
  const stillOpen = after.filter((order) => symbols.includes(symbolForItem(order)));
1387
1398
  if (stillOpen.length > 0) throw new Error(`Expected selected Perps orders to cancel, but ${stillOpen.length} are still open.`);
1399
+ // The run's teardown settles them once the market's position is checked too.
1400
+ // Only a cancel the controller accepted: anything else stays for the teardown to cancel.
1401
+ for (const entry of owned ?? []) {
1402
+ const canceled = (result?.results ?? []).filter((item) => item.orderId === entry.orderId && item.success);
1403
+ if (canceled.length > 0) await markMainnetOrderCancelled(input.context.projectRoot, entry.id, { via: input.action, canceled });
1404
+ }
1388
1405
  return { action: input.action, canceled: true, matchingCount: matching.length, symbols, result, proofPath: 'mobile-perps-controller-cancel-order' };
1389
1406
  }
1390
1407
 
1391
- export async function placeOrder(input) {
1408
+ // The order book fields the mainnet order ledger matches on.
1409
+ const LEDGER_ORDER_FIELDS = {
1410
+ idOf: orderIdForItem,
1411
+ symbolOf: symbolForItem,
1412
+ sideOf: sideForItem,
1413
+ priceOf: (order) => order?.price ?? order?.limitPrice ?? order?.limitPx ?? order?.px,
1414
+ };
1415
+
1416
+ // The signed size held in a market (0 when flat): a fill changes it.
1417
+ function positionSizeIn(positions, symbol) {
1418
+ return positions
1419
+ .filter((position) => symbolForItem(position) === symbol)
1420
+ .reduce((total, position) => total + Number(position.size ?? position.szi ?? 0), 0);
1421
+ }
1422
+
1423
+ // The run's forced cancel of one mainnet limit order it opened (see
1424
+ // mainnet-order-ledger.mjs), from the run's process: on the account that placed
1425
+ // it, still on mainnet, cancel it and read the book until it is gone. The read
1426
+ // also carries the market's position, which the caller compares with the one
1427
+ // before the order (a fill is reported, never closed here).
1428
+ export async function cancelMainnetLedgerOrder(entry, { projectRoot, env = {} }) {
1429
+ const input = {
1430
+ action: 'metamask.perps.close_orders',
1431
+ node: { controller_ready_timeout_ms: 15_000 },
1432
+ context: { nodeId: 'mainnet-order-cleanup', projectRoot, artifactsDir: entry.artifactsDir, env: { ...env, ...entry.connection } },
1433
+ };
1434
+ const bound = { address: entry.address, network: 'mainnet' };
1435
+ // The placing provider too: another provider's book would read as empty.
1436
+ const providerGuard = `if (String(Engine.context.PerpsController.state.activeProvider || '').toLowerCase() !== ${sourceLiteral(entry.provider)}) {
1437
+ return Promise.resolve(JSON.stringify({ contextChanged: true }));
1438
+ }`;
1439
+ const readBook = async () => {
1440
+ const book = await evalAsync(input, `(function(){ ${boundContextGuard(bound)} ${providerGuard}
1441
+ var c = Engine.context.PerpsController;
1442
+ return Promise.all([c.getOpenOrders(), c.getPositions()]).then(function(r){ return JSON.stringify({ orders: r[0], positions: r[1] }); }); })()`);
1443
+ if (book?.contextChanged) {
1444
+ throw new Error(`the app is not on mainnet ${entry.provider} with the placing account ${entry.address} selected`);
1445
+ }
1446
+ if (!Array.isArray(book?.orders) || !Array.isArray(book?.positions)) throw new Error('PerpsController returned a non-array book.');
1447
+ return book;
1448
+ };
1449
+ const bookRead = (book, outcome) => ({
1450
+ source: 'PerpsController.getOpenOrders+getPositions',
1451
+ openOrderIds: book.orders.map(orderIdForItem).map(String),
1452
+ positionSize: positionSizeIn(book.positions, entry.symbol),
1453
+ ...outcome,
1454
+ });
1455
+ const before = await readBook();
1456
+ const targets = ledgerOrderTargets(entry, before.orders, LEDGER_ORDER_FIELDS);
1457
+ if (targets.length === 0) return { canceled: [], read: bookRead(before, { notOpen: true }) };
1458
+ const orders = targets.map((order) => ({ orderId: String(orderIdForItem(order)), symbol: entry.symbol }));
1459
+ const canceled = await evalAsync(input, `(function(){ ${boundContextGuard(bound)} ${providerGuard}
1460
+ var c = Engine.context.PerpsController;
1461
+ return Promise.all(${sourceLiteral(orders)}.map(function(o){
1462
+ return c.cancelOrder(o).then(function(r){ return { orderId: o.orderId, success: !r || r.success !== false, result: r }; });
1463
+ })).then(function(r){ return JSON.stringify(r); }); })()`);
1464
+ if (canceled?.contextChanged) throw new Error(`the app left mainnet, ${entry.provider} or account ${entry.address} before the cancel`);
1465
+ if (!Array.isArray(canceled) || canceled.some((item) => !item.success)) throw new Error(`cancelOrder failed: ${JSON.stringify(canceled)}`);
1466
+ const deadline = Date.now() + 30_000;
1467
+ for (;;) {
1468
+ const after = await readBook();
1469
+ const open = after.orders.filter((order) => orders.some((target) => String(orderIdForItem(order)) === target.orderId));
1470
+ if (open.length === 0) return { canceled, read: bookRead(after, { absent: true }) };
1471
+ if (Date.now() >= deadline) throw new Error(`order(s) ${open.map(orderIdForItem).join(', ')} still open 30 s after the cancel: ${JSON.stringify(canceled)}`);
1472
+ await sleep(1000);
1473
+ }
1474
+ }
1475
+
1476
+ // On mainnet a cancel may only take orders this checkout's runs placed (the
1477
+ // mainnet order ledger); anything else is refused before dispatch. Returns the
1478
+ // ledger entries on mainnet, null on testnet.
1479
+ async function mainnetOrdersOwned(input, orders) {
1480
+ const runtime = await readPerpsRuntimeState(input);
1481
+ if (runtime?.isTestnet !== false) return null;
1482
+ const owned = await Promise.all(orders.map((order) => ownedMainnetOrder(input.context?.projectRoot, order.orderId)));
1483
+ const foreign = orders.filter((_order, index) => !owned[index]);
1484
+ if (foreign.length > 0) {
1485
+ throw Object.assign(
1486
+ new Error(
1487
+ `PERPS_MAINNET_ORDER_REFUSED: ${input.action} on mainnet cancels only orders this run placed; ` +
1488
+ `${foreign.map((order) => `${order.symbol} ${order.orderId}`).join(', ')} ${foreign.length === 1 ? 'is' : 'are'} not. Refused before dispatch.`,
1489
+ ),
1490
+ { code: 'PERPS_MAINNET_ORDER_REFUSED' },
1491
+ );
1492
+ }
1493
+ return owned;
1494
+ }
1495
+
1496
+ // ensure: an ensure_* action opening state; it never leaves a mainnet limit order.
1497
+ export async function placeOrder(input, { ensure = false } = {}) {
1392
1498
  requirePlaceOrderInputs(input);
1393
1499
  const symbol = marketSymbol(input);
1394
1500
  const side = String(input.node.side).toLowerCase();
@@ -1408,17 +1514,27 @@ export async function placeOrder(input) {
1408
1514
  leverage,
1409
1515
  orderType,
1410
1516
  closeImmediately,
1517
+ runCancelsLimit: !ensure,
1411
1518
  projectRoot: input.context?.projectRoot,
1412
1519
  selectedAddress: bound.address,
1413
1520
  });
1521
+ const mainnetLimit = network === 'mainnet' && orderType === 'limit';
1522
+ // A mainnet limit is also bound to the provider it is recorded under.
1523
+ if (mainnetLimit) bound.provider = String(runtime?.activeProvider ?? '').toLowerCase();
1414
1524
  if ((closeImmediately || network === 'mainnet') && input.node?.size != null) {
1415
1525
  throw new Error('close_immediately and mainnet orders are sized from amount; drop size.');
1416
1526
  }
1527
+ if (network === 'mainnet' && hasNodeValue(input, 'current_price', 'currentPrice')) {
1528
+ throw new Error('Mainnet orders are bounded at the live market price; drop current_price.');
1529
+ }
1417
1530
  if (closeImmediately) {
1418
1531
  const book = await readBoundBook(input, bound);
1419
1532
  assertFlatBeforeRoundTrip(book.positions, symbol, symbolForItem, book.orders);
1420
1533
  }
1421
1534
  const currentPrice = await currentPriceForOrder(input, symbol);
1535
+ const limitPrice = orderType === 'limit'
1536
+ ? resolveLimitPrice(input, isBuy, currentPrice)
1537
+ : null;
1422
1538
  const houseNotional = requireHouseNotional({
1423
1539
  network,
1424
1540
  symbol,
@@ -1427,10 +1543,8 @@ export async function placeOrder(input) {
1427
1543
  price: currentPrice,
1428
1544
  szDecimals: network === 'mainnet' ? await readSizeDecimals(input, symbol) : null,
1429
1545
  maxSlippageBps,
1546
+ limit: orderType === 'limit' ? { price: limitPrice, isBuy } : null,
1430
1547
  });
1431
- const limitPrice = orderType === 'limit'
1432
- ? resolveLimitPrice(input, isBuy, currentPrice)
1433
- : null;
1434
1548
  const size = String(
1435
1549
  input.node?.size
1436
1550
  ?? ((Number(amount) * leverage) / (limitPrice ?? currentPrice)),
@@ -1442,7 +1556,8 @@ export async function placeOrder(input) {
1442
1556
  orderType,
1443
1557
  size,
1444
1558
  price: String(limitPrice),
1445
- timeInForce: 'GTC',
1559
+ // Post-only on mainnet: the venue rejects a limit that would fill now.
1560
+ timeInForce: mainnetLimit ? 'ALO' : 'GTC',
1446
1561
  leverage,
1447
1562
  currentPrice,
1448
1563
  priceAtCalculation: currentPrice,
@@ -1462,6 +1577,21 @@ export async function placeOrder(input) {
1462
1577
  if (await tradingContextChanged(input, bound)) {
1463
1578
  throw new Error('Perps order not dispatched: the selected account or network changed after the order rule allowed it.');
1464
1579
  }
1580
+ // Tracked before dispatch, so the run cancels it even if this action dies.
1581
+ const baseline = mainnetLimit ? await readBoundBook(input, bound) : null;
1582
+ const ledgerId = mainnetLimit
1583
+ ? await recordPendingMainnetOrder(input.context.projectRoot, {
1584
+ platform: 'mobile',
1585
+ provider: bound.provider,
1586
+ symbol,
1587
+ side: isBuy ? 'long' : 'short',
1588
+ limitPrice,
1589
+ address: bound.address,
1590
+ baselineOrderIds: baseline.orders.map(orderIdForItem).filter(Boolean),
1591
+ artifactsDir: input.context.artifactsDir,
1592
+ connection: input.context.env ?? {},
1593
+ })
1594
+ : null;
1465
1595
  let result;
1466
1596
  try {
1467
1597
  result = await evalAsync(input, `(function(){ ${boundContextGuard(bound)}
@@ -1473,28 +1603,31 @@ export async function placeOrder(input) {
1473
1603
  );
1474
1604
  }
1475
1605
  if (result?.success === false || result == null) {
1606
+ const restNote = ledgerId ? "; the run's teardown cancels it if it rests" : '';
1476
1607
  // Mobile keeps no placement receipt, so a 429 is never retried: nothing proves the venue saw no order.
1477
1608
  if (isRateLimited(result?.error)) {
1478
1609
  throw rateLimitedError({
1479
1610
  call: 'PerpsController.placeOrder',
1480
1611
  attempts: 1,
1481
1612
  waitedMs: 0,
1482
- reason: `no retry, since nothing proves the ${symbol} order was not placed`,
1613
+ reason: `no retry, since nothing proves the ${symbol} order was not placed${restNote}`,
1483
1614
  next: `read the ${symbol} positions and orders, then wait a minute and rerun only if nothing was placed`,
1484
1615
  });
1485
1616
  }
1486
- throw new Error(`Failed to place ${symbol} ${side}: ${result?.error || JSON.stringify(result)}`);
1617
+ throw new Error(`Failed to place ${symbol} ${side}: ${result?.error || JSON.stringify(result)}${restNote}`);
1487
1618
  }
1488
1619
  if (orderType === 'limit') {
1489
1620
  await waitForSelectedState(input, readOpenOrders, true, Number(input.node?.timeout_ms ?? 30000));
1621
+ if (ledgerId) await recordPlacedMainnetOrder(input.context.projectRoot, ledgerId, await placedMainnetOrderId(input, bound, ledgerId, result));
1490
1622
  } else {
1491
1623
  await waitForPositionPresent(input, symbol, Number(input.node?.timeout_ms ?? 30000));
1492
1624
  }
1493
1625
  } catch (error) {
1626
+ if (ledgerId && error?.knownNotDispatched) await dropMainnetOrder(input.context.projectRoot, ledgerId);
1494
1627
  if (!closeImmediately || error?.knownNotDispatched) throw error;
1495
1628
  throw await closeAfterFailedPlacement(input, symbol, error, bound);
1496
1629
  }
1497
- 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' };
1630
+ const placed = { action: input.action, market: symbol, side, orderType, amount, size, leverage, limitPrice, submitted: true, result, network, policy: policy.lane, ...(policy.autoCancel ? { autoCancel: policy.autoCancel } : {}), ...(houseNotional ? { houseNotional } : {}), proofPath: 'mobile-perps-controller-place-order' };
1498
1631
  if (!closeImmediately) return placed;
1499
1632
  return { ...placed, roundTrip: await closeOpenedPosition(input, symbol, bound) };
1500
1633
  }
@@ -1524,6 +1657,18 @@ async function closeAfterFailedPlacement(input, symbol, error, bound) {
1524
1657
  return new Error(`${message} close_immediately: ${outcome}.`);
1525
1658
  }
1526
1659
 
1660
+ // The venue ID of the mainnet limit order just placed: the controller's answer,
1661
+ // else the one new order in its market and side since the pending entry.
1662
+ async function placedMainnetOrderId(input, bound, ledgerId, result) {
1663
+ if (result?.orderId != null && String(result.orderId) !== '') return String(result.orderId);
1664
+ const entry = (await readMainnetOrderLedger(input.context.projectRoot)).entries.find((candidate) => candidate.id === ledgerId);
1665
+ const targets = ledgerOrderTargets(entry, (await readBoundBook(input, bound)).orders, LEDGER_ORDER_FIELDS);
1666
+ if (targets.length !== 1) {
1667
+ throw new Error(`Mainnet ${entry.symbol} limit order placed but its ID is unknown (${targets.length} new orders match); the run's teardown cancels it.`);
1668
+ }
1669
+ return String(orderIdForItem(targets[0]));
1670
+ }
1671
+
1527
1672
  async function readSizeDecimals(input, symbol) {
1528
1673
  const markets = await evalAsync(
1529
1674
  input,
@@ -1555,7 +1700,9 @@ function boundContextGuard(bound) {
1555
1700
  return `var __account = Engine.context.AccountsController.getSelectedAccount();
1556
1701
  var __address = String((__account && __account.address) || '').toLowerCase();
1557
1702
  var __network = Engine.context.PerpsController.state.isTestnet ? 'testnet' : 'mainnet';
1558
- if (__address !== ${sourceLiteral(bound.address)} || __network !== ${sourceLiteral(bound.network)}) {
1703
+ var __provider = String(Engine.context.PerpsController.state.activeProvider || '').toLowerCase();
1704
+ if (__address !== ${sourceLiteral(bound.address)} || __network !== ${sourceLiteral(bound.network)}
1705
+ || (${sourceLiteral(bound.provider ?? null)} !== null && __provider !== ${sourceLiteral(bound.provider ?? null)})) {
1559
1706
  return Promise.resolve(JSON.stringify({ success: false, contextChanged: true }));
1560
1707
  }`;
1561
1708
  }
@@ -1654,7 +1801,7 @@ export async function ensurePositions(input) {
1654
1801
  }
1655
1802
  if (state === 'open' || state === 'present') {
1656
1803
  const existing = await readPositions(input);
1657
- if (selectedItems(input, existing).length === 0) await placeOrder(input);
1804
+ if (selectedItems(input, existing).length === 0) await placeOrder(input, { ensure: true });
1658
1805
  return assertPositions(input, true);
1659
1806
  }
1660
1807
  throw new Error(`metamask.perps.ensure_positions received unsupported state: ${state}`);
@@ -1672,7 +1819,7 @@ export async function ensureOrders(input) {
1672
1819
  const current = await readOpenOrders(input);
1673
1820
  let order = null;
1674
1821
  if (selectedItems(input, current).length === 0) {
1675
- order = await placeOrder({ ...input, node: { ...input.node, order_type: 'limit' } });
1822
+ order = await placeOrder({ ...input, node: { ...input.node, order_type: 'limit' } }, { ensure: true });
1676
1823
  }
1677
1824
  return { ...(await assertOrders(input, true)), order };
1678
1825
  }
@@ -1921,6 +2068,7 @@ async function ensureNetwork(input, config) {
1921
2068
  if (Boolean(before.isTestnet) === expectedTestnet) {
1922
2069
  return { requested: config.network, isTestnet: before.isTestnet, changed: false };
1923
2070
  }
2071
+ if (expectedTestnet) await requireNoOpenMainnetOrders(input.context?.projectRoot, input.action);
1924
2072
  const result = await evalAsync(input, 'Engine.context.PerpsController.toggleTestnet().then(function(r){return JSON.stringify(r)})');
1925
2073
  const after = await waitForNetworkState(input, expectedTestnet, Number(input.node?.timeout_ms ?? 30000));
1926
2074
  if (Boolean(after.isTestnet) !== expectedTestnet) {
@@ -1,10 +1,13 @@
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.
1
+ // Submit a Perps order through the Mobile controller and assert the resulting
2
+ // order (limit) or position (market). With close_immediately the order's
3
+ // position is already closed again, so the market must be flat.
3
4
  import { runAdapter } from '../platform/bridge.mjs';
4
5
  import { closeImmediatelyRequested } from '../../shared/perps/order-policy.mjs';
5
- import { assertPositions, placeOrder } from './perps.mjs';
6
+ import { assertOrders, assertPositions, placeOrder } from './perps.mjs';
6
7
  runAdapter(async (input) => {
7
8
  const order = await placeOrder(input);
8
- const assertion = await assertPositions(input, !closeImmediatelyRequested(input.node));
9
+ const assertion = order.orderType === 'limit'
10
+ ? await assertOrders(input, true)
11
+ : await assertPositions(input, !closeImmediatelyRequested(input.node));
9
12
  return { ...assertion, submitted: order.submitted === true, order };
10
13
  });