@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
@@ -4,11 +4,13 @@ import { pathToFileURL } from 'node:url';
4
4
  import {
5
5
  MOBILE_BRIDGE_RESULT_ERROR_CODES,
6
6
  bridgeCommand,
7
+ createSetupBudget,
7
8
  deadlineFromTimeout,
8
9
  earliestDeadline,
9
10
  evalAsync,
10
11
  inheritedDeadline,
11
- isTargetTransition,
12
+ isUnansweredRead,
13
+ mobileNavigationTimeoutMs,
12
14
  navigate,
13
15
  routeSatisfiesNavigationTarget,
14
16
  runAdapter,
@@ -25,6 +27,17 @@ import {
25
27
  requireHouseNotional,
26
28
  requirePerpsOrderAllowed,
27
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';
40
+ import { isRateLimited, rateLimitedError, withRateLimitRetry } from '../../shared/perps/rate-limit.mjs';
28
41
 
29
42
  function sleep(ms) {
30
43
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -169,7 +182,7 @@ async function readModeState(input, mode, deadline) {
169
182
  try {
170
183
  return await queryModeState(input, mode, deadline);
171
184
  } catch (error) {
172
- if (!isTargetTransition(error)) throw error;
185
+ if (!isUnansweredRead(error)) throw error;
173
186
  return null;
174
187
  }
175
188
  }
@@ -237,6 +250,8 @@ export async function ensureMode(input) {
237
250
  throw new Error(`Perps did not reach ${requestedMode} mode before timeout.`);
238
251
  }
239
252
 
253
+ // Retries the client-not-initialized race until the deadline, and a 429 with
254
+ // the shared rate-limit backoff.
240
255
  export async function retryPerpsClientRead(
241
256
  read,
242
257
  {
@@ -244,6 +259,8 @@ export async function retryPerpsClientRead(
244
259
  intervalMs = 500,
245
260
  sleep: wait = sleep,
246
261
  deadlineEpochMs,
262
+ call = 'PerpsController read',
263
+ random,
247
264
  } = {},
248
265
  ) {
249
266
  const localDeadline = Date.now() + Math.max(0, timeoutMs);
@@ -254,7 +271,7 @@ export async function retryPerpsClientRead(
254
271
  : Math.min(localDeadline, configuredDeadline);
255
272
  while (true) {
256
273
  try {
257
- return await read();
274
+ return await withRateLimitRetry(call, read, { deadline, sleep: wait, random });
258
275
  } catch (error) {
259
276
  if (!/CLIENT_NOT_INITIALIZED/u.test(String(error?.message ?? error))) {
260
277
  throw error;
@@ -386,7 +403,7 @@ async function readPositions(input) {
386
403
  }
387
404
  return positions;
388
405
  },
389
- { deadlineEpochMs: inheritedDeadline(input) },
406
+ { deadlineEpochMs: inheritedDeadline(input), call: 'PerpsController.getPositions' },
390
407
  );
391
408
  }
392
409
 
@@ -413,7 +430,7 @@ async function readOpenOrders(input) {
413
430
  }
414
431
  return orders;
415
432
  },
416
- { deadlineEpochMs: inheritedDeadline(input) },
433
+ { deadlineEpochMs: inheritedDeadline(input), call: 'PerpsController.getOpenOrders' },
417
434
  );
418
435
  }
419
436
 
@@ -445,7 +462,7 @@ async function readStrategyOrders(input) {
445
462
  }
446
463
  return orders;
447
464
  },
448
- { deadlineEpochMs: inheritedDeadline(input) },
465
+ { deadlineEpochMs: inheritedDeadline(input), call: 'PerpsController.getTwapOrders/getChaseOrders' },
449
466
  );
450
467
  }
451
468
 
@@ -903,7 +920,7 @@ async function readCurrentRoute(input) {
903
920
  try {
904
921
  return await bridgeCommand(input, ['get-route']);
905
922
  } catch (error) {
906
- if (!isTargetTransition(error)) throw error;
923
+ if (!isUnansweredRead(error)) throw error;
907
924
  return null;
908
925
  }
909
926
  }
@@ -961,8 +978,7 @@ async function waitForPerpsUiTarget(input, testId, deadline) {
961
978
  }
962
979
 
