@deeeed/metamask-harness 0.86.2 → 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 (61) hide show
  1. package/CHANGELOG.md +49 -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/action-inputs.js +20 -0
  12. package/dist/adapters/mobile/actions.js +18 -1
  13. package/dist/adapters/mobile/android-screenrecord.js +274 -0
  14. package/dist/adapters/mobile/readiness.js +2 -1
  15. package/dist/adapters/mobile/recording-outcome.js +54 -0
  16. package/dist/adapters/mobile/recording-target.js +11 -0
  17. package/dist/adapters/mobile/run.js +58 -15
  18. package/dist/adapters/mobile/ui.js +96 -2
  19. package/dist/adapters/perps-mainnet-cleanup.js +122 -0
  20. package/dist/adapters/perps-order-inputs.js +43 -28
  21. package/dist/adapters.js +4 -1
  22. package/dist/commands/doctor.js +33 -14
  23. package/dist/commands/status-probe.js +2 -1
  24. package/dist/engine.js +11 -2
  25. package/dist/funding-execution-context.js +13 -8
  26. package/dist/host.js +6 -2
  27. package/dist/index.js +3 -0
  28. package/dist/mm-harness-cli.js +4 -2
  29. package/dist/provider.js +72 -0
  30. package/dist/recipe-security.js +1 -0
  31. package/docs/CONTRIBUTING.md +7 -0
  32. package/docs/RECIPES.md +96 -9
  33. package/docs/RELEASE-QA-CAPABILITY-MAP.md +5 -1
  34. package/docs/cli-contract.md +1 -1
  35. package/library/actions/core/perps/read_account.mjs +4 -2
  36. package/library/actions/core/perps/read_orders.mjs +4 -2
  37. package/library/actions/core/perps/read_positions.mjs +4 -2
  38. package/library/actions/extension/analytics/consent.mjs +7 -2
  39. package/library/actions/extension/perps/compare_provider_market.mjs +12 -8
  40. package/library/actions/extension/perps/mutation-receipt.mjs +124 -26
  41. package/library/actions/extension/perps/perps.mjs +298 -39
  42. package/library/actions/extension/perps/read_snapshot.mjs +6 -2
  43. package/library/actions/extension/perps/set_network.mjs +6 -0
  44. package/library/actions/extension/platform/cdp.mjs +6 -2
  45. package/library/actions/extension/ui/navigate.mjs +8 -0
  46. package/library/actions/mobile/perps/perps.mjs +264 -102
  47. package/library/actions/mobile/perps/place_order.mjs +7 -4
  48. package/library/actions/mobile/platform/bridge.mjs +171 -10
  49. package/library/actions/mobile/wallet/home.mjs +7 -2
  50. package/library/actions/mobile/wallet/select_account.mjs +4 -0
  51. package/library/actions/shared/app/live-adapter-deadline.mjs +8 -0
  52. package/library/actions/shared/perps/mainnet-order-ledger.mjs +378 -0
  53. package/library/actions/shared/perps/order-policy.mjs +56 -19
  54. package/library/actions/shared/perps/rate-limit.mjs +93 -0
  55. package/library/actions/shared/perps/visible-state.mjs +3 -2
  56. package/library/actions/shared/wallet/fixture-accounts.mjs +9 -3
  57. package/library/manifests/extension.action-manifest.json +36 -5
  58. package/library/manifests/mobile.action-manifest.json +48 -10
  59. package/package.json +14 -9
  60. package/scripts/validate-release-report.mjs +14 -2
  61. package/site/assets/help-recipes.json +2 -1
@@ -31,6 +31,20 @@ import {
31
31
  marketOrderLot,
32
32
  requireHouseNotional,
33
33
  } from '../../shared/perps/order-policy.mjs';
