@deeeed/metamask-harness 0.86.2 → 0.88.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -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/action-inputs.js +20 -0
- package/dist/adapters/mobile/actions.js +18 -1
- package/dist/adapters/mobile/android-screenrecord.js +274 -0
- package/dist/adapters/mobile/readiness.js +2 -1
- package/dist/adapters/mobile/recording-outcome.js +54 -0
- package/dist/adapters/mobile/recording-target.js +11 -0
- package/dist/adapters/mobile/run.js +58 -15
- package/dist/adapters/mobile/ui.js +96 -2
- package/dist/adapters/perps-mainnet-cleanup.js +122 -0
- package/dist/adapters/perps-order-inputs.js +43 -28
- package/dist/adapters.js +4 -1
- package/dist/commands/doctor.js +33 -14
- package/dist/commands/status-probe.js +2 -1
- 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 +96 -9
- package/docs/RELEASE-QA-CAPABILITY-MAP.md +5 -1
- package/docs/cli-contract.md +1 -1
- package/library/actions/core/perps/read_account.mjs +4 -2
- package/library/actions/core/perps/read_orders.mjs +4 -2
- package/library/actions/core/perps/read_positions.mjs +4 -2
- package/library/actions/extension/analytics/consent.mjs +7 -2
- package/library/actions/extension/perps/compare_provider_market.mjs +12 -8
- package/library/actions/extension/perps/mutation-receipt.mjs +124 -26
- package/library/actions/extension/perps/perps.mjs +298 -39
- package/library/actions/extension/perps/read_snapshot.mjs +6 -2
- package/library/actions/extension/perps/set_network.mjs +6 -0
- package/library/actions/extension/platform/cdp.mjs +6 -2
- package/library/actions/extension/ui/navigate.mjs +8 -0
- package/library/actions/mobile/perps/perps.mjs +264 -102
- package/library/actions/mobile/perps/place_order.mjs +7 -4
- package/library/actions/mobile/platform/bridge.mjs +171 -10
- package/library/actions/mobile/wallet/home.mjs +7 -2
- package/library/actions/mobile/wallet/select_account.mjs +4 -0
- package/library/actions/shared/app/live-adapter-deadline.mjs +8 -0
- 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/perps/rate-limit.mjs +93 -0
- package/library/actions/shared/perps/visible-state.mjs +3 -2
- package/library/actions/shared/wallet/fixture-accounts.mjs +9 -3
- package/library/manifests/extension.action-manifest.json +36 -5
- package/library/manifests/mobile.action-manifest.json +48 -10
- package/package.json +14 -9
- package/scripts/validate-release-report.mjs +14 -2
- package/site/assets/help-recipes.json +2 -1
|
@@ -4,11 +4,13 @@ import { pathToFileURL } from 'node:url';
|
|
|
4
4
|
import {
|
|
5
5
|
MOBILE_BRIDGE_RESULT_ERROR_CODES,
|
|
6
6
|
bridgeCommand,
|
|
7
|
+
createSetupBudget,
|
|
7
8
|
deadlineFromTimeout,
|
|
8
9
|
earliestDeadline,
|
|
9
10
|
evalAsync,
|
|
10
11
|
inheritedDeadline,
|
|
11
|
-
|
|
12
|
+
isUnansweredRead,
|
|
13
|
+
mobileNavigationTimeoutMs,
|
|
12
14
|
navigate,
|
|
13
15
|
routeSatisfiesNavigationTarget,
|
|
14
16
|
runAdapter,
|
|
@@ -25,6 +27,17 @@ import {
|
|
|
25
27
|
requireHouseNotional,
|
|
26
28
|
requirePerpsOrderAllowed,
|
|
27
29
|
} from '../../shared/perps/order-policy.mjs';
|
|
30
|
+
import {
|
|
31
|
+
dropMainnetOrder,
|
|
32
|
+
ledgerOrderTargets,
|
|
33
|
+
markMainnetOrderCancelled,
|
|
34
|
+
ownedMainnetOrder,
|
|
35
|
+
readMainnetOrderLedger,
|
|
36
|
+
recordPendingMainnetOrder,
|
|
37
|
+
recordPlacedMainnetOrder,
|
|
38
|
+
requireNoOpenMainnetOrders,
|
|
39
|
+
} from '../../shared/perps/mainnet-order-ledger.mjs';
|
|
40
|
+
import { isRateLimited, rateLimitedError, withRateLimitRetry } from '../../shared/perps/rate-limit.mjs';
|
|
28
41
|
|
|
29
42
|
function sleep(ms) {
|
|
30
43
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -169,7 +182,7 @@ async function readModeState(input, mode, deadline) {
|
|
|
169
182
|
try {
|
|
170
183
|
return await queryModeState(input, mode, deadline);
|
|
171
184
|
} catch (error) {
|
|
172
|
-
if (!
|
|
185
|
+
if (!isUnansweredRead(error)) throw error;
|
|
173
186
|
return null;
|
|
174
187
|
}
|
|
175
188
|
}
|
|
@@ -237,6 +250,8 @@ export async function ensureMode(input) {
|
|
|
237
250
|
throw new Error(`Perps did not reach ${requestedMode} mode before timeout.`);
|
|
238
251
|
}
|
|
239
252
|
|
|
253
|
+
// Retries the client-not-initialized race until the deadline, and a 429 with
|
|
254
|
+
// the shared rate-limit backoff.
|
|
240
255
|
export async function retryPerpsClientRead(
|
|
241
256
|
read,
|
|
242
257
|
{
|
|
@@ -244,6 +259,8 @@ export async function retryPerpsClientRead(
|
|
|
244
259
|
intervalMs = 500,
|
|
245
260
|
sleep: wait = sleep,
|
|
246
261
|
deadlineEpochMs,
|
|
262
|
+
call = 'PerpsController read',
|
|
263
|
+
random,
|
|
247
264
|
} = {},
|
|
248
265
|
) {
|
|
249
266
|
const localDeadline = Date.now() + Math.max(0, timeoutMs);
|
|
@@ -254,7 +271,7 @@ export async function retryPerpsClientRead(
|
|
|
254
271
|
: Math.min(localDeadline, configuredDeadline);
|
|
255
272
|
while (true) {
|
|
256
273
|
try {
|
|
257
|
-
return await read
|
|
274
|
+
return await withRateLimitRetry(call, read, { deadline, sleep: wait, random });
|
|
258
275
|
} catch (error) {
|
|
259
276
|
if (!/CLIENT_NOT_INITIALIZED/u.test(String(error?.message ?? error))) {
|
|
260
277
|
throw error;
|
|
@@ -386,7 +403,7 @@ async function readPositions(input) {
|
|
|
386
403
|
}
|
|
387
404
|
return positions;
|
|
388
405
|
},
|
|
389
|
-
{ deadlineEpochMs: inheritedDeadline(input) },
|
|
406
|
+
{ deadlineEpochMs: inheritedDeadline(input), call: 'PerpsController.getPositions' },
|
|
390
407
|
);
|
|
391
408
|
}
|
|
392
409
|
|
|
@@ -413,7 +430,7 @@ async function readOpenOrders(input) {
|
|
|
413
430
|
}
|
|
414
431
|
return orders;
|
|
415
432
|
},
|
|
416
|
-
{ deadlineEpochMs: inheritedDeadline(input) },
|
|
433
|
+
{ deadlineEpochMs: inheritedDeadline(input), call: 'PerpsController.getOpenOrders' },
|
|
417
434
|
);
|
|
418
435
|
}
|
|
419
436
|
|
|
@@ -445,7 +462,7 @@ async function readStrategyOrders(input) {
|
|
|
445
462
|
}
|
|
446
463
|
return orders;
|
|
447
464
|
},
|
|
448
|
-
{ deadlineEpochMs: inheritedDeadline(input) },
|
|
465
|
+
{ deadlineEpochMs: inheritedDeadline(input), call: 'PerpsController.getTwapOrders/getChaseOrders' },
|
|
449
466
|
);
|
|
450
467
|
}
|
|
451
468
|
|
|
@@ -903,7 +920,7 @@ async function readCurrentRoute(input) {
|
|
|
903
920
|
try {
|
|
904
921
|
return await bridgeCommand(input, ['get-route']);
|
|
905
922
|
} catch (error) {
|
|
906
|
-
if (!
|
|
923
|
+
if (!isUnansweredRead(error)) throw error;
|
|
907
924
|
return null;
|
|
908
925
|
}
|
|
909
926
|
}
|
|
@@ -961,8 +978,7 @@ async function waitForPerpsUiTarget(input, testId, deadline) {
|
|
|
961
978
|
}
|
|
962
979
|
|
|
963
980
|
async function selectPerpsMarketFromHome(input, symbol, previousRoute) {
|
|
964
|
-
const
|
|
965
|
-
const deadline = deadlineFromTimeout(input, timeoutMs);
|
|
981
|
+
const deadline = deadlineFromTimeout(input, mobileNavigationTimeoutMs(input));
|
|
966
982
|
const params = { market: { symbol } };
|
|
967
983
|
const rowTestId = `perps-market-row-item-${symbol}`;
|
|
968
984
|
const recentlyViewedTestId = `perps-recently-viewed-tile-${symbol}`;
|
|
@@ -1045,8 +1061,7 @@ async function waitForRouteChange(input, previousRoute, deadline) {
|
|
|
1045
1061
|
}
|
|
1046
1062
|
|
|
1047
1063
|
async function navigateFromMarketDetailsToHome(input, previousRoute) {
|
|
1048
|
-
const
|
|
1049
|
-
const deadline = deadlineFromTimeout(input, timeoutMs);
|
|
1064
|
+
const deadline = deadlineFromTimeout(input, mobileNavigationTimeoutMs(input));
|
|
1050
1065
|
let visibleTestIds = await readVisiblePerpsTestIds(input, deadline);
|
|
1051
1066
|
let backTestId = PERPS_MARKET_DETAILS_BACK_TEST_IDS.find((testId) =>
|
|
1052
1067
|
visibleTestIds.has(testId),
|
|
@@ -1349,6 +1364,7 @@ export async function closeOrders(input) {
|
|
|
1349
1364
|
if (invalid) {
|
|
1350
1365
|
throw new Error(`Cannot cancel selected Perps order without orderId and symbol: ${JSON.stringify(invalid)}`);
|
|
1351
1366
|
}
|
|
1367
|
+
const owned = await mainnetOrdersOwned(input, orders);
|
|
1352
1368
|
const result = await evalAsync(
|
|
1353
1369
|
input,
|
|
1354
1370
|
`(function(){
|
|
@@ -1380,10 +1396,105 @@ export async function closeOrders(input) {
|
|
|
1380
1396
|
const after = await waitForOrdersAbsent(input, symbols, Number(input.node?.timeout_ms ?? 30000));
|
|
1381
1397
|
const stillOpen = after.filter((order) => symbols.includes(symbolForItem(order)));
|
|
1382
1398
|
if (stillOpen.length > 0) throw new Error(`Expected selected Perps orders to cancel, but ${stillOpen.length} are still open.`);
|
|
1399
|
+
// The run's teardown settles them once the market's position is checked too.
|
|
1400
|
+
// Only a cancel the controller accepted: anything else stays for the teardown to cancel.
|
|
1401
|
+
for (const entry of owned ?? []) {
|
|
1402
|
+
const canceled = (result?.results ?? []).filter((item) => item.orderId === entry.orderId && item.success);
|
|
1403
|
+
if (canceled.length > 0) await markMainnetOrderCancelled(input.context.projectRoot, entry.id, { via: input.action, canceled });
|
|
1404
|
+
}
|
|
1383
1405
|
return { action: input.action, canceled: true, matchingCount: matching.length, symbols, result, proofPath: 'mobile-perps-controller-cancel-order' };
|
|
1384
1406
|
}
|
|
1385
1407
|
|
|
1386
|
-
|
|
1408
|
+
// The order book fields the mainnet order ledger matches on.
|
|
1409
|
+
const LEDGER_ORDER_FIELDS = {
|
|
1410
|
+
idOf: orderIdForItem,
|
|
1411
|
+
symbolOf: symbolForItem,
|
|
1412
|
+
sideOf: sideForItem,
|
|
1413
|
+
priceOf: (order) => order?.price ?? order?.limitPrice ?? order?.limitPx ?? order?.px,
|
|
1414
|
+
};
|
|
1415
|
+
|
|
1416
|
+
// The signed size held in a market (0 when flat): a fill changes it.
|
|
1417
|
+
function positionSizeIn(positions, symbol) {
|
|
1418
|
+
return positions
|
|
1419
|
+
.filter((position) => symbolForItem(position) === symbol)
|
|
1420
|
+
.reduce((total, position) => total + Number(position.size ?? position.szi ?? 0), 0);
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
// The run's forced cancel of one mainnet limit order it opened (see
|
|
1424
|
+
// mainnet-order-ledger.mjs), from the run's process: on the account that placed
|
|
1425
|
+
// it, still on mainnet, cancel it and read the book until it is gone. The read
|
|
1426
|
+
// also carries the market's position, which the caller compares with the one
|
|
1427
|
+
// before the order (a fill is reported, never closed here).
|
|
1428
|
+
export async function cancelMainnetLedgerOrder(entry, { projectRoot, env = {} }) {
|
|
1429
|
+
const input = {
|
|
1430
|
+
action: 'metamask.perps.close_orders',
|
|
1431
|
+
node: { controller_ready_timeout_ms: 15_000 },
|
|
1432
|
+
context: { nodeId: 'mainnet-order-cleanup', projectRoot, artifactsDir: entry.artifactsDir, env: { ...env, ...entry.connection } },
|
|
1433
|
+
};
|
|
1434
|
+
const bound = { address: entry.address, network: 'mainnet' };
|
|
1435
|
+
// The placing provider too: another provider's book would read as empty.
|
|
1436
|
+
const providerGuard = `if (String(Engine.context.PerpsController.state.activeProvider || '').toLowerCase() !== ${sourceLiteral(entry.provider)}) {
|
|
1437
|
+
return Promise.resolve(JSON.stringify({ contextChanged: true }));
|
|
1438
|
+
}`;
|
|
1439
|
+
const readBook = async () => {
|
|
1440
|
+
const book = await evalAsync(input, `(function(){ ${boundContextGuard(bound)} ${providerGuard}
|
|
1441
|
+
var c = Engine.context.PerpsController;
|
|
1442
|
+
return Promise.all([c.getOpenOrders(), c.getPositions()]).then(function(r){ return JSON.stringify({ orders: r[0], positions: r[1] }); }); })()`);
|
|
1443
|
+
if (book?.contextChanged) {
|
|
1444
|
+
throw new Error(`the app is not on mainnet ${entry.provider} with the placing account ${entry.address} selected`);
|
|
1445
|
+
}
|
|
1446
|
+
if (!Array.isArray(book?.orders) || !Array.isArray(book?.positions)) throw new Error('PerpsController returned a non-array book.');
|
|
1447
|
+
return book;
|
|
1448
|
+
};
|
|
1449
|
+
const bookRead = (book, outcome) => ({
|
|
1450
|
+
source: 'PerpsController.getOpenOrders+getPositions',
|
|
1451
|
+
openOrderIds: book.orders.map(orderIdForItem).map(String),
|
|
1452
|
+
positionSize: positionSizeIn(book.positions, entry.symbol),
|
|
1453
|
+
...outcome,
|
|
1454
|
+
});
|
|
1455
|
+
const before = await readBook();
|
|
1456
|
+
const targets = ledgerOrderTargets(entry, before.orders, LEDGER_ORDER_FIELDS);
|
|
1457
|
+
if (targets.length === 0) return { canceled: [], read: bookRead(before, { notOpen: true }) };
|
|
1458
|
+
const orders = targets.map((order) => ({ orderId: String(orderIdForItem(order)), symbol: entry.symbol }));
|
|
1459
|
+
const canceled = await evalAsync(input, `(function(){ ${boundContextGuard(bound)} ${providerGuard}
|
|
1460
|
+
var c = Engine.context.PerpsController;
|
|
1461
|
+
return Promise.all(${sourceLiteral(orders)}.map(function(o){
|
|
1462
|
+
return c.cancelOrder(o).then(function(r){ return { orderId: o.orderId, success: !r || r.success !== false, result: r }; });
|
|
1463
|
+
})).then(function(r){ return JSON.stringify(r); }); })()`);
|
|
1464
|
+
if (canceled?.contextChanged) throw new Error(`the app left mainnet, ${entry.provider} or account ${entry.address} before the cancel`);
|
|
1465
|
+
if (!Array.isArray(canceled) || canceled.some((item) => !item.success)) throw new Error(`cancelOrder failed: ${JSON.stringify(canceled)}`);
|
|
1466
|
+
const deadline = Date.now() + 30_000;
|
|
1467
|
+
for (;;) {
|
|
1468
|
+
const after = await readBook();
|
|
1469
|
+
const open = after.orders.filter((order) => orders.some((target) => String(orderIdForItem(order)) === target.orderId));
|
|
1470
|
+
if (open.length === 0) return { canceled, read: bookRead(after, { absent: true }) };
|
|
1471
|
+
if (Date.now() >= deadline) throw new Error(`order(s) ${open.map(orderIdForItem).join(', ')} still open 30 s after the cancel: ${JSON.stringify(canceled)}`);
|
|
1472
|
+
await sleep(1000);
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
// On mainnet a cancel may only take orders this checkout's runs placed (the
|
|
1477
|
+
// mainnet order ledger); anything else is refused before dispatch. Returns the
|
|
1478
|
+
// ledger entries on mainnet, null on testnet.
|
|
1479
|
+
async function mainnetOrdersOwned(input, orders) {
|
|
1480
|
+
const runtime = await readPerpsRuntimeState(input);
|
|
1481
|
+
if (runtime?.isTestnet !== false) return null;
|
|
1482
|
+
const owned = await Promise.all(orders.map((order) => ownedMainnetOrder(input.context?.projectRoot, order.orderId)));
|
|
1483
|
+
const foreign = orders.filter((_order, index) => !owned[index]);
|
|
1484
|
+
if (foreign.length > 0) {
|
|
1485
|
+
throw Object.assign(
|
|
1486
|
+
new Error(
|
|
1487
|
+
`PERPS_MAINNET_ORDER_REFUSED: ${input.action} on mainnet cancels only orders this run placed; ` +
|
|
1488
|
+
`${foreign.map((order) => `${order.symbol} ${order.orderId}`).join(', ')} ${foreign.length === 1 ? 'is' : 'are'} not. Refused before dispatch.`,
|
|
1489
|
+
),
|
|
1490
|
+
{ code: 'PERPS_MAINNET_ORDER_REFUSED' },
|
|
1491
|
+
);
|
|
1492
|
+
}
|
|
1493
|
+
return owned;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
// ensure: an ensure_* action opening state; it never leaves a mainnet limit order.
|
|
1497
|
+
export async function placeOrder(input, { ensure = false } = {}) {
|
|
1387
1498
|
requirePlaceOrderInputs(input);
|
|
1388
1499
|
const symbol = marketSymbol(input);
|
|
1389
1500
|
const side = String(input.node.side).toLowerCase();
|
|
@@ -1403,17 +1514,27 @@ export async function placeOrder(input) {
|
|
|
1403
1514
|
leverage,
|
|
1404
1515
|
orderType,
|
|
1405
1516
|
closeImmediately,
|
|
1517
|
+
runCancelsLimit: !ensure,
|
|
1406
1518
|
projectRoot: input.context?.projectRoot,
|
|
1407
1519
|
selectedAddress: bound.address,
|
|
1408
1520
|
});
|
|
1521
|
+
const mainnetLimit = network === 'mainnet' && orderType === 'limit';
|
|
1522
|
+
// A mainnet limit is also bound to the provider it is recorded under.
|
|
1523
|
+
if (mainnetLimit) bound.provider = String(runtime?.activeProvider ?? '').toLowerCase();
|
|
1409
1524
|
if ((closeImmediately || network === 'mainnet') && input.node?.size != null) {
|
|
1410
1525
|
throw new Error('close_immediately and mainnet orders are sized from amount; drop size.');
|
|
1411
1526
|
}
|
|
1527
|
+
if (network === 'mainnet' && hasNodeValue(input, 'current_price', 'currentPrice')) {
|
|
1528
|
+
throw new Error('Mainnet orders are bounded at the live market price; drop current_price.');
|
|
1529
|
+
}
|
|
1412
1530
|
if (closeImmediately) {
|
|
1413
1531
|
const book = await readBoundBook(input, bound);
|
|
1414
1532
|
assertFlatBeforeRoundTrip(book.positions, symbol, symbolForItem, book.orders);
|
|
1415
1533
|
}
|
|
1416
1534
|
const currentPrice = await currentPriceForOrder(input, symbol);
|
|
1535
|
+
const limitPrice = orderType === 'limit'
|
|
1536
|
+
? resolveLimitPrice(input, isBuy, currentPrice)
|
|
1537
|
+
: null;
|
|
1417
1538
|
const houseNotional = requireHouseNotional({
|
|
1418
1539
|
network,
|
|
1419
1540
|
symbol,
|
|
@@ -1422,10 +1543,8 @@ export async function placeOrder(input) {
|
|
|
1422
1543
|
price: currentPrice,
|
|
1423
1544
|
szDecimals: network === 'mainnet' ? await readSizeDecimals(input, symbol) : null,
|
|
1424
1545
|
maxSlippageBps,
|
|
1546
|
+
limit: orderType === 'limit' ? { price: limitPrice, isBuy } : null,
|
|
1425
1547
|
});
|
|
1426
|
-
const limitPrice = orderType === 'limit'
|
|
1427
|
-
? resolveLimitPrice(input, isBuy, currentPrice)
|
|
1428
|
-
: null;
|
|
1429
1548
|
const size = String(
|
|
1430
1549
|
input.node?.size
|
|
1431
1550
|
?? ((Number(amount) * leverage) / (limitPrice ?? currentPrice)),
|
|
@@ -1437,7 +1556,8 @@ export async function placeOrder(input) {
|
|
|
1437
1556
|
orderType,
|
|
1438
1557
|
size,
|
|
1439
1558
|
price: String(limitPrice),
|
|
1440
|
-
|
|
1559
|
+
// Post-only on mainnet: the venue rejects a limit that would fill now.
|
|
1560
|
+
timeInForce: mainnetLimit ? 'ALO' : 'GTC',
|
|
1441
1561
|
leverage,
|
|
1442
1562
|
currentPrice,
|
|
1443
1563
|
priceAtCalculation: currentPrice,
|
|
@@ -1457,6 +1577,21 @@ export async function placeOrder(input) {
|
|
|
1457
1577
|
if (await tradingContextChanged(input, bound)) {
|
|
1458
1578
|
throw new Error('Perps order not dispatched: the selected account or network changed after the order rule allowed it.');
|
|
1459
1579
|
}
|
|
1580
|
+
// Tracked before dispatch, so the run cancels it even if this action dies.
|
|
1581
|
+
const baseline = mainnetLimit ? await readBoundBook(input, bound) : null;
|
|
1582
|
+
const ledgerId = mainnetLimit
|
|
1583
|
+
? await recordPendingMainnetOrder(input.context.projectRoot, {
|
|
1584
|
+
platform: 'mobile',
|
|
1585
|
+
provider: bound.provider,
|
|
1586
|
+
symbol,
|
|
1587
|
+
side: isBuy ? 'long' : 'short',
|
|
1588
|
+
limitPrice,
|
|
1589
|
+
address: bound.address,
|
|
1590
|
+
baselineOrderIds: baseline.orders.map(orderIdForItem).filter(Boolean),
|
|
1591
|
+
artifactsDir: input.context.artifactsDir,
|
|
1592
|
+
connection: input.context.env ?? {},
|
|
1593
|
+
})
|
|
1594
|
+
: null;
|
|
1460
1595
|
let result;
|
|
1461
1596
|
try {
|
|
1462
1597
|
result = await evalAsync(input, `(function(){ ${boundContextGuard(bound)}
|
|
@@ -1468,18 +1603,31 @@ export async function placeOrder(input) {
|
|
|
1468
1603
|
);
|
|
1469
1604
|
}
|
|
1470
1605
|
if (result?.success === false || result == null) {
|
|
1471
|
-
|
|
1606
|
+
const restNote = ledgerId ? "; the run's teardown cancels it if it rests" : '';
|
|
1607
|
+
// Mobile keeps no placement receipt, so a 429 is never retried: nothing proves the venue saw no order.
|
|
1608
|
+
if (isRateLimited(result?.error)) {
|
|
1609
|
+
throw rateLimitedError({
|
|
1610
|
+
call: 'PerpsController.placeOrder',
|
|
1611
|
+
attempts: 1,
|
|
1612
|
+
waitedMs: 0,
|
|
1613
|
+
reason: `no retry, since nothing proves the ${symbol} order was not placed${restNote}`,
|
|
1614
|
+
next: `read the ${symbol} positions and orders, then wait a minute and rerun only if nothing was placed`,
|
|
1615
|
+
});
|
|
1616
|
+
}
|
|
1617
|
+
throw new Error(`Failed to place ${symbol} ${side}: ${result?.error || JSON.stringify(result)}${restNote}`);
|
|
1472
1618
|
}
|
|
1473
1619
|
if (orderType === 'limit') {
|
|
1474
1620
|
await waitForSelectedState(input, readOpenOrders, true, Number(input.node?.timeout_ms ?? 30000));
|
|
1621
|
+
if (ledgerId) await recordPlacedMainnetOrder(input.context.projectRoot, ledgerId, await placedMainnetOrderId(input, bound, ledgerId, result));
|
|
1475
1622
|
} else {
|
|
1476
1623
|
await waitForPositionPresent(input, symbol, Number(input.node?.timeout_ms ?? 30000));
|
|
1477
1624
|
}
|
|
1478
1625
|
} catch (error) {
|
|
1626
|
+
if (ledgerId && error?.knownNotDispatched) await dropMainnetOrder(input.context.projectRoot, ledgerId);
|
|
1479
1627
|
if (!closeImmediately || error?.knownNotDispatched) throw error;
|
|
1480
1628
|
throw await closeAfterFailedPlacement(input, symbol, error, bound);
|
|
1481
1629
|
}
|
|
1482
|
-
const placed = { action: input.action, market: symbol, side, orderType, amount, size, leverage, limitPrice, submitted: true, result, network, policy: policy.lane, ...(houseNotional ? { houseNotional } : {}), proofPath: 'mobile-perps-controller-place-order' };
|
|
1630
|
+
const placed = { action: input.action, market: symbol, side, orderType, amount, size, leverage, limitPrice, submitted: true, result, network, policy: policy.lane, ...(policy.autoCancel ? { autoCancel: policy.autoCancel } : {}), ...(houseNotional ? { houseNotional } : {}), proofPath: 'mobile-perps-controller-place-order' };
|
|
1483
1631
|
if (!closeImmediately) return placed;
|
|
1484
1632
|
return { ...placed, roundTrip: await closeOpenedPosition(input, symbol, bound) };
|
|
1485
1633
|
}
|
|
@@ -1509,6 +1657,18 @@ async function closeAfterFailedPlacement(input, symbol, error, bound) {
|
|
|
1509
1657
|
return new Error(`${message} close_immediately: ${outcome}.`);
|
|
1510
1658
|
}
|
|
1511
1659
|
|
|
1660
|
+
// The venue ID of the mainnet limit order just placed: the controller's answer,
|
|
1661
|
+
// else the one new order in its market and side since the pending entry.
|
|
1662
|
+
async function placedMainnetOrderId(input, bound, ledgerId, result) {
|
|
1663
|
+
if (result?.orderId != null && String(result.orderId) !== '') return String(result.orderId);
|
|
1664
|
+
const entry = (await readMainnetOrderLedger(input.context.projectRoot)).entries.find((candidate) => candidate.id === ledgerId);
|
|
1665
|
+
const targets = ledgerOrderTargets(entry, (await readBoundBook(input, bound)).orders, LEDGER_ORDER_FIELDS);
|
|
1666
|
+
if (targets.length !== 1) {
|
|
1667
|
+
throw new Error(`Mainnet ${entry.symbol} limit order placed but its ID is unknown (${targets.length} new orders match); the run's teardown cancels it.`);
|
|
1668
|
+
}
|
|
1669
|
+
return String(orderIdForItem(targets[0]));
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1512
1672
|
async function readSizeDecimals(input, symbol) {
|
|
1513
1673
|
const markets = await evalAsync(
|
|
1514
1674
|
input,
|
|
@@ -1540,7 +1700,9 @@ function boundContextGuard(bound) {
|
|
|
1540
1700
|
return `var __account = Engine.context.AccountsController.getSelectedAccount();
|
|
1541
1701
|
var __address = String((__account && __account.address) || '').toLowerCase();
|
|
1542
1702
|
var __network = Engine.context.PerpsController.state.isTestnet ? 'testnet' : 'mainnet';
|
|
1543
|
-
|
|
1703
|
+
var __provider = String(Engine.context.PerpsController.state.activeProvider || '').toLowerCase();
|
|
1704
|
+
if (__address !== ${sourceLiteral(bound.address)} || __network !== ${sourceLiteral(bound.network)}
|
|
1705
|
+
|| (${sourceLiteral(bound.provider ?? null)} !== null && __provider !== ${sourceLiteral(bound.provider ?? null)})) {
|
|
1544
1706
|
return Promise.resolve(JSON.stringify({ success: false, contextChanged: true }));
|
|
1545
1707
|
}`;
|
|
1546
1708
|
}
|
|
@@ -1639,7 +1801,7 @@ export async function ensurePositions(input) {
|
|
|
1639
1801
|
}
|
|
1640
1802
|
if (state === 'open' || state === 'present') {
|
|
1641
1803
|
const existing = await readPositions(input);
|
|
1642
|
-
if (selectedItems(input, existing).length === 0) await placeOrder(input);
|
|
1804
|
+
if (selectedItems(input, existing).length === 0) await placeOrder(input, { ensure: true });
|
|
1643
1805
|
return assertPositions(input, true);
|
|
1644
1806
|
}
|
|
1645
1807
|
throw new Error(`metamask.perps.ensure_positions received unsupported state: ${state}`);
|
|
@@ -1657,7 +1819,7 @@ export async function ensureOrders(input) {
|
|
|
1657
1819
|
const current = await readOpenOrders(input);
|
|
1658
1820
|
let order = null;
|
|
1659
1821
|
if (selectedItems(input, current).length === 0) {
|
|
1660
|
-
order = await placeOrder({ ...input, node: { ...input.node, order_type: 'limit' } });
|
|
1822
|
+
order = await placeOrder({ ...input, node: { ...input.node, order_type: 'limit' } }, { ensure: true });
|
|
1661
1823
|
}
|
|
1662
1824
|
return { ...(await assertOrders(input, true)), order };
|
|
1663
1825
|
}
|
|
@@ -1732,9 +1894,20 @@ function mergeStateConfig(defaults, params) {
|
|
|
1732
1894
|
};
|
|
1733
1895
|
}
|
|
1734
1896
|
|
|
1735
|
-
|
|
1897
|
+
// The policy owns positions, orders and strategies. An explicit request for one
|
|
1898
|
+
// of them would be dropped, so it fails before any phase runs.
|
|
1899
|
+
export function applyTradingStatePolicy(config, requested) {
|
|
1736
1900
|
const policy = config.trading_state;
|
|
1737
1901
|
if (policy === undefined) return config;
|
|
1902
|
+
const dropped = ['positions', 'orders', 'strategies'].filter(
|
|
1903
|
+
(key) => requested[key] !== undefined && requested[key] !== false,
|
|
1904
|
+
);
|
|
1905
|
+
if (dropped.length > 0) {
|
|
1906
|
+
throw new Error(
|
|
1907
|
+
`metamask.perps.start_state trading_state=${String(policy)} sets positions, orders and strategies itself, so the requested ${dropped.join(', ')} would be ignored. ` +
|
|
1908
|
+
`Omit trading_state to apply ${dropped.join(', ')}, or request ${dropped.join(', ')} in a separate metamask.perps.start_state node.`,
|
|
1909
|
+
);
|
|
1910
|
+
}
|
|
1738
1911
|
if (policy === 'preserve') {
|
|
1739
1912
|
return { ...config, positions: false, orders: false, strategies: false };
|
|
1740
1913
|
}
|
|
@@ -1895,6 +2068,7 @@ async function ensureNetwork(input, config) {
|
|
|
1895
2068
|
if (Boolean(before.isTestnet) === expectedTestnet) {
|
|
1896
2069
|
return { requested: config.network, isTestnet: before.isTestnet, changed: false };
|
|
1897
2070
|
}
|
|
2071
|
+
if (expectedTestnet) await requireNoOpenMainnetOrders(input.context?.projectRoot, input.action);
|
|
1898
2072
|
const result = await evalAsync(input, 'Engine.context.PerpsController.toggleTestnet().then(function(r){return JSON.stringify(r)})');
|
|
1899
2073
|
const after = await waitForNetworkState(input, expectedTestnet, Number(input.node?.timeout_ms ?? 30000));
|
|
1900
2074
|
if (Boolean(after.isTestnet) !== expectedTestnet) {
|
|
@@ -1969,16 +2143,15 @@ export async function startState(input) {
|
|
|
1969
2143
|
const params = paramsForState(input);
|
|
1970
2144
|
const config = applyTradingStatePolicy(
|
|
1971
2145
|
mergeStateConfig(profileDefaults(params.profile), params),
|
|
2146
|
+
params,
|
|
1972
2147
|
);
|
|
1973
|
-
const
|
|
1974
|
-
const
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
);
|
|
1981
|
-
}
|
|
2148
|
+
const budget = createSetupBudget(input, 'metamask.perps.start_state');
|
|
2149
|
+
const navigationSlice = { sliceMs: budget.navigationMs };
|
|
2150
|
+
// Product edits require an app restart before proof. Mobile relocks on restart,
|
|
2151
|
+
// so a deterministic Perps start state must restore the fixture-backed wallet
|
|
2152
|
+
// prerequisite before it navigates or converges controller state.
|
|
2153
|
+
const wallet = await budget.phase('unlock', (phaseInput) => {
|
|
2154
|
+
const remainingMs = phaseInput.node.timeout_ms;
|
|
1982
2155
|
const configuredTargetMs = Number(input.node?.target_timeout_ms);
|
|
1983
2156
|
const targetTimeoutMs = Math.min(
|
|
1984
2157
|
remainingMs,
|
|
@@ -1993,75 +2166,78 @@ export async function startState(input) {
|
|
|
1993
2166
|
? configuredUnlockMs
|
|
1994
2167
|
: remainingMs,
|
|
1995
2168
|
);
|
|
1996
|
-
return {
|
|
1997
|
-
...
|
|
2169
|
+
return ensureUnlocked({
|
|
2170
|
+
...phaseInput,
|
|
2171
|
+
action: 'metamask.wallet.ensure_unlocked',
|
|
1998
2172
|
node: {
|
|
1999
|
-
...
|
|
2000
|
-
|
|
2001
|
-
timeout_ms: remainingMs,
|
|
2002
|
-
bridge_timeout_ms: remainingMs,
|
|
2003
|
-
cdp_timeout_ms: remainingMs,
|
|
2004
|
-
controller_ready_timeout_ms: remainingMs,
|
|
2173
|
+
...phaseInput.node,
|
|
2174
|
+
action: 'metamask.wallet.ensure_unlocked',
|
|
2005
2175
|
target_timeout_ms: targetTimeoutMs,
|
|
2006
2176
|
unlock_timeout_ms: unlockTimeoutMs,
|
|
2007
2177
|
},
|
|
2008
|
-
};
|
|
2009
|
-
};
|
|
2010
|
-
// Product edits require an app restart before proof. Mobile relocks on restart,
|
|
2011
|
-
// so a deterministic Perps start state must restore the fixture-backed wallet
|
|
2012
|
-
// prerequisite before it navigates or converges controller state.
|
|
2013
|
-
const walletInput = stateInput();
|
|
2014
|
-
const wallet = await ensureUnlocked({
|
|
2015
|
-
...walletInput,
|
|
2016
|
-
action: 'metamask.wallet.ensure_unlocked',
|
|
2017
|
-
node: { ...walletInput.node, action: 'metamask.wallet.ensure_unlocked' },
|
|
2178
|
+
});
|
|
2018
2179
|
});
|
|
2019
|
-
const accountInput = stateInput();
|
|
2020
2180
|
const account = typeof config.account === 'string' && config.account.trim()
|
|
2021
|
-
? await selectAccount({
|
|
2022
|
-
...
|
|
2181
|
+
? await budget.phase('account', (phaseInput) => selectAccount({
|
|
2182
|
+
...phaseInput,
|
|
2023
2183
|
action: 'metamask.wallet.select_account',
|
|
2024
2184
|
node: {
|
|
2025
|
-
...
|
|
2185
|
+
...phaseInput.node,
|
|
2026
2186
|
action: 'metamask.wallet.select_account',
|
|
2027
2187
|
...(/^0x[0-9a-f]{40}$/iu.test(config.account.trim())
|
|
2028
2188
|
? { address: config.account.trim() }
|
|
2029
2189
|
: { name: config.account.trim() }),
|
|
2030
2190
|
},
|
|
2031
|
-
})
|
|
2191
|
+
}))
|
|
2032
2192
|
: { skipped: true };
|
|
2033
2193
|
// Entering Perps initializes the provider client on a freshly launched app.
|
|
2034
2194
|
// State reads before this navigation fail with CLIENT_NOT_INITIALIZED.
|
|
2035
|
-
const navigation = await
|
|
2036
|
-
|
|
2037
|
-
|
|
2195
|
+
const navigation = await budget.phase(
|
|
2196
|
+
'navigation',
|
|
2197
|
+
(phaseInput) => applyStateNavigation(phaseInput, config),
|
|
2198
|
+
navigationSlice,
|
|
2199
|
+
);
|
|
2200
|
+
const provider = await budget.phase('provider', (phaseInput) =>
|
|
2201
|
+
ensureProvider(phaseInput, config));
|
|
2202
|
+
const network = await budget.phase('network', (phaseInput) =>
|
|
2203
|
+
ensureNetwork(phaseInput, config));
|
|
2038
2204
|
let resolvedNavigation = provider.changed || network.changed
|
|
2039
|
-
? await
|
|
2205
|
+
? await budget.phase(
|
|
2206
|
+
'navigation after provider or network change',
|
|
2207
|
+
(phaseInput) => applyStateNavigation(phaseInput, config),
|
|
2208
|
+
navigationSlice,
|
|
2209
|
+
)
|
|
2040
2210
|
: navigation;
|
|
2041
|
-
const modeInput = stateInput();
|
|
2042
2211
|
const marketMode = config.market_mode
|
|
2043
|
-
? await ensureMode({
|
|
2044
|
-
...
|
|
2212
|
+
? await budget.phase('market mode', (phaseInput) => ensureMode({
|
|
2213
|
+
...phaseInput,
|
|
2045
2214
|
action: 'metamask.perps.ensure_mode',
|
|
2046
2215
|
node: {
|
|
2047
|
-
...
|
|
2216
|
+
...phaseInput.node,
|
|
2048
2217
|
action: 'metamask.perps.ensure_mode',
|
|
2049
2218
|
mode: config.market_mode,
|
|
2050
2219
|
},
|
|
2051
|
-
})
|
|
2220
|
+
}))
|
|
2052
2221
|
: { skipped: true };
|
|
2053
2222
|
if (marketMode.alreadySelected === false) {
|
|
2054
|
-
resolvedNavigation = await
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
config
|
|
2062
|
-
)
|
|
2063
|
-
|
|
2064
|
-
const
|
|
2223
|
+
resolvedNavigation = await budget.phase(
|
|
2224
|
+
'navigation after mode change',
|
|
2225
|
+
(phaseInput) => applyStateNavigation(phaseInput, config),
|
|
2226
|
+
navigationSlice,
|
|
2227
|
+
);
|
|
2228
|
+
}
|
|
2229
|
+
const tutorial = await budget.phase('tutorial', (phaseInput) =>
|
|
2230
|
+
applyTutorialState(phaseInput, config));
|
|
2231
|
+
const readyToTrade = await budget.phase('ready to trade', (phaseInput) =>
|
|
2232
|
+
assertReadyToTrade(phaseInput, config));
|
|
2233
|
+
const balance = await budget.phase('balance', (phaseInput) =>
|
|
2234
|
+
assertBalance(phaseInput, config));
|
|
2235
|
+
const strategies = await budget.phase('strategies', (phaseInput) =>
|
|
2236
|
+
applyStrategiesState(phaseInput, config.strategies));
|
|
2237
|
+
const orders = await budget.phase('orders', (phaseInput) =>
|
|
2238
|
+
applyOrdersState(phaseInput, config.orders));
|
|
2239
|
+
const positions = await budget.phase('positions', (phaseInput) =>
|
|
2240
|
+
applyPositionsState(phaseInput, config.positions));
|
|
2065
2241
|
return {
|
|
2066
2242
|
action: input.action,
|
|
2067
2243
|
profile: config.profile ?? params.profile ?? null,
|
|
@@ -2090,16 +2266,7 @@ export async function startState(input) {
|
|
|
2090
2266
|
}
|
|
2091
2267
|
|
|
2092
2268
|
export async function teardownState(input) {
|
|
2093
|
-
const
|
|
2094
|
-
const deadline = deadlineFromTimeout(input, timeoutMs);
|
|
2095
|
-
const stateInput = (phase) => {
|
|
2096
|
-
if (Date.now() >= deadline) {
|
|
2097
|
-
throw new Error(
|
|
2098
|
-
`metamask.perps.teardown_state timed out before ${phase}.`,
|
|
2099
|
-
);
|
|
2100
|
-
}
|
|
2101
|
-
return withRemainingDeadline(input, deadline);
|
|
2102
|
-
};
|
|
2269
|
+
const budget = createSetupBudget(input, 'metamask.perps.teardown_state');
|
|
2103
2270
|
const params = paramsForState(input);
|
|
2104
2271
|
const defaults = {
|
|
2105
2272
|
provider: 'hyperliquid',
|
|
@@ -2110,28 +2277,23 @@ export async function teardownState(input) {
|
|
|
2110
2277
|
strategies: params.market || params.symbol || params.markets || params.symbols ? { state: 'none', mode: 'matching' } : false,
|
|
2111
2278
|
};
|
|
2112
2279
|
const config = mergeStateConfig(defaults, params);
|
|
2113
|
-
const environment = await
|
|
2114
|
-
|
|
2115
|
-
config,
|
|
2116
|
-
);
|
|
2117
|
-
const strategies = await applyStrategiesState(
|
|
2118
|
-
stateInput('cleaning strategies'),
|
|
2119
|
-
config.strategies,
|
|
2120
|
-
);
|
|
2121
|
-
const orders = await applyOrdersState(
|
|
2122
|
-
stateInput('cleaning orders'),
|
|
2123
|
-
config.orders,
|
|
2124
|
-
);
|
|
2125
|
-
const positions = await applyPositionsState(
|
|
2126
|
-
stateInput('closing positions'),
|
|
2127
|
-
config.positions,
|
|
2280
|
+
const environment = await budget.phase(
|
|
2281
|
+
'checking the requested environment',
|
|
2282
|
+
(phaseInput) => assertRequestedRuntimeState(phaseInput, config),
|
|
2128
2283
|
);
|
|
2284
|
+
const strategies = await budget.phase('cleaning strategies', (phaseInput) =>
|
|
2285
|
+
applyStrategiesState(phaseInput, config.strategies));
|
|
2286
|
+
const orders = await budget.phase('cleaning orders', (phaseInput) =>
|
|
2287
|
+
applyOrdersState(phaseInput, config.orders));
|
|
2288
|
+
const positions = await budget.phase('closing positions', (phaseInput) =>
|
|
2289
|
+
applyPositionsState(phaseInput, config.positions));
|
|
2129
2290
|
const navigation =
|
|
2130
2291
|
config.page === false
|
|
2131
2292
|
? { skipped: true }
|
|
2132
|
-
: await
|
|
2133
|
-
|
|
2134
|
-
config,
|
|
2293
|
+
: await budget.phase(
|
|
2294
|
+
'restoring the requested page',
|
|
2295
|
+
(phaseInput) => applyStateNavigation(phaseInput, config),
|
|
2296
|
+
{ sliceMs: budget.navigationMs },
|
|
2135
2297
|
);
|
|
2136
2298
|
return {
|
|
2137
2299
|
action: input.action,
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
// Submit a Perps order through the Mobile controller and assert
|
|
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
|
});
|