@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 +31 -0
- package/library/actions/core/perps/_controller.mjs +3 -1
- package/library/actions/core/perps/ensure_orders.mjs +19 -3
- package/library/actions/core/perps/ensure_positions.mjs +7 -1
- package/library/actions/core/perps/place_order.mjs +23 -0
- package/library/actions/mobile/perps/perps.mjs +80 -10
- package/library/actions/mobile/platform/observe-ui.mjs +17 -2
- package/library/actions/mobile/ui/locators.mjs +15 -5
- package/library/actions/shared/perps/margin-mode.mjs +53 -0
- package/library/actions/shared/perps/order-policy.mjs +3 -1
- package/library/actions/shared/ui/locators.mjs +85 -1
- package/library/manifests/core.action-manifest.json +26 -1
- package/library/manifests/mobile.action-manifest.json +43 -3
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
255
|
-
// the
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2032
|
-
|
|
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
|
-
|
|
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('/>'))
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
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(
|
|
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
|
|
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
|
},
|