34
+ import { liveAdapterDeadline } from '../../shared/app/live-adapter-deadline.mjs';
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';
34
48
  import {
35
49
  orderIdentityMatches,
36
50
  parseOrderCard,
@@ -238,8 +252,13 @@ export async function closePositions(input) {
238
252
  }
239
253
 
240
254
  async function closeOrderThroughBackground(input, page, timeoutMs) {
241
- ({ 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, {
242
259
  operations: [{ operation: 'perps-cancel-order', amount: String(input.node?.amount ?? '') }],
260
+ mainnetAllowed: Boolean(owned),
261
+ mainnetRefusal: 'cancels only orders this run placed',
243
262
  }));
244
263
  await requirePerpsTradingContext(input, page);
245
264
  const symbol = marketSymbol(input);
@@ -333,6 +352,10 @@ async function closeOrderThroughBackground(input, page, timeoutMs) {
333
352
  throw new Error('Exact background order did not become absent after cancellation.');
334
353
  },
335
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
+ }
336
359
  return {
337
360
  action: input.action,
338
361
  canceled: true,
@@ -345,23 +368,36 @@ async function closeOrderThroughBackground(input, page, timeoutMs) {
345
368
  };
346
369
  }
347
370
 
348
- async function readAcceptedFilledOrder(input, page, placement, requireTerminal = false) {
349
- const accepted = placement.record?.acceptance;
350
- if (!accepted?.venueOrderId) return null;
371
+ function hyperliquidOrigin(network) {
372
+ return network === 'testnet' ? 'https://api.hyperliquid-testnet.xyz' : 'https://api.hyperliquid.xyz';
373
+ }
374
+
375
+ function postHyperliquidInfo(network, body, signal = AbortSignal.timeout(10_000)) {
376
+ return fetch(`${hyperliquidOrigin(network)}/info`, {
377
+ method: 'POST', headers: { 'content-type': 'application/json' },
378
+ body: JSON.stringify(body), signal,
379
+ });
380
+ }
381
+
382
+ // The Hyperliquid network and user a receipt's order went to, read from the live
383
+ // app and bound to the receipt's selected-account hash.
384
+ async function receiptVenueUser(input, page, identity, purpose) {
351
385
  const { provider, network } = await requirePerpsTradingContext(input, page);
352
- if (provider !== 'hyperliquid') throw new Error('Accepted-order status requires the original Hyperliquid provider.\nNext: mm-harness last --json');
386
+ if (provider !== 'hyperliquid') throw new Error(`${purpose} requires the original Hyperliquid provider.\nNext: mm-harness last --json`);
353
387
  const wallet = await page.evaluate(readWalletStateExpression());
354
388
  const user = String(wallet.selectedAccount?.address ?? '').toLowerCase();
355
389
  if (!/^0x[a-f0-9]{40}$/u.test(user)
356
- || createHash('sha256').update(user).digest('hex') !== placement.record.identity.selectedIdentitySha256) {
357
- throw new Error('Accepted-order status wallet differs from the placement receipt.\nNext: mm-harness last --json');
390
+ || createHash('sha256').update(user).digest('hex') !== identity.selectedIdentitySha256) {
391
+ throw new Error(`${purpose} wallet differs from the placement receipt.\nNext: mm-harness last --json`);
358
392
  }
359
- const origin = network === 'testnet' ? 'https://api.hyperliquid-testnet.xyz' : 'https://api.hyperliquid.xyz';
360
- const response = await fetch(`${origin}/info`, {
361
- method: 'POST', headers: { 'content-type': 'application/json' },
362
- body: JSON.stringify({ type: 'orderStatus', user, oid: Number(accepted.venueOrderId) }),
363
- signal: AbortSignal.timeout(10_000),
364
- });
393
+ return { network, user };
394
+ }
395
+
396
+ async function readAcceptedFilledOrder(input, page, placement, requireTerminal = false) {
397
+ const accepted = placement.record?.acceptance;
398
+ if (!accepted?.venueOrderId) return null;
399
+ const { network, user } = await receiptVenueUser(input, page, placement.record.identity, 'Accepted-order status');
400
+ const response = await postHyperliquidInfo(network, { type: 'orderStatus', user, oid: Number(accepted.venueOrderId) });
365
401
  if (!response.ok) throw new Error('Accepted-order status request failed.\nNext: mm-harness last --json');
366
402
  const result = await response.json();
367
403
  if (result.status === 'unknownOid') throw new Error('Accepted venue order status is unavailable.\nNext: mm-harness last --json');
@@ -671,7 +707,7 @@ async function readCurrentMarketPrice(page, symbol, explicitPrice) {
671
707
  if (Number.isFinite(parsed) && parsed > 0) return parsed;
672
708
  throw new Error(`Invalid explicit current price for ${symbol}: ${String(explicitPrice)}`);
673
709
  }
674
- const price = await page.evaluate(String.raw`(async () => {
710
+ const price = await withRateLimitRetry('perpsGetMarketDataWithPrices', () => page.evaluate(String.raw`(async () => {
675
711
  const request = globalThis.stateHooks?.submitRequestToBackground;
676
712
  const manager = globalThis.stateHooks?.getPerpsStreamManager?.();
677
713
  const symbol = ${JSON.stringify(symbol)};
@@ -709,7 +745,7 @@ async function readCurrentMarketPrice(page, symbol, explicitPrice) {
709
745
  const line = body.split('\n').find((text) => /\$[0-9][0-9,]*(?:\.[0-9]+)?/.test(text));
710
746
  const fromBody = parsePrice(line);
711
747
  return fromBody ? { price: fromBody, source: 'document-body-price-line' } : null;
712
- })()`);
748
+ })()`), { deadline: page.actionDeadline });
713
749
  const numericPrice = Number(price?.price ?? price);
714
750
  if (!Number.isFinite(numericPrice) || numericPrice <= 0) throw new Error(`Unable to determine current Perps price for ${symbol}.`);
715
751
  return numericPrice;
@@ -871,6 +907,7 @@ async function dispatchBackgroundPlace(page, {
871
907
  orderType,
872
908
  resolvedPrice,
873
909
  symbol,
910
+ timeInForce = 'GTC',
874
911
  }) {
875
912
  return page.evaluate(`(async () => {
876
913
  const request = globalThis.stateHooks?.submitRequestToBackground;
@@ -887,7 +924,7 @@ async function dispatchBackgroundPlace(page, {
887
924
  size: ((Number(usdAmount) * Number(leverage)) / Number(limitPrice)).toString(),
888
925
  orderType,
889
926
  price: String(limitPrice),
890
- timeInForce: 'GTC',
927
+ timeInForce: ${JSON.stringify(timeInForce)},
891
928
  leverage,
892
929
  currentPrice,
893
930
  priceAtCalculation: currentPrice,
@@ -915,7 +952,7 @@ async function dispatchBackgroundPlace(page, {
915
952
  }
916
953
 
917
954
  async function readOpenOrdersState(page) {
918
- const state = await page.evaluate(`(async () => {
955
+ const state = await withRateLimitRetry('perpsGetOpenOrders', () => page.evaluate(`(async () => {
919
956
  const request = globalThis.stateHooks?.submitRequestToBackground;
920
957
  const manager = globalThis.stateHooks?.getPerpsStreamManager?.();
921
958
  const cached = manager?.orders?.cache;
@@ -933,13 +970,13 @@ async function readOpenOrdersState(page) {
933
970
  connected: Boolean(manager?.orders?.isConnected),
934
971
  orders: Array.isArray(cached) ? cached : [],
935
972
  };
936
- })()`);
973
+ })()`), { deadline: page.actionDeadline });
937
974
  if (!state.available) throw new Error('Perps orders are unavailable from both background and stream manager cache.');
938
975
  return state;
939
976
  }
940
977
 
941
978
  async function readAccountState(page) {
942
- const state = await page.evaluate(`(async () => {
979
+ const state = await withRateLimitRetry('perpsGetAccountState', () => page.evaluate(`(async () => {
943
980
  const request = globalThis.stateHooks?.submitRequestToBackground;
944
981
  const manager = globalThis.stateHooks?.getPerpsStreamManager?.();
945
982
  const cached = manager?.account?.cache;
@@ -957,7 +994,7 @@ async function readAccountState(page) {
957
994
  connected: Boolean(manager?.account?.isConnected),
958
995
  account: cached && typeof cached === 'object' ? cached : null,
959
996
  };
960
- })()`);
997
+ })()`), { deadline: page.actionDeadline });
961
998
  if (!state.available) throw new Error('Perps account state is unavailable from both background and stream manager cache.');
962
999
  return state.account;
963
1000
  }
@@ -1242,9 +1279,56 @@ async function observeExactVisibleOrderResult(page, expected, receipt, timeoutMs
1242
1279
  throw new Error(`matching ${expected.orderType} ${identity} identity was not stably visible before timeout; last observed: ${JSON.stringify(lastObserved)}`);
1243
1280
  }
1244
1281
 
1282
+ // Fills and open orders up to this long before the first dispatch count as this
1283
+ // order's, so a local clock behind the venue's cannot hide one.
1284
+ const VENUE_CLOCK_SKEW_MS = 30_000;
1285
+ // All three venue reads together; a retry starts only if they fit before its deadline.
1286
+ const VENUE_VERIFY_TIMEOUT_MS = 5_000;
1287
+
1288
+ // After a 429 on a background placement: Hyperliquid's own account state, not the
1289
+ // extension's cache, shows no open order and no fill in the market since the first
1290
+ // dispatch, and for a market order no position on its side (the pre-order baseline
1291
+ // had none). Open orders are read before fills, so an order of ours that fills
1292
+ // between the two reads still shows in one of them. Anything else, or a failed
1293
+ // read, leaves the outcome unproven.
1294
+ async function verifyNothingPlacedOnVenue(input, page, identity, sinceMs) {
1295
+ const coin = identity.market;
1296
+ if (coin.includes(':')) {
1297
+ return { proven: false, reason: `the venue read covers only the main Hyperliquid dex, not ${coin}` };
1298
+ }
1299
+ const { network, user } = await receiptVenueUser(input, page, identity, 'Rate-limit verification');
1300
+ const startTime = sinceMs - VENUE_CLOCK_SKEW_MS;
1301
+ const signal = AbortSignal.timeout(VENUE_VERIFY_TIMEOUT_MS);
1302
+ const read = async (body) => {
1303
+ const response = await postHyperliquidInfo(network, body, signal);
1304
+ if (!response.ok) throw new Error(`Hyperliquid ${body.type} answered HTTP ${response.status}`);
1305
+ return response.json();
1306
+ };
1307
+ const orders = await read({ type: 'openOrders', user });
1308
+ const fills = await read({ type: 'userFillsByTime', user, startTime });
1309
+ const account = await read({ type: 'clearinghouseState', user });
1310
+ if (!Array.isArray(fills) || !Array.isArray(orders) || !Array.isArray(account?.assetPositions)) {
1311
+ return { proven: false, reason: 'the Hyperliquid account reads returned an unexpected shape' };
1312
+ }
1313
+ const size = Number(account.assetPositions.find((entry) => entry?.position?.coin === coin)?.position?.szi ?? 0);
1314
+ if (!Number.isFinite(size)) return { proven: false, reason: `the Hyperliquid ${coin} position size is unreadable` };
1315
+ const facts = {
1316
+ source: 'hyperliquid-info',
1317
+ startTime,
1318
+ openOrders: orders.filter((order) => order?.coin === coin && !(Number(order?.timestamp) < startTime)).length,
1319
+ fills: fills.filter((fill) => fill?.coin === coin && !(Number(fill?.time) < startTime)).length,
1320
+ sameSidePosition: identity.orderType === 'market' && (identity.side === 'long' ? size > 0 : size < 0),
1321
+ };
1322
+ if (facts.openOrders > 0 || facts.fills > 0 || facts.sameSidePosition) {
1323
+ return { proven: false, reason: `Hyperliquid shows ${coin} activity since the first dispatch`, facts };
1324
+ }
1325
+ return { proven: true, facts };
1326
+ }
1327
+
1245
1328
  async function placeOrderThroughBackground(input, page, {
1246
1329
  amount,
1247
1330
  bound = null,
1331
+ mainnetLimit = false,
1248
1332
  onDispatch = () => {},
1249
1333
  expected,
1250
1334
  identity,
@@ -1293,9 +1377,30 @@ async function placeOrderThroughBackground(input, page, {
1293
1377
  // A resume checks the fill against the lot sized at publication, not one
1294
1378
  // recomputed at a price that has since moved.
1295
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;
1296
1394
  const mutation = await runGuardedExternalMutation({
1297
1395
  input,
1298
1396
  identity,
1397
+ rateLimit: {
1398
+ call: 'perpsPlaceOrder',
1399
+ // A retry leaves the postcondition its timeout_ms and 5 s before the runner's process limit.
1400
+ deadline: liveAdapterDeadline(input.node, Number(input.node?.timeout_ms ?? 30_000) + 5_000),
1401
+ verifyBudgetMs: VENUE_VERIFY_TIMEOUT_MS,
1402
+ verifyNotPlaced: ({ sinceMs }) => verifyNothingPlacedOnVenue(input, page, identity, sinceMs),
1403
+ },
1299
1404
  // The pre-order balance lets a receipt-bound close reconcile its final balance.
1300
1405
  publicationFacts: {
1301
1406
  balanceBeforeBaseUnits: fundingBalance.observedBaseUnits,
@@ -1303,19 +1408,27 @@ async function placeOrderThroughBackground(input, page, {
1303
1408
  fundingBalance,
1304
1409
  ...(expected.marketLot ? { marketLot: expected.marketLot } : {}),
1305
1410
  },
1306
- dispatch: async () => {
1411
+ dispatch: async ({ deadline = null } = {}) => {
1307
1412
  try {
1413
+ const timeoutMs = Number(input.node?.timeout_ms ?? 30_000);
1308
1414
  await requireCurrentVisibleFundingBalance(
1309
1415
  input,
1310
1416
  page,
1311
- Number(input.node?.timeout_ms ?? 30_000),
1417
+ deadline == null ? timeoutMs : Math.max(0, Math.min(timeoutMs, deadline - Date.now())),
1312
1418
  fundingBalance.observedBaseUnits,
1313
1419
  );
1314
1420
  await requirePerpsTradingContext(input, page);
1315
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);
1425
+ if (deadline != null && Date.now() >= deadline) {
1426
+ throw new Error('it reached the action deadline before dispatch');
1427
+ }
1316
1428
  } catch (error) {
1317
1429
  throw externalMutationNotDispatched(error instanceof Error ? error.message : String(error));
1318
1430
  }
1431
+ if (ledgerFields && !ledgerId) ledgerId = await recordPendingMainnetOrder(projectRoot, ledgerFields);
1319
1432
  onDispatch();
1320
1433
  const accepted = await dispatchBackgroundPlace(page, {
1321
1434
  amount,
@@ -1327,8 +1440,11 @@ async function placeOrderThroughBackground(input, page, {
1327
1440
  orderType,
1328
1441
  resolvedPrice,
1329
1442
  symbol,
1443
+ // Post-only on mainnet: the venue rejects a limit that would fill now.
1444
+ timeInForce: mainnetLimit ? 'ALO' : 'GTC',
1330
1445
  });
1331
1446
  if (accepted?.changed) {
1447
+ if (ledgerId) await dropMainnetOrder(projectRoot, ledgerId);
1332
1448
  throw externalMutationNotDispatched('the selected account or Perps network changed after the order rule allowed it');
1333
1449
  }
1334
1450
  // The venue's filled order ID goes into the receipt, so cleanup can prove the
@@ -1353,6 +1469,14 @@ async function placeOrderThroughBackground(input, page, {
1353
1469
  receipt?.acceptance,
1354
1470
  ),
1355
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
+ }
1356
1480
  return {
1357
1481
  action: input.action,
1358
1482
  market: symbol,
@@ -1567,12 +1691,8 @@ export async function observeHyperliquidSubmission(page, expected) {
1567
1691
  if (expected.provider !== 'hyperliquid' || !['market', 'limit'].includes(expected.orderType)) {
1568
1692
  throw new Error('Exact visible submission observation requires a Hyperliquid market or limit order.');
1569
1693
  }
1570
- const origin = expected.network === 'testnet'
1571
- ? 'https://api.hyperliquid-testnet.xyz' : 'https://api.hyperliquid.xyz';
1572
- const metadataResponse = await fetch(`${origin}/info`, {
1573
- method: 'POST', headers: { 'content-type': 'application/json' },
1574
- body: JSON.stringify({ type: 'meta' }), signal: AbortSignal.timeout(10_000),
1575
- });
1694
+ const origin = hyperliquidOrigin(expected.network);
1695
+ const metadataResponse = await postHyperliquidInfo(expected.network, { type: 'meta' });
1576
1696
  if (!metadataResponse.ok) throw new Error('Hyperliquid market metadata is unavailable before submission.');
1577
1697
  const metadata = await metadataResponse.json();
1578
1698
  const asset = metadata.universe?.findIndex((market) => market.name === expected.market);
@@ -1718,11 +1838,15 @@ export async function placeOrder(input) {
1718
1838
  // The account and network the order rule allowed, rechecked before any close.
1719
1839
  let bound = null;
1720
1840
  const place = () => withExtensionPage(input, async (page) => {
1841
+ const hasBackgroundRequest = await page.evaluate(
1842
+ `typeof globalThis.stateHooks?.submitRequestToBackground === 'function'`,
1843
+ );
1721
1844
  const lane = await withFixturePolicyContext(input, page, {
1722
1845
  operations: [
1723
1846
  { operation: orderType === 'limit' ? 'perps-limit-order' : 'perps-market-order', amount, order: true },
1724
1847
  ],
1725
- 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 },
1726
1850
  });
1727
1851
  input = lane.input;
1728
1852
  if (lane.network) {
@@ -1788,9 +1912,6 @@ export async function placeOrder(input) {
1788
1912
  } : {}),
1789
1913
  ...(fromCurrentForm && orderType === 'limit' ? { maximumNotionalBaseUnits: placementBudget.maxBaseUnits } : {}),
1790
1914
  };
1791
- const hasBackgroundRequest = await page.evaluate(
1792
- `typeof globalThis.stateHooks?.submitRequestToBackground === 'function'`,
1793
- );
1794
1915
  if (closeImmediately) {
1795
1916
  // The close goes through the background request, so the order must too.
1796
1917
  if (!hasBackgroundRequest) throw new Error('close_immediately needs the background Perps request; it is unavailable.');
@@ -1805,6 +1926,9 @@ export async function placeOrder(input) {
1805
1926
  }
1806
1927
  if (hasBackgroundRequest && !fromCurrentForm) {
1807
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
+ }
1808
1932
  const resolvedPrice = await readCurrentMarketPrice(
1809
1933
  page,
1810
1934
  symbol,
@@ -1812,6 +1936,9 @@ export async function placeOrder(input) {
1812
1936
  );
1813
1937
  const szDecimals = lane.network === 'mainnet' || orderType === 'market'
1814
1938
  ? await readSizeDecimals(page, symbol) : null;
1939
+ const backgroundLimitPrice = orderType === 'limit'
1940
+ ? resolveLimitPrice(input, isBuy, resolvedPrice)
1941
+ : null;
1815
1942
  houseNotional = requireHouseNotional({
1816
1943
  network: lane.network,
1817
1944
  symbol,
@@ -1820,14 +1947,13 @@ export async function placeOrder(input) {
1820
1947
  price: resolvedPrice,
1821
1948
  szDecimals,
1822
1949
  maxSlippageBps: Number(input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300),
1950
+ limit: orderType === 'limit' ? { price: backgroundLimitPrice, isBuy } : null,
1823
1951
  });
1824
1952
  const marketLot = orderType === 'market' ? marketOrderLot({ amount, leverage, price: resolvedPrice, szDecimals }) : null;
1825
- const backgroundLimitPrice = orderType === 'limit'
1826
- ? resolveLimitPrice(input, isBuy, resolvedPrice)
1827
- : null;
1828
1953
  return placeOrderThroughBackground(input, page, {
1829
1954
  amount,
1830
1955
  bound,
1956
+ mainnetLimit: lane.network === 'mainnet' && orderType === 'limit',
1831
1957
  onDispatch: () => {
1832
1958
  dispatchMayHaveHappened = true;
1833
1959
  },
@@ -2178,7 +2304,7 @@ export async function placeOrder(input) {
2178
2304
  // read in one evaluation with the context guard before, between and after the
2179
2305
  // reads, so a switch to another account's book and back can't pass as flat.
2180
2306
  async function readBoundBook(page, bound) {
2181
- const book = await page.evaluate(`(async () => {
2307
+ const book = await withRateLimitRetry('perpsGetPositions/perpsGetOpenOrders', () => page.evaluate(`(async () => {
2182
2308
  const request = globalThis.stateHooks?.submitRequestToBackground;
2183
2309
  if (typeof request !== 'function') return { unavailable: true };
2184
2310
  const guard = async () => { ${boundContextGuard(bound)} return null; };
@@ -2188,7 +2314,7 @@ async function readBoundBook(page, bound) {
2188
2314
  const orders = await request('perpsGetOpenOrders', []);
2189
2315
  if (await guard()) return { changed: true };
2190
2316
  return { positions, orders };
2191
- })()`);
2317
+ })()`), { deadline: page.actionDeadline });
2192
2318
  if (book?.changed) {
2193
2319
  throw Object.assign(
2194
2320
  new Error('Perps order not dispatched: the selected account or network changed while the market was checked flat.'),
@@ -2202,9 +2328,9 @@ async function readBoundBook(page, bound) {
2202
2328
  }
2203
2329
 
2204
2330
  async function readSizeDecimals(page, symbol) {
2205
- const markets = await page.evaluate(
2331
+ const markets = await withRateLimitRetry('perpsGetMarkets', () => page.evaluate(
2206
2332
  `globalThis.stateHooks.submitRequestToBackground('perpsGetMarkets', [{ symbols: [${JSON.stringify(symbol)}] }])`,
2207
- );
2333
+ ), { deadline: page.actionDeadline });
2208
2334
  const market = (Array.isArray(markets) ? markets : []).find((item) => normalizeMarketSymbol(item?.name ?? '') === symbol);
2209
2335
  return market?.szDecimals ?? null;
2210
2336
  }
@@ -2482,6 +2608,139 @@ async function ensureNetwork(input, config) {
2482
2608
  return withExtensionPage(input, (page) => assertNetwork(page, config.network));
2483
2609
  }
2484
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
+
2485
2744
  async function assertReadyToTrade(input, config) {
2486
2745
  if (config.readyToTrade === undefined || config.readyToTrade === false) return { skipped: true };
2487
2746
  return withExtensionPage(input, async (page) => {
@@ -3,6 +3,7 @@
3
3
  import { pathToFileURL } from 'node:url';
4
4
 
5
5
  import { normalizeMarketSymbol, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
6
+ import { withRateLimitRetry } from '../../shared/perps/rate-limit.mjs';
6
7
 
7
8
  const ALL_SECTIONS = ['account', 'markets', 'positions', 'orders', 'history', 'watchlist'];
8
9
  const DEFAULT_HISTORY_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
@@ -533,8 +534,11 @@ export async function readSnapshot(input, testOptions = {}) {
533
534
  const pause = testOptions.pause ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
534
535
  const samples = [];
535
536
  for (let index = 0; index < options.sampling.count; index += 1) {
536
- const raw = await page.evaluate(snapshotRequestExpression(options));
537
- samples.push(normalizeSnapshotResult(raw, options));
537
+ // A section a 429 left unavailable fails normalization; the sample reruns.
538
+ samples.push(await withRateLimitRetry('Perps snapshot', async () => normalizeSnapshotResult(
539
+ await page.evaluate(snapshotRequestExpression(options)),
540
+ options,
541
+ ), { sleep: pause, deadline: page.actionDeadline }));
538
542
  if (index + 1 < options.sampling.count) await pause(options.sampling.intervalMs);
539
543
  }
540
544
  const sections = samples.at(-1);
@@ -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);
@@ -22,6 +22,8 @@ import path from 'node:path';
22
22
 
23
23
  import { captureActiveRecipeRecordingSnapshot, recipeHarnessPath } from '@farmslot/recipe-cli/harness';
24
24
  import { importRecipeHarnessRuntimeBrowserExtension, importRecipeHarnessRuntimeCdp, extensionIdPath, walletFixturePath } from '../../harness-exports.mjs';
25
+ import { liveAdapterDeadline } from '../../shared/app/live-adapter-deadline.mjs';
26
+ import { withRateLimitRetry } from '../../shared/perps/rate-limit.mjs';
25
27
 
26
28
  // Resolve the Farmslot harness through normal package dependencies by default.
27
29
  // Local Farmslot source is only a dev override handled by the harness paths module.
@@ -843,6 +845,8 @@ export async function withExtensionPage(input, callback) {
843
845
  const origin = `chrome-extension://${extensionId}`;
844
846
  const runtimeIdentity = extensionRuntimeIdentity({ port, target, browserVersion });
845
847
  const page = new ExtensionPage(session, origin, target, port, runtimeIdentity);
848
+ // Rate-limit retries on this page's reads stop before the runner's process limit.
849
+ page.actionDeadline = liveAdapterDeadline(input.node);
846
850
  const recoverVisibleWallet = input.action === 'metamask.wallet.import' && input.node?.method === 'ui';
847
851
  if (recoverVisibleWallet) {
848
852
  try {
@@ -1030,7 +1034,7 @@ export class ExtensionPage extends CdpWebPage {
1030
1034
  }
1031
1035
 
1032
1036
  async readPositions() {
1033
- return this.evaluate(`(async () => {
1037
+ return withRateLimitRetry('perpsGetPositions', () => this.evaluate(`(async () => {
1034
1038
  const request = globalThis.stateHooks?.submitRequestToBackground;
1035
1039
  const manager = globalThis.stateHooks?.getPerpsStreamManager?.();
1036
1040
  const cached = manager?.positions?.cache;
@@ -1050,7 +1054,7 @@ export class ExtensionPage extends CdpWebPage {
1050
1054
  connected: Boolean(manager?.positions?.isConnected),
1051
1055
  positions: Array.isArray(cached) ? cached : [],
1052
1056
  };
1053
- })()`);
1057
+ })()`), { deadline: this.actionDeadline });
1054
1058
  }
1055
1059
 
1056
1060
  async screenshot(contextOrInput, relPath, metadata = {}) {
@@ -280,6 +280,8 @@ export async function openPerpsHome(page, timeoutMs) {
280
280
  await pauseForUi(page);
281
281
  }
282
282
 
283
+ // The sample is capped at 30 and the header and tabs come first in the DOM, so loading
284
+ // markers are listed first; otherwise a Perps view still loading reads as a wrong route.
283
285
  const state = await page.evaluate(`({
284
286
  href: location.href,
285
287
  title: document.title,
@@ -291,8 +293,14 @@ export async function openPerpsHome(page, timeoutMs) {
291
293
  })
292
294
  .map((element) => element.getAttribute('data-testid'))
293
295
  .filter(Boolean)
296
+ .sort((a, b) => Number(b.endsWith('-loading')) - Number(a.endsWith('-loading')))
294
297
  .slice(0, 30),
295
298
  })`);
299
+ if (await hasVisibleSelector(page, perpsLoading)) {
300
+ throw new Error(
301
+ `extension ui.navigate page=perps: Perps home stayed on its loading view (perps-view-loading visible) within the ${timeoutMs}ms budget; observed ${JSON.stringify(state)}.\nNext: check the Perps stream/provider, or raise timeout_ms; a raw hash route does not wait for Perps readiness.`,
302
+ );
303
+ }
296
304
  throw new Error(
297
305
  `extension ui.navigate page=perps could not resolve the Perps home through visible UI; observed ${JSON.stringify(state)}. Capture the current UI with mm-harness call ui.screenshot, then use a verified visible control or raw hash/path.`,
298
306
  );