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