@deeeed/metamask-harness 0.92.39 → 0.94.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,37 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.94.0 - 2026-10-22
6
+
7
+ ### Added
8
+
9
+ - Core `metamask.perps.place_order`, `ensure_positions` and `ensure_orders` take `margin_mode` (`isolated` or `cross`) with the same contract as Mobile: the controller order carries it, and the action proves the opened position's mode or, for a resting order, the venue lock (`getMarginModeLock`). It refuses a resting order before dispatch when the lock can't be read, and refuses anything but isolated on mainnet. `ensure_orders` proves reused orders per market and lists them in `marginModeLocks`; `margin_mode` applies only to `state=open`.
10
+ - Mobile and Core `metamask.perps.start_state` pass `margin_mode` through their `positions` and `orders` objects to the `ensure_*` step that creates the state.
11
+ - Mobile and Core positions report `side` from their signed size, and Core positions also report `marginMode`, like Mobile.
12
+ - On Android, an unlabeled test-ID container (for example a list row whose copy sits on child text views) reports its visible descendants' text as `descendant_text` (up to 500 characters), and `ui.locators` `byTestId` includes it in `description`.
13
+ - Mobile `ui.locators` takes `expect_enabled`, a map of requested test IDs to `true` or `false`, and re-reads until they reach that state or `timeout_ms` passes. The Pro margin chooser keeps Cross disabled until its venue lock read answers, so a single read can see a transient state. The last read is returned either way.
14
+
15
+ ### Changed
16
+
17
+ - The margin-mode request parsing and venue proofs live in one shared module (`library/actions/shared/perps/margin-mode.mjs`) used by Mobile and Core.
18
+ - Extension order actions don't take `margin_mode`: the Extension product has no Cross margin control to drive yet.
19
+
20
+ ### Fixed
21
+
22
+ - Mobile `place_order` with `margin_mode` retries the account-guarded position read on a transient client error instead of failing right after a fill. An account or network change is still never retried. A failed lock check now says the order may still be resting, since the order may have filled or been rejected. The lock mode is compared case-insensitively, and the returned top-level `marginMode` is lowercase like position reads; the `marginModeLock` objects keep the venue's value.
23
+ - Mobile `ui.locators` `test_ids` returns after one read when given no finite deadline instead of polling forever, and its description says to prove presence from `byTestId`.
24
+ - Mobile `ensure_orders` `margin_mode` documents `marginModeLocks`, one lock per selected market.
25
+ - Mobile Perps reads (positions, open orders, margin lock) retry a bridge read the app left unanswered (`CDP_TIMEOUT`, a busy JS thread) until their deadline, as they already did for `CLIENT_NOT_INITIALIZED`. A lost target or closed socket, as after a reload, still fails. Before, one busy moment right after a signed order failed `assert_positions` although the position opened.
26
+ - Mobile `start_state` with `page=false` keeps the current route even when `market` or `symbol` scopes the trading state. Before, it navigated to a `false` target before cleaning, so a cleanup could fail on navigation and leave the position open.
27
+
28
+ ## 0.93.0 - 2026-10-21
29
+
30
+ ### Added
31
+
32
+ - Mobile `metamask.perps.place_order`, `ensure_positions` and `ensure_orders` take `margin_mode` (`isolated` or `cross`). The order carries it to `PerpsController.placeOrder`. The action then proves the venue holds that mode: the opened position's mode for a market order, or `getMarginModeLock` for a resting order. It fails when they differ or when no position appears. A resting order is refused before dispatch when the venue lock cannot be read, and a failed lock check names the order left resting. `ensure_orders` proves each reused order on its own market. `ensure_positions` refuses to reuse an open position in another mode, since the venue cannot change it while open. `margin_mode` applies only to `state=open`. The mainnet house limits refuse any mode but `isolated`. Omitted keeps the venue default (the open position's mode, else isolated). Extension and Core order actions do not take `margin_mode` yet.
33
+ - Mobile `metamask.perps.read_positions` (and every position the Perps actions report) includes `marginMode`, so recipes can assert a position's collateral mode as state.
34
+ - Mobile `ui.locators` takes `test_ids` and returns `byTestId` (each element's role, enabled state, `null` when unreported, and description), `missingTestIds` and `unobservedTestIds`. A recipe can assert a control's state by test ID instead of scanning the ranked list. It covers disabled and non-interactive elements, which the ranked list drops as press targets. With `test_ids` it re-reads the accessibility tree until each ID is present or `timeout_ms` (default 5000, capped by the action deadline) passes, because the native tree can trail the React tree while a sheet animates in. A truncated observation reports its gaps as `unobservedTestIds`, since it cannot prove absence. The Extension observation keeps only interactive elements, so Extension `ui.locators` does not take `test_ids`.
35
+
5
36
  ## 0.92.39 - 2026-10-20
6
37
 
7
38
  ### Added
@@ -7,6 +7,7 @@ import { pathToFileURL } from 'node:url';
7
7
 
8
8
  import { walletFixturePath } from '../../harness-exports.mjs';
9
9
  import { normalizeSide, sideForItem } from '../../shared/perps/item-side.mjs';
10
+ import { marginModeOf } from '../../shared/perps/margin-mode.mjs';
10
11
  import { readFixtureAccounts } from '../../shared/wallet/fixture-accounts.mjs';
11
12
  import { createRewardsClient, rewardsApiUrl } from './_rewards.mjs';
12
13
 
@@ -655,8 +656,9 @@ export function redactPosition(position) {
655
656
  return {
656
657
  coin: position.coin ?? position.symbol ?? null,
657
658
  size: position.size ?? position.szi ?? null,
658
- side: position.side ?? null,
659
+ side: sideForItem(position, 'position') || normalizeSide(position.side) || null,
659
660
  entryPrice: position.entryPrice ?? position.entryPx ?? null,
661
+ marginMode: marginModeOf(position),
660
662
  };
661
663
  }
662
664
 
@@ -1,10 +1,11 @@
1
1
  // Converge selected testnet orders to open or absent state, then assert the result.
2
2
  // Reuse an existing matching order instead of placing another.
3
3
 
4
- import { getCoreController, isDirectRun, requireExplicitSelection, runAdapter, selectedItems } from './_controller.mjs';
4
+ import { getCoreController, getCoreControllerWithSigner, isDirectRun, requireExplicitSelection, runAdapter, selectedItems } from './_controller.mjs';
5
5
  import { assertOrders } from './assert_orders.mjs';
6
6
  import { closeOrders } from './close_orders.mjs';
7
7
  import { placeOrder } from './place_order.mjs';
8
+ import { refuseMarginModeForAbsentState, requireLockMarginMode, resolveMarginMode } from '../../shared/perps/margin-mode.mjs';
8
9
 
9
10
  export async function ensureOrders(input) {
10
11
  if (input.node?.state == null) throw new Error('metamask.perps.ensure_orders requires state=open or state=none.');
@@ -12,6 +13,7 @@ export async function ensureOrders(input) {
12
13
  const state = String(input.node.state).toLowerCase();
13
14
 
14
15
  if (state === 'none' || state === 'closed' || state === 'absent') {
16
+ refuseMarginModeForAbsentState(input.node, input.action);
15
17
  const close = await closeOrders(input);
16
18
  const assertion = await assertOrders(input, false);
17
19
  return { ...assertion, ensured: 'none', close };
@@ -28,11 +30,25 @@ export async function ensureOrders(input) {
28
30
  userAddress: accountAddress,
29
31
  });
30
32
  let order = null;
31
- if (selectedItems(input, orders).length === 0) {
33
+ const reused = selectedItems(input, orders);
34
+ if (reused.length === 0) {
32
35
  order = await placeOrder({ ...input, node: { ...input.node, order_type: 'limit' } });
33
36
  }
34
37
  const assertion = await assertOrders(input, true);
35
- return { ...assertion, ensured: 'open', order };
38
+ const marginMode = resolveMarginMode(input.node, input.action);
39
+ if (!marginMode) return { ...assertion, ensured: 'open', order };
40
+ // A placed order was already proved inside placeOrder; reused orders are proved per market.
41
+ const locks = {};
42
+ if (order) {
43
+ locks[order.market] = order.marginModeLock;
44
+ } else {
45
+ // The lock is read for the controller's own account, which only the signer setup binds.
46
+ const { controller: signed } = await getCoreControllerWithSigner(input);
47
+ for (const symbol of [...new Set(reused.map((item) => item.coin ?? item.symbol))]) {
48
+ locks[symbol] = requireLockMarginMode(input.action, symbol, await signed.getMarginModeLock({ symbol }), marginMode);
49
+ }
50
+ }
51
+ return { ...assertion, ensured: 'open', order, marginModeLock: Object.values(locks)[0], marginModeLocks: locks };
36
52
  }
37
53
 
38
54
  throw new Error(`metamask.perps.ensure_orders received unsupported state: ${state}`);
@@ -5,6 +5,7 @@ import { getCoreController, isDirectRun, requireExplicitSelection, runAdapter, s
5
5
  import { assertPositions } from './assert_positions.mjs';
6
6
  import { closePositions } from './close_positions.mjs';
7
7
  import { placeOrder } from './place_order.mjs';
8
+ import { refuseMarginModeForAbsentState, requirePositionMarginMode, resolveMarginMode } from '../../shared/perps/margin-mode.mjs';
8
9
 
9
10
  export async function ensurePositions(input) {
10
11
  if (input.node?.state == null) throw new Error('metamask.perps.ensure_positions requires state=open or state=none.');
@@ -12,6 +13,7 @@ export async function ensurePositions(input) {
12
13
  const state = String(input.node.state).toLowerCase();
13
14
 
14
15
  if (state === 'none' || state === 'closed' || state === 'absent') {
16
+ refuseMarginModeForAbsentState(input.node, input.action);
15
17
  const close = await closePositions(input);
16
18
  const assertion = await assertPositions(input, false);
17
19
  return { ...assertion, ensured: 'none', close };
@@ -25,7 +27,11 @@ export async function ensurePositions(input) {
25
27
  userAddress: accountAddress,
26
28
  });
27
29
  let order = null;
28
- if (selectedItems(input, positions, 'position').length === 0) {
30
+ const existing = selectedItems(input, positions, 'position');
31
+ const marginMode = resolveMarginMode(input.node, input.action);
32
+ // The venue cannot change an open position's mode, so a reused one must already hold it.
33
+ if (existing.length > 0 && marginMode) requirePositionMarginMode(input.action, existing, marginMode);
34
+ if (existing.length === 0) {
29
35
  order = await placeOrder(input);
30
36
  }
31
37
  const assertion = await assertPositions(input, true);
@@ -16,6 +16,12 @@ import {
16
16
  symbolForItem,
17
17
  } from './_controller.mjs';
18
18
  import { waitForPositionsAbsent } from './close_positions.mjs';
19
+ import {
20
+ requireLockMarginMode,
21
+ requireLockReadable,
22
+ requirePositionMarginMode,
23
+ resolveMarginMode,
24
+ } from '../../shared/perps/margin-mode.mjs';
19
25
  import {
20
26
  assertFlatBeforeRoundTrip,
21
27
  assertHouseLimitPosition,
@@ -376,6 +382,7 @@ export async function placeOrder(input) {
376
382
  }
377
383
  const isBuy = side === 'long';
378
384
  const orderType = resolveOrderType(input);
385
+ const marginMode = resolveMarginMode(input.node, input.action);
379
386
  const leverage = Number(optionalParam(input.node ?? {}, 'leverage') ?? 3);
380
387
  const maxSlippageBps = Number(
381
388
  optionalParam(
@@ -437,6 +444,7 @@ export async function placeOrder(input) {
437
444
  leverage,
438
445
  orderType,
439
446
  closeImmediately,
447
+ marginMode,
440
448
  projectRoot: input.context?.projectRoot,
441
449
  selectedAddress: signerAddress,
442
450
  });
@@ -489,6 +497,7 @@ export async function placeOrder(input) {
489
497
  const strayTimeInForce =
490
498
  orderType === 'limit' ? undefined : requestedTimeInForce;
491
499
  const forwardedFields = {
500
+ ...(marginMode ? { marginMode } : {}),
492
501
  ...(strayTriggerPrice === undefined
493
502
  ? {}
494
503
  : { triggerPrice: strayTriggerPrice }),
@@ -566,6 +575,11 @@ export async function placeOrder(input) {
566
575
  // close_immediately started from a flat market (checked above), so a position
567
576
  // in it after a failed placement is this order's: close it rather than leave it open.
568
577
  const restsOnBook = orderType === 'limit' || isTriggerOrderType(orderType);
578
+ if (marginMode && restsOnBook) {
579
+ requireLockReadable(input.action, symbol, await controller.getMarginModeLock({ symbol }));
580
+ }
581
+ let marginModeLock;
582
+ let observedMarginMode;
569
583
  const placeAndVerify = async () => {
570
584
  const result = await controller.placeOrder(orderParams);
571
585
  if (!result || result.success !== true) {
@@ -614,6 +628,13 @@ export async function placeOrder(input) {
614
628
  `core placeOrder (market) for ${symbol} reported success but no matching position is visible (orderId=${result.orderId ?? 'null'}).`,
615
629
  );
616
630
  }
631
+ if (marginMode && restsOnBook) {
632
+ const resting = ` The ${symbol} order it placed (${result.orderId ?? 'unknown id'}) may still be resting; cancel it with ensure_orders state=none.`;
633
+ marginModeLock = requireLockMarginMode(input.action, symbol, await controller.getMarginModeLock({ symbol }), marginMode, resting);
634
+ observedMarginMode = String(marginModeLock.marginMode).toLowerCase();
635
+ } else if (marginMode) {
636
+ observedMarginMode = requirePositionMarginMode(input.action, matchingPositions, marginMode);
637
+ }
617
638
  return { result, matchingOrders, matchingPositions };
618
639
  };
619
640
  let placed;
@@ -649,6 +670,8 @@ export async function placeOrder(input) {
649
670
  orderType,
650
671
  notional: usdAmount,
651
672
  leverage,
673
+ ...(observedMarginMode ? { marginMode: observedMarginMode } : {}),
674
+ ...(marginModeLock ? { marginModeLock } : {}),
652
675
  timeInForce,
653
676
  size: orderParams.size,
654
677
  currentPrice,
@@ -1,8 +1,17 @@
1
1
  // Implement Mobile Perps navigation, trading mutations, assertions, and setup or cleanup through the bridge.
2
2
  // Strategy getters used during cleanup can settle tracked collateral.
3
- import { sideForItem } from '../../shared/perps/item-side.mjs';
3
+ import { normalizeSide, sideForItem } from '../../shared/perps/item-side.mjs';
4
+ import {
5
+ marginModeOf,
6
+ refuseMarginModeForAbsentState,
7
+ requireLockMarginMode,
8
+ requireLockReadable,
9
+ requirePositionMarginMode,
10
+ resolveMarginMode,
11
+ } from '../../shared/perps/margin-mode.mjs';
4
12
  import { pathToFileURL } from 'node:url';
5
13
  import {
14
+ MOBILE_BRIDGE_ERROR_CODES,
6
15
  MOBILE_BRIDGE_RESULT_ERROR_CODES,
7
16
  bridgeCommand,
8
17
  createSetupBudget,
@@ -251,8 +260,10 @@ export async function ensureMode(input) {
251
260
  throw new Error(`Perps did not reach ${requestedMode} mode before timeout.`);
252
261
  }
253
262
 
254
- // Retries the client-not-initialized race until the deadline, and a 429 with
255
- // the shared rate-limit backoff.
263
+ // Retries the client-not-initialized race and a CDP_TIMEOUT (the app's JS
264
+ // thread busy, e.g. signing on a dev build) until the deadline, and a 429 with
265
+ // the shared rate-limit backoff. Only reads are wrapped, so a retry cannot
266
+ // repeat a write. A lost target or closed socket (a reload) still throws.
256
267
  export async function retryPerpsClientRead(
257
268
  read,
258
269
  {
@@ -274,7 +285,8 @@ export async function retryPerpsClientRead(
274
285
  try {
275
286
  return await withRateLimitRetry(call, read, { deadline, sleep: wait, random });
276
287
  } catch (error) {
277
- if (!/CLIENT_NOT_INITIALIZED/u.test(String(error?.message ?? error))) {
288
+ if (!/CLIENT_NOT_INITIALIZED/u.test(String(error?.message ?? error))
289
+ && error?.code !== MOBILE_BRIDGE_ERROR_CODES.CDP_TIMEOUT) {
278
290
  throw error;
279
291
  }
280
292
  const remainingMs = deadline - Date.now();
@@ -428,6 +440,28 @@ async function readOpenOrders(input) {
428
440
  );
429
441
  }
430
442
 
443
+ // The mode the venue binds to a market through its resting orders or TWAPs;
444
+ // open orders themselves do not report it.
445
+ async function readMarginModeLock(input, symbol) {
446
+ return retryPerpsClientRead(
447
+ () => evalAsync(
448
+ input,
449
+ `(function(){
450
+ var controller = Engine && Engine.context && Engine.context.PerpsController;
451
+ if (!controller || typeof controller.getMarginModeLock !== 'function') {
452
+ throw new Error('Engine.context.PerpsController.getMarginModeLock is unavailable; this build cannot verify margin_mode.');
453
+ }
454
+ return controller.getMarginModeLock(${sourceLiteral({ symbol })}).then(function(r){ return JSON.stringify(r); });
455
+ })()`,
456
+ ),
457
+ { deadlineEpochMs: inheritedDeadline(input), call: 'PerpsController.getMarginModeLock' },
458
+ );
459
+ }
460
+
461
+ async function requireOrderMarginMode(input, symbol, marginMode, leftOver = '') {
462
+ return requireLockMarginMode(input.action, symbol, await readMarginModeLock(input, symbol), marginMode, leftOver);
463
+ }
464
+
431
465
  const TWAP_NOT_ADVERTISED = 'provider-does-not-advertise-twap';
432
466
 
433
467
  async function readStrategyOrders(input) {
@@ -829,8 +863,9 @@ function redactPosition(position) {
829
863
  return {
830
864
  symbol: position.symbol ?? position.coin ?? null,
831
865
  size: position.size ?? position.szi ?? null,
832
- side: position.side ?? null,
866
+ side: sideForItem(position, 'position') || normalizeSide(position.side) || null,
833
867
  entryPrice: position.entryPrice ?? position.entryPx ?? null,
868
+ marginMode: marginModeOf(position),
834
869
  };
835
870
  }
836
871
 
@@ -1527,6 +1562,7 @@ export async function placeOrder(input, { ensure = false } = {}) {
1527
1562
  const maxSlippageBps = Number(input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300);
1528
1563
  const isBuy = orderSideIsBuy(side);
1529
1564
  const orderType = resolveOrderType(input);
1565
+ const marginMode = resolveMarginMode(input.node, input.action);
1530
1566
  const closeImmediately = closeImmediatelyRequested(input.node);
1531
1567
  const runtime = await readPerpsRuntimeState(input);
1532
1568
  const network = orderNetwork({ declared: input.node?.network, liveIsTestnet: runtime?.isTestnet });
@@ -1539,6 +1575,7 @@ export async function placeOrder(input, { ensure = false } = {}) {
1539
1575
  orderType,
1540
1576
  closeImmediately,
1541
1577
  runCancelsLimit: !ensure,
1578
+ marginMode,
1542
1579
  projectRoot: input.context?.projectRoot,
1543
1580
  selectedAddress: bound.address,
1544
1581
  });
@@ -1586,6 +1623,7 @@ export async function placeOrder(input, { ensure = false } = {}) {
1586
1623
  currentPrice,
1587
1624
  priceAtCalculation: currentPrice,
1588
1625
  maxSlippageBps,
1626
+ ...(marginMode ? { marginMode } : {}),
1589
1627
  }
1590
1628
  : {
1591
1629
  symbol,
@@ -1597,7 +1635,9 @@ export async function placeOrder(input, { ensure = false } = {}) {
1597
1635
  currentPrice,
1598
1636
  priceAtCalculation: currentPrice,
1599
1637
  maxSlippageBps,
1638
+ ...(marginMode ? { marginMode } : {}),
1600
1639
  };
1640
+ if (marginMode && orderType === 'limit') requireLockReadable(input.action, symbol, await readMarginModeLock(input, symbol));
1601
1641
  if (await tradingContextChanged(input, bound)) {
1602
1642
  throw new Error('Perps order not dispatched: the selected account or network changed after the order rule allowed it.');
1603
1643
  }
@@ -1617,6 +1657,8 @@ export async function placeOrder(input, { ensure = false } = {}) {
1617
1657
  })
1618
1658
  : null;
1619
1659
  let result;
1660
+ let marginModeLock;
1661
+ let observedMarginMode;
1620
1662
  try {
1621
1663
  result = await evalAsync(input, `(function(){ ${boundContextGuard(bound)}
1622
1664
  return Engine.context.PerpsController.placeOrder(${sourceLiteral(orderParams)}).then(function(r){return JSON.stringify(r)}); })()`);
@@ -1646,12 +1688,24 @@ export async function placeOrder(input, { ensure = false } = {}) {
1646
1688
  } else {
1647
1689
  await waitForPositionPresent(input, symbol, Number(input.node?.timeout_ms ?? 30000));
1648
1690
  }
1691
+ if (marginMode && orderType === 'limit') {
1692
+ const resting = ` The ${symbol} order it placed (${result?.orderId ?? 'unknown id'}) may still be resting; ${ledgerId ? "the run's teardown cancels it" : 'cancel it with ensure_orders state=none'}.`;
1693
+ marginModeLock = await requireOrderMarginMode(input, symbol, marginMode, resting);
1694
+ observedMarginMode = String(marginModeLock.marginMode).toLowerCase();
1695
+ } else if (marginMode) {
1696
+ const positions = await retryPerpsClientRead(
1697
+ () => readBoundPositions(input, bound),
1698
+ { deadlineEpochMs: inheritedDeadline(input), call: 'PerpsController.getPositions' },
1699
+ );
1700
+ const opened = positions.filter((position) => symbolForItem(position) === symbol);
1701
+ observedMarginMode = requirePositionMarginMode(input.action, opened, marginMode);
1702
+ }
1649
1703
  } catch (error) {
1650
1704
  if (ledgerId && error?.knownNotDispatched) await dropMainnetOrder(input.context.projectRoot, ledgerId);
1651
1705
  if (!closeImmediately || error?.knownNotDispatched) throw error;
1652
1706
  throw await closeAfterFailedPlacement(input, symbol, error, bound);
1653
1707
  }
1654
- 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' };
1708
+ const placed = { action: input.action, market: symbol, side, orderType, amount, size, leverage, ...(observedMarginMode ? { marginMode: observedMarginMode } : {}), ...(marginModeLock ? { marginModeLock } : {}), limitPrice, submitted: true, result, network, policy: policy.lane, ...(policy.autoCancel ? { autoCancel: policy.autoCancel } : {}), ...(houseNotional ? { houseNotional } : {}), proofPath: 'mobile-perps-controller-place-order' };
1655
1709
  if (!closeImmediately) return placed;
1656
1710
  return { ...placed, roundTrip: await closeOpenedPosition(input, symbol, bound) };
1657
1711
  }
@@ -1820,13 +1874,17 @@ export async function ensurePositions(input) {
1820
1874
  requireExplicitSelection(input);
1821
1875
  const state = String(input.node.state).toLowerCase();
1822
1876
  if (state === 'none' || state === 'closed' || state === 'absent') {
1877
+ refuseMarginModeForAbsentState(input.node, input.action);
1823
1878
  const close = await closePositions(input);
1824
1879
  return { ...(await assertPositions(input, false)), close };
1825
1880
  }
1826
1881
  if (state === 'open' || state === 'present') {
1827
1882
  const existing = await readPositions(input);
1828
1883
  if (selectedItems(input, existing).length === 0) await placeOrder(input, { ensure: true });
1829
- return assertPositions(input, true);
1884
+ const result = await assertPositions(input, true);
1885
+ const marginMode = resolveMarginMode(input.node, input.action);
1886
+ if (marginMode) requirePositionMarginMode(input.action, result.positions, marginMode);
1887
+ return result;
1830
1888
  }
1831
1889
  throw new Error(`metamask.perps.ensure_positions received unsupported state: ${state}`);
1832
1890
  }
@@ -1836,6 +1894,7 @@ export async function ensureOrders(input) {
1836
1894
  requireExplicitSelection(input);
1837
1895
  const state = String(input.node.state).toLowerCase();
1838
1896
  if (state === 'none' || state === 'closed' || state === 'absent') {
1897
+ refuseMarginModeForAbsentState(input.node, input.action);
1839
1898
  const close = await closeOrders(input);
1840
1899
  return { ...(await assertOrders(input, false)), close };
1841
1900
  }
@@ -1845,7 +1904,16 @@ export async function ensureOrders(input) {
1845
1904
  if (selectedItems(input, current).length === 0) {
1846
1905
  order = await placeOrder({ ...input, node: { ...input.node, order_type: 'limit' } }, { ensure: true });
1847
1906
  }
1848
- return { ...(await assertOrders(input, true)), order };
1907
+ const result = await assertOrders(input, true);
1908
+ const marginMode = resolveMarginMode(input.node, input.action);
1909
+ if (!marginMode) return { ...result, order };
1910
+ // A placed order was already proved inside placeOrder; reused orders are proved per market.
1911
+ const symbols = order ? [marketSymbol(input)] : [...new Set(result.orders.map((item) => item.symbol))];
1912
+ const locks = {};
1913
+ for (const symbol of symbols) {
1914
+ locks[symbol] = order ? order.marginModeLock : await requireOrderMarginMode(input, symbol, marginMode);
1915
+ }
1916
+ return { ...result, order, marginModeLock: locks[symbols[0]], marginModeLocks: locks };
1849
1917
  }
1850
1918
  throw new Error(`metamask.perps.ensure_orders received unsupported state: ${state}`);
1851
1919
  }
@@ -2028,8 +2096,10 @@ async function applyStrategiesState(input, config) {
2028
2096
  return closeStrategyOrders(childInput(input, node));
2029
2097
  }
2030
2098
 
2031
- async function applyStateNavigation(input, config) {
2032
- if (!config.page && !config.market && !config.symbol) return { skipped: true };
2099
+ // page=false preserves the current route even when market/symbol scopes the
2100
+ // cleanup, so a teardown cannot fail on navigation before it cleans.
2101
+ export async function applyStateNavigation(input, config) {
2102
+ if (config.page === false || (!config.page && !config.market && !config.symbol)) return { skipped: true };
2033
2103
  const target = config.page ?? defaultNavigationTarget(config);
2034
2104
  return navigatePerps(childInput(input, { target, market: config.market, symbol: config.symbol }));
2035
2105
  }
@@ -503,18 +503,26 @@ function flattenIosHierarchy(value, textTestIds = [], hudIntent) {
503
503
  return { visible, hidden, nodeCount, screenName };
504
504
  }
505
505
 
506
- function parseAndroidHierarchy(output, hudIntent) {
506
+ // Unlabeled test-ID containers (a Pressable around TextViews) also report the
507
+ // visible text of their descendants as descendant_text, capped so a container
508
+ // such as a scroll view does not carry the whole screen.
509
+ const DESCENDANT_TEXT_LIMIT = 500;
510
+
511
+ export function parseAndroidHierarchy(output, hudIntent) {
507
512
  const xmlStart = Math.max(output.indexOf('<?xml'), output.indexOf('<hierarchy'));
508
513
  const xml = xmlStart >= 0 ? output.slice(xmlStart) : output;
509
514
  const visible = [];
510
515
  const hidden = [];
511
516
  const ancestorVisibility = [];
517
+ const openItems = [];
512
518
  let nodeCount = 0;
513
519
  let screenName;
514
520
  for (const match of xml.matchAll(/<\/?node\b[^>]*>/gu)) {
515
521
  const tag = match[0];
516
522
  if (tag.startsWith('</')) {
517
523
  ancestorVisibility.pop();
524
+ const closed = openItems.pop();
525
+ if (closed?.texts.length) closed.item.descendant_text = closed.texts.join(' ').slice(0, DESCENDANT_TEXT_LIMIT);
518
526
  continue;
519
527
  }
520
528
  const attributes = {};
@@ -528,6 +536,7 @@ function parseAndroidHierarchy(output, hudIntent) {
528
536
  const label = text(attributes['content-desc']) ??
529
537
  (isEditable ? undefined : text(attributes.text));
530
538
  if (!screenName && label && !isEditable) screenName = label;
539
+ const ownText = isEditable ? undefined : text(attributes.text) ?? text(attributes['content-desc']);
531
540
  const item = compactItem({
532
541
  role,
533
542
  label,
@@ -540,6 +549,9 @@ function parseAndroidHierarchy(output, hudIntent) {
540
549
  const isVisible =
541
550
  ancestorVisibility.every(Boolean) &&
542
551
  attributes['visible-to-user'] !== 'false';
552
+ if (ownText && isVisible) {
553
+ for (const open of openItems) if (open) open.texts.push(ownText);
554
+ }
543
555
  const isActionable =
544
556
  isActionableRole(role) ||
545
557
  attributes.clickable === 'true' ||
@@ -551,7 +563,10 @@ function parseAndroidHierarchy(output, hudIntent) {
551
563
  hidden.push({ ...item, reason: 'hidden' });
552
564
  }
553
565
  }
554
- if (!tag.endsWith('/>')) ancestorVisibility.push(isVisible);
566
+ if (!tag.endsWith('/>')) {
567
+ ancestorVisibility.push(isVisible);
568
+ openItems.push(item.test_id && !item.label ? { item, texts: [] } : null);
569
+ }
555
570
  }
556
571
  return { visible, hidden, nodeCount, screenName };
557
572
  }
@@ -1,18 +1,28 @@
1
1
  // Turn visible Mobile accessibility observations into UI locator candidates.
2
2
  import { observeNativeUi } from '../platform/observe-ui.mjs';
3
- import { runAdapter } from '../platform/bridge.mjs';
3
+ import { earliestDeadline, runAdapter } from '../platform/bridge.mjs';
4
4
  import {
5
5
  locatorsFromObservation,
6
+ observeRequestedTestIds,
7
+ requestedEnabledStates,
8
+ requestedTestIds,
9
+ requestedTimeoutMs,
6
10
  requireVisibleObservation,
7
11
  } from '../../shared/ui/locators.mjs';
8
12
 
9
13
  runAdapter(async (input) => {
10
- const observed = await observeNativeUi(
11
- { refs: ['ui.visible'], node: input.node },
12
- input.context,
14
+ const testIds = requestedTestIds(input.node);
15
+ const observation = await observeRequestedTestIds(
16
+ async () => requireVisibleObservation(await observeNativeUi(
17
+ { refs: ['ui.visible'], node: input.node },
18
+ input.context,
19
+ )),
20
+ testIds,
21
+ earliestDeadline(input, Date.now() + requestedTimeoutMs(input.node)),
22
+ requestedEnabledStates(input.node, testIds),
13
23
  );
14
24
  return {
15
25
  action: input.action,
16
- ...locatorsFromObservation(requireVisibleObservation(observed)),
26
+ ...locatorsFromObservation(observation, { testIds }),
17
27
  };
18
28
  });
@@ -0,0 +1,53 @@
1
+ // Margin-mode requests and venue proofs shared by the Mobile and Core Perps order actions.
2
+
3
+ export const MARGIN_MODES = Object.freeze(['isolated', 'cross']);
4
+
5
+ // Omitted keeps the venue default: the open position's mode, else isolated.
6
+ export function resolveMarginMode(node, action) {
7
+ const raw = node?.margin_mode ?? node?.marginMode;
8
+ if (raw == null || raw === '') return undefined;
9
+ const value = String(raw).toLowerCase();
10
+ if (!MARGIN_MODES.includes(value)) {
11
+ throw new Error(`${action} received unsupported margin_mode: ${value}.`);
12
+ }
13
+ return value;
14
+ }
15
+
16
+ // Same source as the mainnet house-limit check, normalized to isolated|cross.
17
+ // Accepts a raw controller position or one already redacted with marginMode.
18
+ export function marginModeOf(position) {
19
+ return String(position?.marginMode ?? position?.leverage?.type ?? position?.marginType ?? '').toLowerCase() || null;
20
+ }
21
+
22
+ export function refuseMarginModeForAbsentState(node, action) {
23
+ if (resolveMarginMode(node, action)) {
24
+ throw new Error(`${action} margin_mode applies only to state=open; drop it for state=none.`);
25
+ }
26
+ }
27
+
28
+ export function requirePositionMarginMode(action, positions, marginMode) {
29
+ if (positions.length === 0) {
30
+ throw new Error(`${action} requested margin_mode=${marginMode}, but no matching position was observed to prove it.`);
31
+ }
32
+ const mismatched = positions.filter((position) => marginModeOf(position) !== marginMode);
33
+ if (mismatched.length > 0) {
34
+ const found = mismatched.map((position) => `${position.symbol ?? position.coin} is ${marginModeOf(position)}`).join(', ');
35
+ throw new Error(`${action} requested margin_mode=${marginMode}, but ${found}; the venue cannot change an open position's mode, so converge with state=none first.`);
36
+ }
37
+ return marginModeOf(positions[0]);
38
+ }
39
+
40
+ // A resting order is only provable through the venue lock, so refuse before
41
+ // dispatch when the build or provider cannot answer it.
42
+ export function requireLockReadable(action, symbol, lock) {
43
+ if (lock?.status !== 'locked' && lock?.status !== 'unlocked') {
44
+ throw new Error(`${action} cannot verify margin_mode for ${symbol} before placing a resting order: the venue lock reports ${JSON.stringify(lock)}.`);
45
+ }
46
+ }
47
+
48
+ export function requireLockMarginMode(action, symbol, lock, marginMode, leftOver = '') {
49
+ if (lock?.status !== 'locked' || String(lock.marginMode).toLowerCase() !== marginMode) {
50
+ throw new Error(`${action} requested margin_mode=${marginMode} for ${symbol}, but the venue reports ${JSON.stringify(lock)}.${leftOver}`);
51
+ }
52
+ return lock;
53
+ }
@@ -62,10 +62,11 @@ export function closeImmediatelyRequested(node) {
62
62
  * @param {boolean} order.fixtureAccount - The selected account is a synced fixture account.
63
63
  * @param {string|null} [order.accountName] - Its fixture name.
64
64
  * @param {boolean} [order.runCancelsLimit] - The caller records a limit order for the run's forced cancel.
65
+ * @param {'isolated'|'cross'} [order.marginMode] - Explicit collateral mode; mainnet allows only isolated.
65
66
  * @returns {{ lane: 'testnet' } | { lane: 'mainnet-house-limits', notionalBaseUnits: string, autoCancel?: 'end-of-run' }}
66
67
  */
67
68
  export function decidePerpsOrder({
68
- network, amount, leverage, orderType = 'market', closeImmediately, fixtureAccount, accountName = null, runCancelsLimit = false,
69
+ network, amount, leverage, orderType = 'market', closeImmediately, fixtureAccount, accountName = null, runCancelsLimit = false, marginMode,
69
70
  }) {
70
71
  if (closeImmediately && orderType !== 'market') {
71
72
  throw new Error('close_immediately needs a market order: a resting limit order is not a position the action can close.');
@@ -80,6 +81,7 @@ export function decidePerpsOrder({
80
81
  if (amountBaseUnits <= 0n) broken.push('the amount is not positive');
81
82
  if (!fixtureAccount) broken.push('the selected account is not one of the synced test accounts');
82
83
  if (leverageValue !== MAINNET_HOUSE_LIMITS.leverage) broken.push(`leverage is ${leverage}, not 1`);
84
+ if (marginMode !== undefined && marginMode !== 'isolated') broken.push(`margin_mode is ${marginMode}, not isolated`);
83
85
  if (notionalBaseUnits > MAINNET_HOUSE_LIMITS.maxNotionalBaseUnits) {
84
86
  broken.push(`notional ${formatUsd(notionalBaseUnits)} is above $${MAINNET_HOUSE_LIMITS.maxNotionalUsd}`);
85
87
  }
@@ -21,7 +21,7 @@ const INTERACTIVE_ROLE_PARTS = [
21
21
  'cell',
22
22
  ];
23
23
 
24
- export function locatorsFromObservation(observation) {
24
+ export function locatorsFromObservation(observation, { testIds = [] } = {}) {
25
25
  const value = record(observation);
26
26
  const items = Array.isArray(value.items) ? value.items.map(record) : [];
27
27
  const ranked = items
@@ -44,9 +44,92 @@ export function locatorsFromObservation(observation) {
44
44
  count: locators.length,
45
45
  truncated: value.truncated === true || ranked.length > locators.length,
46
46
  locators,
47
+ ...(testIds.length > 0 ? visibleStateByTestId(items, testIds, value.truncated === true) : {}),
47
48
  };
48
49
  }
49
50
 
51
+ // State lookup for requested test IDs over every visible item, including
52
+ // disabled or non-interactive ones that are not press targets.
53
+ // A truncated observation cannot prove absence, so its gaps are reported as
54
+ // unobservedTestIds rather than missingTestIds.
55
+ function visibleStateByTestId(items, testIds, truncated) {
56
+ const byTestId = Object.create(null);
57
+ const absent = [];
58
+ for (const testId of testIds) {
59
+ const matches = items.filter((item) => text(item.test_id) === testId);
60
+ if (matches.length === 0) {
61
+ absent.push(testId);
62
+ continue;
63
+ }
64
+ const item = matches[0];
65
+ byTestId[testId] = {
66
+ matches: matches.length,
67
+ role: text(item.role),
68
+ enabled: typeof item.enabled === 'boolean' ? item.enabled : null,
69
+ ...(typeof item.selected === 'boolean' ? { selected: item.selected } : {}),
70
+ description: describe(item),
71
+ bounds: bounds(item.bounds),
72
+ };
73
+ }
74
+ return truncated
75
+ ? { byTestId, missingTestIds: [], unobservedTestIds: absent }
76
+ : { byTestId, missingTestIds: absent, unobservedTestIds: [] };
77
+ }
78
+
79
+ // The native accessibility tree can lag the React tree while a sheet animates
80
+ // in, and a control can stay disabled until its data loads, so requested test
81
+ // IDs are re-read until observed (and in their expected enabled state) or the
82
+ // bound expires. The caller asserts on whatever the last read saw.
83
+ export async function observeRequestedTestIds(observe, testIds, deadlineEpochMs, expectEnabled = {}) {
84
+ const pollMs = 250;
85
+ while (true) {
86
+ const observation = await observe();
87
+ if (testIds.length === 0 || !Number.isFinite(deadlineEpochMs)) return observation;
88
+ const items = (Array.isArray(observation?.items) ? observation.items : []).map(record);
89
+ const settled = testIds.every((testId) => {
90
+ const item = items.find((candidate) => text(candidate.test_id) === testId);
91
+ return item && (!(testId in expectEnabled) || item.enabled === expectEnabled[testId]);
92
+ });
93
+ if (settled) return observation;
94
+ if (Date.now() + pollMs >= deadlineEpochMs) return observation;
95
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
96
+ }
97
+ }
98
+
99
+ export function requestedTestIds(node) {
100
+ const value = node?.test_ids;
101
+ if (value == null) return [];
102
+ if (!Array.isArray(value) || !value.every((id) => typeof id === 'string' && id.trim())) {
103
+ throw new Error('ui.locators test_ids must be an array of non-empty test ID strings.');
104
+ }
105
+ return value.map((id) => id.trim());
106
+ }
107
+
108
+ // Booleans may arrive as rendered template strings ("true"/"false").
109
+ export function requestedEnabledStates(node, testIds) {
110
+ const value = node?.expect_enabled;
111
+ if (value == null) return {};
112
+ if (typeof value !== 'object' || Array.isArray(value)) {
113
+ throw new Error('ui.locators expect_enabled must map requested test IDs to true or false.');
114
+ }
115
+ const states = Object.create(null);
116
+ for (const [testId, raw] of Object.entries(value)) {
117
+ const enabled = raw === true || raw === 'true' ? true : raw === false || raw === 'false' ? false : null;
118
+ if (!testIds.includes(testId) || enabled === null) {
119
+ throw new Error(`ui.locators expect_enabled must map requested test IDs to true or false; got ${testId}: ${JSON.stringify(raw)}.`);
120
+ }
121
+ states[testId] = enabled;
122
+ }
123
+ return states;
124
+ }
125
+
126
+ export function requestedTimeoutMs(node) {
127
+ const value = node?.timeout_ms;
128
+ if (value == null) return 5000;
129
+ if (!Number.isFinite(value) || value < 0) throw new Error('ui.locators timeout_ms must be a non-negative number.');
130
+ return value;
131
+ }
132
+
50
133
  function locatorPriority(locator) {
51
134
  const role = text(locator.role)?.toLowerCase() ?? '';
52
135
  if (INTERACTIVE_ROLE_PARTS.some((part) => role.includes(part))) return 0;
@@ -129,6 +212,7 @@ function supportsTextTarget(role) {
129
212
  function describe(item) {
130
213
  const parts = [text(item.role) ?? 'element'];
131
214
  if (text(item.label)) parts.push(`label=${JSON.stringify(text(item.label))}`);
215
+ if (text(item.descendant_text)) parts.push(`text=${JSON.stringify(text(item.descendant_text))}`);
132
216
  if (text(item.test_id)) parts.push(`test_id=${JSON.stringify(text(item.test_id))}`);
133
217
  return parts.join(' ');
134
218
  }
@@ -572,7 +572,7 @@
572
572
  "execution_capabilities": []
573
573
  },
574
574
  "metamask.perps.read_positions": {
575
- "description": "core Read live Perps positions directly from the Perps controller; without a selector, return all live positions.",
575
+ "description": "core Read live Perps positions directly from the Perps controller; without a selector, return all live positions. Each position reports side (from its signed size) and marginMode (isolated or cross) when the controller exposes it.",
576
576
  "schema": {
577
577
  "type": "object",
578
578
  "properties": {
@@ -1098,6 +1098,11 @@
1098
1098
  "type": "number",
1099
1099
  "default": 3
1100
1100
  },
1101
+ "margin_mode": {
1102
+ "type": "string",
1103
+ "enum": ["isolated", "cross"],
1104
+ "description": "Collateral mode for this order (Hyperliquid). The action proves the opened position or, for a resting order, the venue lock carries it and fails otherwise. Mainnet refuses anything but isolated. Omitted keeps the venue default: the open position mode, else isolated."
1105
+ },
1101
1106
  "close_immediately": {
1102
1107
  "type": "boolean",
1103
1108
  "description": "Close the opened position again inside this action and prove the market flat (market orders only; the market must start flat). Required on mainnet, with leverage 1 and notional at most $15 from a fixture account."
@@ -1701,6 +1706,11 @@
1701
1706
  ],
1702
1707
  "description": "USD notional alias."
1703
1708
  },
1709
+ "margin_mode": {
1710
+ "type": "string",
1711
+ "enum": ["isolated", "cross"],
1712
+ "description": "Collateral mode for a created position. A reused open position must already hold it, since the venue cannot change it while open. Applies only to state=open."
1713
+ },
1704
1714
  "leverage": {
1705
1715
  "type": "number"
1706
1716
  },
@@ -2188,6 +2198,11 @@
2188
2198
  "type": "number",
2189
2199
  "description": "Leverage used only when creating a missing open order."
2190
2200
  },
2201
+ "margin_mode": {
2202
+ "type": "string",
2203
+ "enum": ["isolated", "cross"],
2204
+ "description": "Collateral mode for a created resting order. Also proves the venue lock binds this mode on every selected market, including orders that already rested; the result lists each lock in marginModeLocks. Applies only to state=open."
2205
+ },
2191
2206
  "state": {
2192
2207
  "type": "string",
2193
2208
  "enum": [
@@ -2347,6 +2362,11 @@
2347
2362
  "leverage": {
2348
2363
  "type": "number"
2349
2364
  },
2365
+ "margin_mode": {
2366
+ "type": "string",
2367
+ "enum": ["isolated", "cross"],
2368
+ "description": "Collateral mode passed to the ensure_* step that creates this state (isolated or cross); see metamask.perps.ensure_positions / ensure_orders."
2369
+ },
2350
2370
  "order_type": {
2351
2371
  "type": "string"
2352
2372
  },
@@ -2458,6 +2478,11 @@
2458
2478
  "leverage": {
2459
2479
  "type": "number"
2460
2480
  },
2481
+ "margin_mode": {
2482
+ "type": "string",
2483
+ "enum": ["isolated", "cross"],
2484
+ "description": "Collateral mode passed to the ensure_* step that creates this state (isolated or cross); see metamask.perps.ensure_positions / ensure_orders."
2485
+ },
2461
2486
  "order_type": {
2462
2487
  "type": "string"
2463
2488
  },
@@ -1005,7 +1005,22 @@
1005
1005
  "execution_capabilities": ["host-read-export"],
1006
1006
  "schema": {
1007
1007
  "type": "object",
1008
- "properties": {},
1008
+ "properties": {
1009
+ "test_ids": {
1010
+ "type": "array",
1011
+ "items": { "type": "string" },
1012
+ "description": "Also report byTestId: the role, enabled state and description of each visible element with these test IDs, including disabled and non-interactive ones (enabled is null when unreported). Absent IDs are listed in missingTestIds, or in unobservedTestIds when the accessibility observation itself was truncated (not the 20-entry ranked list) and cannot prove absence. Prove presence from byTestId, not from an empty missingTestIds. Use it to assert control state; locators lists only press targets."
1013
+ },
1014
+ "expect_enabled": {
1015
+ "type": "object",
1016
+ "description": "Map of requested test IDs to the enabled state (true or false) to wait for, for controls that stay disabled until their data loads. The last read is returned either way; assert on byTestId."
1017
+ },
1018
+ "timeout_ms": {
1019
+ "type": "number",
1020
+ "minimum": 0,
1021
+ "description": "With test_ids, re-read the observation until every requested test ID is present (and matches expect_enabled) or this many milliseconds pass (default 5000)."
1022
+ }
1023
+ },
1009
1024
  "additionalProperties": false
1010
1025
  },
1011
1026
  "examples": [
@@ -2363,7 +2378,7 @@
2363
2378
  "execution_capabilities": ["app-mutation"]
2364
2379
  },
2365
2380
  "metamask.perps.read_positions": {
2366
- "description": "mobile Read live Perps positions; without a selector, return all live positions.",
2381
+ "description": "mobile Read live Perps positions; without a selector, return all live positions. Each position reports side (from its signed size) and marginMode (isolated or cross) when the product exposes it.",
2367
2382
  "schema": {
2368
2383
  "type": "object",
2369
2384
  "properties": {
@@ -2840,6 +2855,11 @@
2840
2855
  "enum": ["market", "limit"],
2841
2856
  "default": "market"
2842
2857
  },
2858
+ "margin_mode": {
2859
+ "type": "string",
2860
+ "enum": ["isolated", "cross"],
2861
+ "description": "Collateral mode for this order (Hyperliquid; limit orders need a build with getMarginModeLock). The action proves the opened position or the venue lock carries it and fails otherwise. Omitted keeps the venue default: the open position mode, else isolated."
2862
+ },
2843
2863
  "limit_price": {
2844
2864
  "type": ["string", "number"],
2845
2865
  "description": "Limit price; omit it to offset from the current market price by offset_pct."
@@ -3128,6 +3148,11 @@
3128
3148
  "type": ["string", "number"],
3129
3149
  "description": "Alias for amount."
3130
3150
  },
3151
+ "margin_mode": {
3152
+ "type": "string",
3153
+ "enum": ["isolated", "cross"],
3154
+ "description": "Collateral mode for a created position. Also proves every selected open position carries it; an existing position in another mode fails, since the venue cannot change it while open."
3155
+ },
3131
3156
  "state": {
3132
3157
  "type": "string",
3133
3158
  "enum": ["none", "absent", "closed", "open", "present"],
@@ -3249,6 +3274,11 @@
3249
3274
  "default": 3,
3250
3275
  "description": "Testnet leverage used only when creating a missing open order."
3251
3276
  },
3277
+ "margin_mode": {
3278
+ "type": "string",
3279
+ "enum": ["isolated", "cross"],
3280
+ "description": "Collateral mode for a created resting order. Also proves the venue lock binds this mode on every selected market, including orders that already rested; the result lists each lock in marginModeLocks, and marginModeLock is the first market's. Requires a build with getMarginModeLock."
3281
+ },
3252
3282
  "state": {
3253
3283
  "type": "string",
3254
3284
  "enum": ["none", "absent", "closed", "open", "present"],
@@ -3556,7 +3586,7 @@
3556
3586
  }
3557
3587
  },
3558
3588
  "page": {
3559
- "description": "Initial Perps page target; omit it to use the profile or market default, or set false without market/symbol to preserve the current route.",
3589
+ "description": "Initial Perps page target; omit it to use the profile or market default, or set false to preserve the current route (market/symbol then only scope the trading state). Without navigation, Perps must already have been opened in this app session.",
3560
3590
  "type": ["string", "boolean"],
3561
3591
  "enum": [false, "home", "perps", "perps_home", "market", "market_details"]
3562
3592
  },
@@ -3616,6 +3646,11 @@
3616
3646
  "leverage": {
3617
3647
  "type": "number"
3618
3648
  },
3649
+ "margin_mode": {
3650
+ "type": "string",
3651
+ "enum": ["isolated", "cross"],
3652
+ "description": "Collateral mode passed to the ensure_* step that creates this state (isolated or cross); see metamask.perps.ensure_positions / ensure_orders."
3653
+ },
3619
3654
  "order_type": {
3620
3655
  "type": "string"
3621
3656
  },
@@ -3704,6 +3739,11 @@
3704
3739
  "leverage": {
3705
3740
  "type": "number"
3706
3741
  },
3742
+ "margin_mode": {
3743
+ "type": "string",
3744
+ "enum": ["isolated", "cross"],
3745
+ "description": "Collateral mode passed to the ensure_* step that creates this state (isolated or cross); see metamask.perps.ensure_positions / ensure_orders."
3746
+ },
3707
3747
  "order_type": {
3708
3748
  "type": "string"
3709
3749
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.92.39",
3
+ "version": "0.94.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"