963
980
  async function selectPerpsMarketFromHome(input, symbol, previousRoute) {
964
- const timeoutMs = Number(input.node?.navigation_timeout_ms ?? 15_000);
965
- const deadline = deadlineFromTimeout(input, timeoutMs);
981
+ const deadline = deadlineFromTimeout(input, mobileNavigationTimeoutMs(input));
966
982
  const params = { market: { symbol } };
967
983
  const rowTestId = `perps-market-row-item-${symbol}`;
968
984
  const recentlyViewedTestId = `perps-recently-viewed-tile-${symbol}`;
@@ -1045,8 +1061,7 @@ async function waitForRouteChange(input, previousRoute, deadline) {
1045
1061
  }
1046
1062
 
1047
1063
  async function navigateFromMarketDetailsToHome(input, previousRoute) {
1048
- const timeoutMs = Number(input.node?.navigation_timeout_ms ?? 15_000);
1049
- const deadline = deadlineFromTimeout(input, timeoutMs);
1064
+ const deadline = deadlineFromTimeout(input, mobileNavigationTimeoutMs(input));
1050
1065
  let visibleTestIds = await readVisiblePerpsTestIds(input, deadline);
1051
1066
  let backTestId = PERPS_MARKET_DETAILS_BACK_TEST_IDS.find((testId) =>
1052
1067
  visibleTestIds.has(testId),
@@ -1349,6 +1364,7 @@ export async function closeOrders(input) {
1349
1364
  if (invalid) {
1350
1365
  throw new Error(`Cannot cancel selected Perps order without orderId and symbol: ${JSON.stringify(invalid)}`);
1351
1366
  }
1367
+ const owned = await mainnetOrdersOwned(input, orders);
1352
1368
  const result = await evalAsync(
1353
1369
  input,
1354
1370
  `(function(){
@@ -1380,10 +1396,105 @@ export async function closeOrders(input) {
1380
1396
  const after = await waitForOrdersAbsent(input, symbols, Number(input.node?.timeout_ms ?? 30000));
1381
1397
  const stillOpen = after.filter((order) => symbols.includes(symbolForItem(order)));
1382
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
+ }
1383
1405
  return { action: input.action, canceled: true, matchingCount: matching.length, symbols, result, proofPath: 'mobile-perps-controller-cancel-order' };
1384
1406
  }
1385
1407
 
1386
- 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 } = {}) {
1387
1498
  requirePlaceOrderInputs(input);
1388
1499
  const symbol = marketSymbol(input);
1389
1500
  const side = String(input.node.side).toLowerCase();
@@ -1403,17 +1514,27 @@ export async function placeOrder(input) {
1403
1514
  leverage,
1404
1515
  orderType,
1405
1516
  closeImmediately,
1517
+ runCancelsLimit: !ensure,
1406
1518
  projectRoot: input.context?.projectRoot,
1407
1519
  selectedAddress: bound.address,
1408
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();
1409
1524
  if ((closeImmediately || network === 'mainnet') && input.node?.size != null) {
1410
1525
  throw new Error('close_immediately and mainnet orders are sized from amount; drop size.');
1411
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
+ }
1412
1530
  if (closeImmediately) {
1413
1531
  const book = await readBoundBook(input, bound);
1414
1532
  assertFlatBeforeRoundTrip(book.positions, symbol, symbolForItem, book.orders);
1415
1533
  }
1416
1534
  const currentPrice = await currentPriceForOrder(input, symbol);
1535
+ const limitPrice = orderType === 'limit'
1536
+ ? resolveLimitPrice(input, isBuy, currentPrice)
1537
+ : null;
1417
1538
  const houseNotional = requireHouseNotional({
1418
1539
  network,
1419
1540
  symbol,
@@ -1422,10 +1543,8 @@ export async function placeOrder(input) {
1422
1543
  price: currentPrice,
1423
1544
  szDecimals: network === 'mainnet' ? await readSizeDecimals(input, symbol) : null,
1424
1545
  maxSlippageBps,
1546
+ limit: orderType === 'limit' ? { price: limitPrice, isBuy } : null,
1425
1547
  });
1426
- const limitPrice = orderType === 'limit'
1427
- ? resolveLimitPrice(input, isBuy, currentPrice)
1428
- : null;
1429
1548
  const size = String(
1430
1549
  input.node?.size
1431
1550
  ?? ((Number(amount) * leverage) / (limitPrice ?? currentPrice)),
@@ -1437,7 +1556,8 @@ export async function placeOrder(input) {
1437
1556
  orderType,
1438
1557
  size,
1439
1558
  price: String(limitPrice),
1440
- timeInForce: 'GTC',
1559
+ // Post-only on mainnet: the venue rejects a limit that would fill now.
1560
+ timeInForce: mainnetLimit ? 'ALO' : 'GTC',
1441
1561
  leverage,
1442
1562
  currentPrice,
1443
1563
  priceAtCalculation: currentPrice,
@@ -1457,6 +1577,21 @@ export async function placeOrder(input) {
1457
1577
  if (await tradingContextChanged(input, bound)) {
1458
1578
  throw new Error('Perps order not dispatched: the selected account or network changed after the order rule allowed it.');
1459
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;
1460
1595
  let result;
1461
1596
  try {
1462
1597
  result = await evalAsync(input, `(function(){ ${boundContextGuard(bound)}
@@ -1468,18 +1603,31 @@ export async function placeOrder(input) {
1468
1603
  );
1469
1604
  }
1470
1605
  if (result?.success === false || result == null) {
1471
- throw new Error(`Failed to place ${symbol} ${side}: ${result?.error || JSON.stringify(result)}`);
1606
+ const restNote = ledgerId ? "; the run's teardown cancels it if it rests" : '';
1607
+ // Mobile keeps no placement receipt, so a 429 is never retried: nothing proves the venue saw no order.
1608
+ if (isRateLimited(result?.error)) {
1609
+ throw rateLimitedError({
1610
+ call: 'PerpsController.placeOrder',
1611
+ attempts: 1,
1612
+ waitedMs: 0,
1613
+ reason: `no retry, since nothing proves the ${symbol} order was not placed${restNote}`,
1614
+ next: `read the ${symbol} positions and orders, then wait a minute and rerun only if nothing was placed`,
1615
+ });
1616
+ }
1617
+ throw new Error(`Failed to place ${symbol} ${side}: ${result?.error || JSON.stringify(result)}${restNote}`);
1472
1618
  }
1473
1619
  if (orderType === 'limit') {
1474
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));
1475
1622
  } else {
1476
1623
  await waitForPositionPresent(input, symbol, Number(input.node?.timeout_ms ?? 30000));
1477
1624
  }
1478
1625
  } catch (error) {
1626
+ if (ledgerId && error?.knownNotDispatched) await dropMainnetOrder(input.context.projectRoot, ledgerId);
1479
1627
  if (!closeImmediately || error?.knownNotDispatched) throw error;
1480
1628
  throw await closeAfterFailedPlacement(input, symbol, error, bound);
1481
1629
  }
1482
- 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' };
1483
1631
  if (!closeImmediately) return placed;
1484
1632
  return { ...placed, roundTrip: await closeOpenedPosition(input, symbol, bound) };
1485
1633
  }
@@ -1509,6 +1657,18 @@ async function closeAfterFailedPlacement(input, symbol, error, bound) {
1509
1657
  return new Error(`${message} close_immediately: ${outcome}.`);
1510
1658
  }
1511
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
+
1512
1672
  async function readSizeDecimals(input, symbol) {
1513
1673
  const markets = await evalAsync(
1514
1674
  input,
@@ -1540,7 +1700,9 @@ function boundContextGuard(bound) {
1540
1700
  return `var __account = Engine.context.AccountsController.getSelectedAccount();
1541
1701
  var __address = String((__account && __account.address) || '').toLowerCase();
1542
1702
  var __network = Engine.context.PerpsController.state.isTestnet ? 'testnet' : 'mainnet';
1543
- 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)})) {
1544
1706
  return Promise.resolve(JSON.stringify({ success: false, contextChanged: true }));
1545
1707
  }`;
1546
1708
  }
@@ -1639,7 +1801,7 @@ export async function ensurePositions(input) {
1639
1801
  }
1640
1802
  if (state === 'open' || state === 'present') {
1641
1803
  const existing = await readPositions(input);
1642
- if (selectedItems(input, existing).length === 0) await placeOrder(input);
1804
+ if (selectedItems(input, existing).length === 0) await placeOrder(input, { ensure: true });
1643
1805
  return assertPositions(input, true);
1644
1806
  }
1645
1807
  throw new Error(`metamask.perps.ensure_positions received unsupported state: ${state}`);
@@ -1657,7 +1819,7 @@ export async function ensureOrders(input) {
1657
1819
  const current = await readOpenOrders(input);
1658
1820
  let order = null;
1659
1821
  if (selectedItems(input, current).length === 0) {
1660
- order = await placeOrder({ ...input, node: { ...input.node, order_type: 'limit' } });
1822
+ order = await placeOrder({ ...input, node: { ...input.node, order_type: 'limit' } }, { ensure: true });
1661
1823
  }
1662
1824
  return { ...(await assertOrders(input, true)), order };
1663
1825
  }
@@ -1732,9 +1894,20 @@ function mergeStateConfig(defaults, params) {
1732
1894
  };
1733
1895
  }
1734
1896
 
1735
- export function applyTradingStatePolicy(config) {
1897
+ // The policy owns positions, orders and strategies. An explicit request for one
1898
+ // of them would be dropped, so it fails before any phase runs.
1899
+ export function applyTradingStatePolicy(config, requested) {
1736
1900
  const policy = config.trading_state;
1737
1901
  if (policy === undefined) return config;
1902
+ const dropped = ['positions', 'orders', 'strategies'].filter(
1903
+ (key) => requested[key] !== undefined && requested[key] !== false,
1904
+ );
1905
+ if (dropped.length > 0) {
1906
+ throw new Error(
1907
+ `metamask.perps.start_state trading_state=${String(policy)} sets positions, orders and strategies itself, so the requested ${dropped.join(', ')} would be ignored. ` +
1908
+ `Omit trading_state to apply ${dropped.join(', ')}, or request ${dropped.join(', ')} in a separate metamask.perps.start_state node.`,
1909
+ );
1910
+ }
1738
1911
  if (policy === 'preserve') {
1739
1912
  return { ...config, positions: false, orders: false, strategies: false };
1740
1913
  }
@@ -1895,6 +2068,7 @@ async function ensureNetwork(input, config) {
1895
2068
  if (Boolean(before.isTestnet) === expectedTestnet) {
1896
2069
  return { requested: config.network, isTestnet: before.isTestnet, changed: false };
1897
2070
  }
2071
+ if (expectedTestnet) await requireNoOpenMainnetOrders(input.context?.projectRoot, input.action);
1898
2072
  const result = await evalAsync(input, 'Engine.context.PerpsController.toggleTestnet().then(function(r){return JSON.stringify(r)})');
1899
2073
  const after = await waitForNetworkState(input, expectedTestnet, Number(input.node?.timeout_ms ?? 30000));
1900
2074
  if (Boolean(after.isTestnet) !== expectedTestnet) {
@@ -1969,16 +2143,15 @@ export async function startState(input) {
1969
2143
  const params = paramsForState(input);
1970
2144
  const config = applyTradingStatePolicy(
1971
2145
  mergeStateConfig(profileDefaults(params.profile), params),
2146
+ params,
1972
2147
  );
1973
- const timeoutMs = Math.max(1, Number(input.node?.timeout_ms ?? 30_000));
1974
- const deadline = deadlineFromTimeout(input, timeoutMs);
1975
- const stateInput = () => {
1976
- const remainingMs = deadline - Date.now();
1977
- if (remainingMs <= 0) {
1978
- throw new Error(
1979
- 'metamask.perps.start_state timed out before completing all phases.',
1980
- );
1981
- }
2148
+ const budget = createSetupBudget(input, 'metamask.perps.start_state');
2149
+ const navigationSlice = { sliceMs: budget.navigationMs };
2150
+ // Product edits require an app restart before proof. Mobile relocks on restart,
2151
+ // so a deterministic Perps start state must restore the fixture-backed wallet
2152
+ // prerequisite before it navigates or converges controller state.
2153
+ const wallet = await budget.phase('unlock', (phaseInput) => {
2154
+ const remainingMs = phaseInput.node.timeout_ms;
1982
2155
  const configuredTargetMs = Number(input.node?.target_timeout_ms);
1983
2156
  const targetTimeoutMs = Math.min(
1984
2157
  remainingMs,
@@ -1993,75 +2166,78 @@ export async function startState(input) {
1993
2166
  ? configuredUnlockMs
1994
2167
  : remainingMs,
1995
2168
  );
1996
- return {
1997
- ...input,
2169
+ return ensureUnlocked({
2170
+ ...phaseInput,
2171
+ action: 'metamask.wallet.ensure_unlocked',
1998
2172
  node: {
1999
- ...input.node,
2000
- _action_deadline_epoch_ms: deadline,
2001
- timeout_ms: remainingMs,
2002
- bridge_timeout_ms: remainingMs,
2003
- cdp_timeout_ms: remainingMs,
2004
- controller_ready_timeout_ms: remainingMs,
2173
+ ...phaseInput.node,
2174
+ action: 'metamask.wallet.ensure_unlocked',
2005
2175
  target_timeout_ms: targetTimeoutMs,
2006
2176
  unlock_timeout_ms: unlockTimeoutMs,
2007
2177
  },
2008
- };
2009
- };
2010
- // Product edits require an app restart before proof. Mobile relocks on restart,
2011
- // so a deterministic Perps start state must restore the fixture-backed wallet
2012
- // prerequisite before it navigates or converges controller state.
2013
- const walletInput = stateInput();
2014
- const wallet = await ensureUnlocked({
2015
- ...walletInput,
2016
- action: 'metamask.wallet.ensure_unlocked',
2017
- node: { ...walletInput.node, action: 'metamask.wallet.ensure_unlocked' },
2178
+ });
2018
2179
  });
2019
- const accountInput = stateInput();
2020
2180
  const account = typeof config.account === 'string' && config.account.trim()
2021
- ? await selectAccount({
2022
- ...accountInput,
2181
+ ? await budget.phase('account', (phaseInput) => selectAccount({
2182
+ ...phaseInput,
2023
2183
  action: 'metamask.wallet.select_account',
2024
2184
  node: {
2025
- ...accountInput.node,
2185
+ ...phaseInput.node,
2026
2186
  action: 'metamask.wallet.select_account',
2027
2187
  ...(/^0x[0-9a-f]{40}$/iu.test(config.account.trim())
2028
2188
  ? { address: config.account.trim() }
2029
2189
  : { name: config.account.trim() }),
2030
2190
  },
2031
- })
2191
+ }))
2032
2192
  : { skipped: true };
2033
2193
  // Entering Perps initializes the provider client on a freshly launched app.
2034
2194
  // State reads before this navigation fail with CLIENT_NOT_INITIALIZED.
2035
- const navigation = await applyStateNavigation(stateInput(), config);
2036
- const provider = await ensureProvider(stateInput(), config);
2037
- const network = await ensureNetwork(stateInput(), config);
2195
+ const navigation = await budget.phase(
2196
+ 'navigation',
2197
+ (phaseInput) => applyStateNavigation(phaseInput, config),
2198
+ navigationSlice,
2199
+ );
2200
+ const provider = await budget.phase('provider', (phaseInput) =>
2201
+ ensureProvider(phaseInput, config));
2202
+ const network = await budget.phase('network', (phaseInput) =>
2203
+ ensureNetwork(phaseInput, config));
2038
2204
  let resolvedNavigation = provider.changed || network.changed
2039
- ? await applyStateNavigation(stateInput(), config)
2205
+ ? await budget.phase(
2206
+ 'navigation after provider or network change',
2207
+ (phaseInput) => applyStateNavigation(phaseInput, config),
2208
+ navigationSlice,
2209
+ )
2040
2210
  : navigation;
2041
- const modeInput = stateInput();
2042
2211
  const marketMode = config.market_mode
2043
- ? await ensureMode({
2044
- ...modeInput,
2212
+ ? await budget.phase('market mode', (phaseInput) => ensureMode({
2213
+ ...phaseInput,
2045
2214
  action: 'metamask.perps.ensure_mode',
2046
2215
  node: {
2047
- ...modeInput.node,
2216
+ ...phaseInput.node,
2048
2217
  action: 'metamask.perps.ensure_mode',
2049
2218
  mode: config.market_mode,
2050
2219
  },
2051
- })
2220
+ }))
2052
2221
  : { skipped: true };
2053
2222
  if (marketMode.alreadySelected === false) {
2054
- resolvedNavigation = await applyStateNavigation(stateInput(), config);
2055
- }
2056
- const tutorial = await applyTutorialState(stateInput(), config);
2057
- const readyToTrade = await assertReadyToTrade(stateInput(), config);
2058
- const balance = await assertBalance(stateInput(), config);
2059
- const strategies = await applyStrategiesState(
2060
- stateInput(),
2061
- config.strategies,
2062
- );
2063
- const orders = await applyOrdersState(stateInput(), config.orders);
2064
- const positions = await applyPositionsState(stateInput(), config.positions);
2223
+ resolvedNavigation = await budget.phase(
2224
+ 'navigation after mode change',
2225
+ (phaseInput) => applyStateNavigation(phaseInput, config),
2226
+ navigationSlice,
2227
+ );
2228
+ }
2229
+ const tutorial = await budget.phase('tutorial', (phaseInput) =>
2230
+ applyTutorialState(phaseInput, config));
2231
+ const readyToTrade = await budget.phase('ready to trade', (phaseInput) =>
2232
+ assertReadyToTrade(phaseInput, config));
2233
+ const balance = await budget.phase('balance', (phaseInput) =>
2234
+ assertBalance(phaseInput, config));
2235
+ const strategies = await budget.phase('strategies', (phaseInput) =>
2236
+ applyStrategiesState(phaseInput, config.strategies));
2237
+ const orders = await budget.phase('orders', (phaseInput) =>
2238
+ applyOrdersState(phaseInput, config.orders));
2239
+ const positions = await budget.phase('positions', (phaseInput) =>
2240
+ applyPositionsState(phaseInput, config.positions));
2065
2241
  return {
2066
2242
  action: input.action,
2067
2243
  profile: config.profile ?? params.profile ?? null,
@@ -2090,16 +2266,7 @@ export async function startState(input) {
2090
2266
  }
2091
2267
 
2092
2268
  export async function teardownState(input) {
2093
- const timeoutMs = Math.max(1, Number(input.node?.timeout_ms ?? 30_000));
2094
- const deadline = deadlineFromTimeout(input, timeoutMs);
2095
- const stateInput = (phase) => {
2096
- if (Date.now() >= deadline) {
2097
- throw new Error(
2098
- `metamask.perps.teardown_state timed out before ${phase}.`,
2099
- );
2100
- }
2101
- return withRemainingDeadline(input, deadline);
2102
- };
2269
+ const budget = createSetupBudget(input, 'metamask.perps.teardown_state');
2103
2270
  const params = paramsForState(input);
2104
2271
  const defaults = {
2105
2272
  provider: 'hyperliquid',
@@ -2110,28 +2277,23 @@ export async function teardownState(input) {
2110
2277
  strategies: params.market || params.symbol || params.markets || params.symbols ? { state: 'none', mode: 'matching' } : false,
2111
2278
  };
2112
2279
  const config = mergeStateConfig(defaults, params);
2113
- const environment = await assertRequestedRuntimeState(
2114
- stateInput('checking the requested environment'),
2115
- config,
2116
- );
2117
- const strategies = await applyStrategiesState(
2118
- stateInput('cleaning strategies'),
2119
- config.strategies,
2120
- );
2121
- const orders = await applyOrdersState(
2122
- stateInput('cleaning orders'),
2123
- config.orders,
2124
- );
2125
- const positions = await applyPositionsState(
2126
- stateInput('closing positions'),
2127
- config.positions,
2280
+ const environment = await budget.phase(
2281
+ 'checking the requested environment',
2282
+ (phaseInput) => assertRequestedRuntimeState(phaseInput, config),
2128
2283
  );
2284
+ const strategies = await budget.phase('cleaning strategies', (phaseInput) =>
2285
+ applyStrategiesState(phaseInput, config.strategies));
2286
+ const orders = await budget.phase('cleaning orders', (phaseInput) =>
2287
+ applyOrdersState(phaseInput, config.orders));
2288
+ const positions = await budget.phase('closing positions', (phaseInput) =>
2289
+ applyPositionsState(phaseInput, config.positions));
2129
2290
  const navigation =
2130
2291
  config.page === false
2131
2292
  ? { skipped: true }
2132
- : await applyStateNavigation(
2133
- stateInput('restoring the requested page'),
2134
- config,
2293
+ : await budget.phase(
2294
+ 'restoring the requested page',
2295
+ (phaseInput) => applyStateNavigation(phaseInput, config),
2296
+ { sliceMs: budget.navigationMs },
2135
2297
  );
2136
2298
  return {
2137
2299
  action: input.action,
@@ -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
  });