@deeeed/metamask-harness 0.80.0 → 0.81.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 +26 -0
- package/adapters/manifest.json +14 -30
- package/adapters/terminal/launch.mjs +17 -13
- package/adapters/terminal/lib/mainnet-guard.mjs +9 -0
- package/adapters/terminal/lib/readiness.mjs +20 -12
- package/adapters/terminal/policy.mjs +35 -0
- package/adapters/terminal/verify.mjs +10 -3
- package/adapters/terminal/wallet-host.mjs +43 -225
- package/dist/adapters/core/actions.js +4 -1
- package/dist/adapters/extension/actions.js +6 -2
- package/dist/adapters/extension/network-observer.js +13 -0
- package/dist/adapters/extension/run.js +112 -5
- package/dist/adapters/mobile/actions.js +4 -1
- package/dist/adapters/mobile/network-observer.js +68 -0
- package/dist/adapters/mobile/performance-observer.js +1 -3
- package/dist/adapters/mobile/run.js +32 -9
- package/dist/adapters/perps-order-inputs.js +36 -0
- package/dist/adapters/proof-failure.js +11 -0
- package/dist/adapters/terminal/actions.js +4 -1
- package/dist/adapters/terminal/doctor-checks.js +2 -0
- package/dist/adapters/terminal/launch.js +3 -0
- package/dist/adapters/terminal/overlay.js +8 -1
- package/dist/adapters/terminal/run.js +8 -3
- package/dist/adapters/terminal/surface.js +2 -0
- package/dist/adapters.js +20 -4
- package/dist/cli.js +31 -6
- package/dist/commands/check.js +30 -6
- package/dist/commands/completion-candidates.js +5 -5
- package/dist/commands/self-test.js +4 -3
- package/dist/engine.js +57 -0
- package/dist/funding-execution-context.js +81 -0
- package/dist/mm-harness-cli.js +3 -3
- package/dist/performance-observation.js +1 -3
- package/dist/recipe-assistance-context.js +6 -7
- package/dist/runner.js +2 -1
- package/docs/CONTRIBUTING.md +4 -2
- package/docs/RECIPES.md +31 -3
- package/library/actions/core/perps/_controller.mjs +24 -28
- package/library/actions/core/perps/_rewards.mjs +186 -0
- package/library/actions/core/perps/close_positions.mjs +1 -1
- package/library/actions/core/perps/place_order.mjs +167 -45
- package/library/actions/core/perps/quote_fees.mjs +7 -4
- package/library/actions/core/wallet/list_accounts.mjs +1 -1
- package/library/actions/extension/perps/close_visible_position.mjs +5 -0
- package/library/actions/extension/perps/edit_margin.mjs +4 -0
- package/library/actions/extension/perps/mutation-receipt.mjs +95 -3
- package/library/actions/extension/perps/perps.mjs +320 -3
- package/library/actions/extension/perps/place_order.mjs +3 -1
- package/library/actions/extension/perps/update_position_tpsl.mjs +4 -0
- package/library/actions/extension/platform/cdp.mjs +71 -3
- package/library/actions/mobile/perps/perps.mjs +216 -11
- package/library/actions/mobile/perps/place_order.mjs +3 -1
- package/library/actions/shared/perps/order-policy.mjs +177 -0
- package/library/actions/{core/wallet/_fixture-accounts.mjs → shared/wallet/fixture-accounts.mjs} +18 -3
- package/library/actions/terminal/app/launch.mjs +3 -2
- package/library/actions/terminal/platform/hud.mjs +2 -1
- package/library/actions/terminal/platform/page.mjs +2 -1
- package/library/actions/terminal/wallet/_signature-log.mjs +40 -161
- package/library/actions/terminal/wallet/assert_signatures.mjs +6 -12
- package/library/actions/terminal/wallet/read_signatures.mjs +10 -9
- package/library/manifests/core.action-manifest.json +6 -2
- package/library/manifests/extension.action-manifest.json +4 -0
- package/library/manifests/mobile.action-manifest.json +12 -0
- package/package.json +4 -4
- package/site/assets/help-recipes.json +1 -0
- package/adapters/terminal/lib/origin.mjs +0 -30
- package/adapters/terminal/lib/page-script.mjs +0 -246
- package/adapters/terminal/lib/strict-wallet.mjs +0 -138
- package/dist/commands/call.js +0 -671
- package/dist/commands/list-executables.js +0 -245
- package/dist/commands/manifest.js +0 -477
- package/dist/commands/run-engine.js +0 -1301
- package/dist/commands/run.js +0 -730
- package/dist/discovery.js +0 -15
- package/dist/metamask-action-validation.js +0 -68
- package/dist/network-observation.js +0 -290
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
7
|
+
import path from 'node:path';
|
|
6
8
|
import { dataTestId, marketSymbol, normalizeMarketSymbol, runAdapter, withExtensionPage } from '../platform/cdp.mjs';
|
|
7
9
|
import {
|
|
8
10
|
assertTrustedExternalMutationTarget,
|
|
@@ -14,11 +16,19 @@ import {
|
|
|
14
16
|
trustedExternalMutationBudget,
|
|
15
17
|
trustedExternalMutationFundingRequirement,
|
|
16
18
|
trustedExternalMutationIdentity,
|
|
19
|
+
inFixturePolicyLane,
|
|
20
|
+
withFixturePolicyContext,
|
|
17
21
|
withExternalMutationRecovery,
|
|
18
22
|
} from './mutation-receipt.mjs';
|
|
19
23
|
import { readSnapshot } from './read_snapshot.mjs';
|
|
20
24
|
import { readPerpsRuntimeStateExpression } from './state.mjs';
|
|
21
25
|
import { readWalletStateExpression } from '../wallet/state.mjs';
|
|
26
|
+
import {
|
|
27
|
+
assertFlatBeforeRoundTrip,
|
|
28
|
+
assertHouseLimitPosition,
|
|
29
|
+
closeImmediatelyRequested,
|
|
30
|
+
requireHouseNotional,
|
|
31
|
+
} from '../../shared/perps/order-policy.mjs';
|
|
22
32
|
import {
|
|
23
33
|
orderIdentityMatches,
|
|
24
34
|
parseOrderCard,
|
|
@@ -44,6 +54,10 @@ function sideForItem(item) {
|
|
|
44
54
|
const side = String(item?.side ?? item?.direction ?? '').toLowerCase();
|
|
45
55
|
if (side === 'buy' || side === 'b') return 'long';
|
|
46
56
|
if (side === 'sell' || side === 'a' || side === 's') return 'short';
|
|
57
|
+
if (side) return side;
|
|
58
|
+
// Positions carry no side, only a signed size (long > 0, short < 0).
|
|
59
|
+
const size = Number(item?.size ?? item?.szi);
|
|
60
|
+
if (Number.isFinite(size) && size !== 0) return size > 0 ? 'long' : 'short';
|
|
47
61
|
return side;
|
|
48
62
|
}
|
|
49
63
|
|
|
@@ -222,6 +236,9 @@ export async function closePositions(input) {
|
|
|
222
236
|
}
|
|
223
237
|
|
|
224
238
|
async function closeOrderThroughBackground(input, page, timeoutMs) {
|
|
239
|
+
({ input } = await withFixturePolicyContext(input, page, {
|
|
240
|
+
operations: [{ operation: 'perps-cancel-order', amount: String(input.node?.amount ?? '') }],
|
|
241
|
+
}));
|
|
225
242
|
await requirePerpsTradingContext(input, page);
|
|
226
243
|
const symbol = marketSymbol(input);
|
|
227
244
|
const side = String(input.node?.side ?? '').toLowerCase();
|
|
@@ -414,6 +431,9 @@ export async function closeOrders(input) {
|
|
|
414
431
|
partialCloseAmount: null,
|
|
415
432
|
roundingToleranceBaseUnits,
|
|
416
433
|
};
|
|
434
|
+
({ input } = await withFixturePolicyContext(input, page, {
|
|
435
|
+
operations: [{ operation: 'perps-cancel-order', amount }],
|
|
436
|
+
}));
|
|
417
437
|
const priorLookupIdentity = trustedExternalMutationIdentity(input, page, operation);
|
|
418
438
|
const priorPlace = await readPriorMutationReceipt(
|
|
419
439
|
input,
|
|
@@ -791,8 +811,24 @@ async function observeBackgroundPlace(page, expected, baselineIds, timeoutMs) {
|
|
|
791
811
|
throw new Error('Exact background Perps placement postcondition was not proven.');
|
|
792
812
|
}
|
|
793
813
|
|
|
814
|
+
// Page source that returns { changed: true } unless the selected account and the
|
|
815
|
+
// Perps network are still the ones the order rule allowed. It runs in the same
|
|
816
|
+
// evaluation as the request it guards, with no await between the comparison and
|
|
817
|
+
// the request. The state reads themselves are awaited, so a switch inside that
|
|
818
|
+
// millisecond window can't be ruled out from the page; anything earlier is.
|
|
819
|
+
function boundContextGuard(bound) {
|
|
820
|
+
if (!bound) return '';
|
|
821
|
+
return `
|
|
822
|
+
const __wallet = await (${readWalletStateExpression()});
|
|
823
|
+
const __perps = await (${readPerpsRuntimeStateExpression()});
|
|
824
|
+
const __address = String(__wallet?.selectedAccount?.address ?? '').toLowerCase();
|
|
825
|
+
const __network = __perps?.networkKnown ? (__perps.isTestnet ? 'testnet' : 'mainnet') : null;
|
|
826
|
+
if (__address !== ${JSON.stringify(bound.address)} || __network !== ${JSON.stringify(bound.network)}) return { changed: true };`;
|
|
827
|
+
}
|
|
828
|
+
|
|
794
829
|
async function dispatchBackgroundPlace(page, {
|
|
795
830
|
amount,
|
|
831
|
+
bound = null,
|
|
796
832
|
isBuy,
|
|
797
833
|
leverage,
|
|
798
834
|
limitPrice,
|
|
@@ -804,6 +840,7 @@ async function dispatchBackgroundPlace(page, {
|
|
|
804
840
|
return page.evaluate(`(async () => {
|
|
805
841
|
const request = globalThis.stateHooks?.submitRequestToBackground;
|
|
806
842
|
if (typeof request !== 'function') throw new Error('stateHooks.submitRequestToBackground is unavailable; cannot place live Perps order.');
|
|
843
|
+
${boundContextGuard(bound)}
|
|
807
844
|
const currentPrice = Number(${JSON.stringify(resolvedPrice)});
|
|
808
845
|
const usdAmount = ${JSON.stringify(amount)};
|
|
809
846
|
const leverage = ${JSON.stringify(leverage)};
|
|
@@ -1167,6 +1204,8 @@ async function observeExactVisibleOrderResult(page, expected, receipt, timeoutMs
|
|
|
1167
1204
|
|
|
1168
1205
|
async function placeOrderThroughBackground(input, page, {
|
|
1169
1206
|
amount,
|
|
1207
|
+
bound = null,
|
|
1208
|
+
onDispatch = () => {},
|
|
1170
1209
|
expected,
|
|
1171
1210
|
identity,
|
|
1172
1211
|
isBuy,
|
|
@@ -1224,8 +1263,10 @@ async function placeOrderThroughBackground(input, page, {
|
|
|
1224
1263
|
} catch (error) {
|
|
1225
1264
|
throw externalMutationNotDispatched(error instanceof Error ? error.message : String(error));
|
|
1226
1265
|
}
|
|
1227
|
-
|
|
1266
|
+
onDispatch();
|
|
1267
|
+
const accepted = await dispatchBackgroundPlace(page, {
|
|
1228
1268
|
amount,
|
|
1269
|
+
bound,
|
|
1229
1270
|
isBuy,
|
|
1230
1271
|
leverage,
|
|
1231
1272
|
limitPrice,
|
|
@@ -1234,6 +1275,10 @@ async function placeOrderThroughBackground(input, page, {
|
|
|
1234
1275
|
resolvedPrice,
|
|
1235
1276
|
symbol,
|
|
1236
1277
|
});
|
|
1278
|
+
if (accepted?.changed) {
|
|
1279
|
+
throw externalMutationNotDispatched('the selected account or Perps network changed after the order rule allowed it');
|
|
1280
|
+
}
|
|
1281
|
+
return accepted;
|
|
1237
1282
|
},
|
|
1238
1283
|
observe: async () => observeBackgroundPlace(
|
|
1239
1284
|
page,
|
|
@@ -1285,6 +1330,16 @@ export async function requirePerpsTradingContext(input, page) {
|
|
|
1285
1330
|
throw new Error('Source-dev funded mutation selected account changed after its visible identity proof.');
|
|
1286
1331
|
}
|
|
1287
1332
|
}
|
|
1333
|
+
if (trusted?.proofLane === 'fixture-policy') {
|
|
1334
|
+
// The order rule allowed the account selected when the context was completed;
|
|
1335
|
+
// dispatch only from that same account.
|
|
1336
|
+
const wallet = await page.evaluate(readWalletStateExpression());
|
|
1337
|
+
const address = String(wallet?.selectedAccount?.address ?? '').toLowerCase();
|
|
1338
|
+
if (!/^0x[0-9a-f]{40}$/u.test(address)
|
|
1339
|
+
|| createHash('sha256').update(address).digest('hex') !== trusted.selectedIdentitySha256) {
|
|
1340
|
+
throw new Error('Perps mutation selected account changed after the order rule allowed it.');
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1288
1343
|
if (actualProvider !== provider || actualNetwork !== network) {
|
|
1289
1344
|
throw new Error(
|
|
1290
1345
|
`Perps mutation expected ${provider}/${network}, got ${actualProvider || 'unknown'}/${actualNetwork}.`,
|
|
@@ -1565,7 +1620,7 @@ export async function placeOrder(input) {
|
|
|
1565
1620
|
if (fromCurrentForm && (positionMode !== 'new' || submitAttempts !== 1)) {
|
|
1566
1621
|
throw new Error('Prepared-form placement requires position_mode=new and submit_attempts=1.');
|
|
1567
1622
|
}
|
|
1568
|
-
|
|
1623
|
+
let roundingToleranceBaseUnits = String(input.node?.identity_rounding_tolerance_base_units ?? '10000');
|
|
1569
1624
|
if (!/^(?:0|[1-9][0-9]{0,6})$/u.test(roundingToleranceBaseUnits)
|
|
1570
1625
|
|| BigInt(roundingToleranceBaseUnits) > 1_000_000n) {
|
|
1571
1626
|
throw new Error(`${input.action} identity_rounding_tolerance_base_units must be 0..1000000.`);
|
|
@@ -1581,7 +1636,55 @@ export async function placeOrder(input) {
|
|
|
1581
1636
|
if (!Number.isInteger(submitAttempts) || submitAttempts < 1 || submitAttempts > 2) {
|
|
1582
1637
|
throw new Error(`${input.action} submit_attempts must be 1 or 2.`);
|
|
1583
1638
|
}
|
|
1584
|
-
|
|
1639
|
+
const closeImmediately = closeImmediatelyRequested(input.node);
|
|
1640
|
+
if (closeImmediately && !inFixturePolicyLane(input)) {
|
|
1641
|
+
throw new Error('close_immediately runs without funding files; a funded run closes through its own authorized close node.');
|
|
1642
|
+
}
|
|
1643
|
+
if (closeImmediately && (positionMode !== 'new' || fromCurrentForm)) {
|
|
1644
|
+
throw new Error('close_immediately needs position_mode=new and a background placement.');
|
|
1645
|
+
}
|
|
1646
|
+
// Set once the market is proven flat in this action: only then is a position
|
|
1647
|
+
// in it after a failed placement this order's own.
|
|
1648
|
+
// Set at the actual dispatch (or by a prior receipt that may have dispatched):
|
|
1649
|
+
// only then can a failure have left a position, so only then may cleanup run.
|
|
1650
|
+
let dispatchMayHaveHappened = false;
|
|
1651
|
+
let houseNotional = null;
|
|
1652
|
+
// The account and network the order rule allowed, rechecked before any close.
|
|
1653
|
+
let bound = null;
|
|
1654
|
+
const place = () => withExtensionPage(input, async (page) => {
|
|
1655
|
+
const lane = await withFixturePolicyContext(input, page, {
|
|
1656
|
+
operations: [
|
|
1657
|
+
{ operation: orderType === 'limit' ? 'perps-limit-order' : 'perps-market-order', amount, order: true },
|
|
1658
|
+
],
|
|
1659
|
+
order: { amount, leverage, orderType, closeImmediately },
|
|
1660
|
+
});
|
|
1661
|
+
input = lane.input;
|
|
1662
|
+
if (lane.network) {
|
|
1663
|
+
const wallet = await page.evaluate(readWalletStateExpression());
|
|
1664
|
+
const address = String(wallet?.selectedAccount?.address ?? '').toLowerCase();
|
|
1665
|
+
if (createHash('sha256').update(address).digest('hex') !== input.context?.trustedMutation?.selectedIdentitySha256) {
|
|
1666
|
+
throw new Error('Perps mutation selected account changed after the order rule allowed it.');
|
|
1667
|
+
}
|
|
1668
|
+
bound = { address, network: lane.network };
|
|
1669
|
+
}
|
|
1670
|
+
if (closeImmediately) {
|
|
1671
|
+
if (input.node?.identity_rounding_tolerance_base_units == null) {
|
|
1672
|
+
// The fill's margin differs from the request by up to one size step plus
|
|
1673
|
+
// slippage, more than the default cent: check it fits the $1 cap.
|
|
1674
|
+
const price = await readCurrentMarketPrice(page, symbol, input.node?.current_price ?? input.node?.currentPrice);
|
|
1675
|
+
const szDecimals = await readSizeDecimals(page, symbol);
|
|
1676
|
+
const slippage = Number(input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300) / 10_000;
|
|
1677
|
+
const margin = Number(amount);
|
|
1678
|
+
if (szDecimals == null) throw new Error(`close_immediately cannot read the ${symbol} size decimals; not dispatched.`);
|
|
1679
|
+
const tolerance = Math.ceil(((10 ** -Number(szDecimals)) * Number(price) / leverage + margin * slippage) * 1_000_000) + 10_000;
|
|
1680
|
+
if (!Number.isInteger(Number(szDecimals)) || !Number.isFinite(tolerance) || tolerance > 1_000_000) {
|
|
1681
|
+
throw new Error(`close_immediately cannot prove a ${symbol} fill within $1 (size step or slippage too coarse); pass identity_rounding_tolerance_base_units or choose another market.`);
|
|
1682
|
+
}
|
|
1683
|
+
// The receipt identity must not move with the price, so a resume finds it:
|
|
1684
|
+
// use the fixed $1 cap the estimate fits in.
|
|
1685
|
+
roundingToleranceBaseUnits = '1000000';
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1585
1688
|
const identity = trustedExternalMutationIdentity(input, page, {
|
|
1586
1689
|
operation: orderType === 'limit' ? 'perps-limit-order' : 'perps-market-order',
|
|
1587
1690
|
provider: input.node?.provider,
|
|
@@ -1621,6 +1724,18 @@ export async function placeOrder(input) {
|
|
|
1621
1724
|
const hasBackgroundRequest = await page.evaluate(
|
|
1622
1725
|
`typeof globalThis.stateHooks?.submitRequestToBackground === 'function'`,
|
|
1623
1726
|
);
|
|
1727
|
+
if (closeImmediately) {
|
|
1728
|
+
// The close goes through the background request, so the order must too.
|
|
1729
|
+
if (!hasBackgroundRequest) throw new Error('close_immediately needs the background Perps request; it is unavailable.');
|
|
1730
|
+
const receipt = await readExternalMutationReceipt(input, identity);
|
|
1731
|
+
// Only a receipt that may have dispatched owns what is open in the market now.
|
|
1732
|
+
const owned = ['succeeded', 'outcome-unknown'].includes(receipt?.record?.state);
|
|
1733
|
+
if (owned) dispatchMayHaveHappened = true;
|
|
1734
|
+
if (!owned) {
|
|
1735
|
+
const book = await readBoundBook(page, bound);
|
|
1736
|
+
assertFlatBeforeRoundTrip(book.positions, symbol, symbolForItem, book.orders);
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1624
1739
|
if (hasBackgroundRequest && !fromCurrentForm) {
|
|
1625
1740
|
await requirePerpsTradingContext(input, page);
|
|
1626
1741
|
const resolvedPrice = await readCurrentMarketPrice(
|
|
@@ -1628,11 +1743,24 @@ export async function placeOrder(input) {
|
|
|
1628
1743
|
symbol,
|
|
1629
1744
|
input.node?.current_price ?? input.node?.currentPrice,
|
|
1630
1745
|
);
|
|
1746
|
+
houseNotional = requireHouseNotional({
|
|
1747
|
+
network: lane.network,
|
|
1748
|
+
symbol,
|
|
1749
|
+
amount,
|
|
1750
|
+
leverage,
|
|
1751
|
+
price: resolvedPrice,
|
|
1752
|
+
szDecimals: lane.network === 'mainnet' ? await readSizeDecimals(page, symbol) : null,
|
|
1753
|
+
maxSlippageBps: Number(input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300),
|
|
1754
|
+
});
|
|
1631
1755
|
const backgroundLimitPrice = orderType === 'limit'
|
|
1632
1756
|
? resolveLimitPrice(input, isBuy, resolvedPrice)
|
|
1633
1757
|
: null;
|
|
1634
1758
|
return placeOrderThroughBackground(input, page, {
|
|
1635
1759
|
amount,
|
|
1760
|
+
bound,
|
|
1761
|
+
onDispatch: () => {
|
|
1762
|
+
dispatchMayHaveHappened = true;
|
|
1763
|
+
},
|
|
1636
1764
|
expected: {
|
|
1637
1765
|
...expectedVisibleIdentity,
|
|
1638
1766
|
limitPriceBaseUnits: backgroundLimitPrice == null ? null : parseUsdBaseUnits(backgroundLimitPrice),
|
|
@@ -1952,6 +2080,195 @@ export async function placeOrder(input) {
|
|
|
1952
2080
|
}
|
|
1953
2081
|
throw new Error('Visible Perps placement did not return a terminal result.');
|
|
1954
2082
|
});
|
|
2083
|
+
if (!closeImmediately) return place();
|
|
2084
|
+
// A resumed order checks its close record before anything else: it never
|
|
2085
|
+
// re-observes, cleans up or closes after its round trip has run.
|
|
2086
|
+
const marker = closeMarkerPath(input);
|
|
2087
|
+
const recorded = await readCloseMarker(marker);
|
|
2088
|
+
if (recorded?.state === 'closed') {
|
|
2089
|
+
if (recorded.error) throw new Error(recorded.error);
|
|
2090
|
+
return { ...recorded.placed, roundTrip: { ...recorded.roundTrip, resumed: true } };
|
|
2091
|
+
}
|
|
2092
|
+
if (recorded) {
|
|
2093
|
+
throw new Error(`close_immediately: an earlier close of ${symbol} may have run (record ${recorded.state}); check ${symbol} and rerun with a fresh artifacts dir.`);
|
|
2094
|
+
}
|
|
2095
|
+
let placed;
|
|
2096
|
+
try {
|
|
2097
|
+
placed = await place();
|
|
2098
|
+
} catch (error) {
|
|
2099
|
+
if (!dispatchMayHaveHappened || error?.knownNotDispatched) throw error;
|
|
2100
|
+
throw await closeAfterFailedPlacement(input, symbol, error, bound, marker);
|
|
2101
|
+
}
|
|
2102
|
+
placed = { ...placed, ...(houseNotional ? { houseNotional } : {}) };
|
|
2103
|
+
return { ...placed, roundTrip: await closeOpenedPosition(input, { symbol, bound, marker, placed }) };
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
// The flat-market baseline: the bound account's positions and resting orders,
|
|
2107
|
+
// read in one evaluation with the context guard before, between and after the
|
|
2108
|
+
// reads, so a switch to another account's book and back can't pass as flat.
|
|
2109
|
+
async function readBoundBook(page, bound) {
|
|
2110
|
+
const book = await page.evaluate(`(async () => {
|
|
2111
|
+
const request = globalThis.stateHooks?.submitRequestToBackground;
|
|
2112
|
+
if (typeof request !== 'function') return { unavailable: true };
|
|
2113
|
+
const guard = async () => { ${boundContextGuard(bound)} return null; };
|
|
2114
|
+
if (await guard()) return { changed: true };
|
|
2115
|
+
const positions = await request('perpsGetPositions', []);
|
|
2116
|
+
if (await guard()) return { changed: true };
|
|
2117
|
+
const orders = await request('perpsGetOpenOrders', []);
|
|
2118
|
+
if (await guard()) return { changed: true };
|
|
2119
|
+
return { positions, orders };
|
|
2120
|
+
})()`);
|
|
2121
|
+
if (book?.changed) {
|
|
2122
|
+
throw Object.assign(
|
|
2123
|
+
new Error('Perps order not dispatched: the selected account or network changed while the market was checked flat.'),
|
|
2124
|
+
{ knownNotDispatched: true },
|
|
2125
|
+
);
|
|
2126
|
+
}
|
|
2127
|
+
if (!Array.isArray(book?.positions) || !Array.isArray(book?.orders)) {
|
|
2128
|
+
throw new Error('Background Perps API unavailable; cannot check the market is flat.');
|
|
2129
|
+
}
|
|
2130
|
+
return book;
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
async function readSizeDecimals(page, symbol) {
|
|
2134
|
+
const markets = await page.evaluate(
|
|
2135
|
+
`globalThis.stateHooks.submitRequestToBackground('perpsGetMarkets', [{ symbols: [${JSON.stringify(symbol)}] }])`,
|
|
2136
|
+
);
|
|
2137
|
+
const market = (Array.isArray(markets) ? markets : []).find((item) => normalizeMarketSymbol(item?.name ?? '') === symbol);
|
|
2138
|
+
return market?.szDecimals ?? null;
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
// close_immediately started from a flat market (proven in the same action), so a
|
|
2142
|
+
// position in that market afterwards is this order's own. It is closed with the
|
|
2143
|
+
// product's own background close request and the market is proven flat again.
|
|
2144
|
+
// Every read and the close itself run guarded: if the selected account or the
|
|
2145
|
+
// Perps network is no longer the bound one, nothing is read as ours or closed.
|
|
2146
|
+
export async function closeOwnPosition(page, symbol, { houseLimits = false, bound = null } = {}) {
|
|
2147
|
+
const read = () => page.evaluate(`(async () => {
|
|
2148
|
+
${boundContextGuard(bound)}
|
|
2149
|
+
const positions = await globalThis.stateHooks.submitRequestToBackground('perpsGetPositions', []);
|
|
2150
|
+
return { positions: Array.isArray(positions) ? positions : null };
|
|
2151
|
+
})()`);
|
|
2152
|
+
const own = async () => {
|
|
2153
|
+
const state = await read();
|
|
2154
|
+
if (state?.changed) return { changed: true };
|
|
2155
|
+
if (!state?.positions) throw new Error('Background Perps API unavailable.');
|
|
2156
|
+
return { position: state.positions.find((position) => symbolForItem(position) === symbol) ?? null };
|
|
2157
|
+
};
|
|
2158
|
+
const before = await own();
|
|
2159
|
+
if (before.changed) return { changed: true, closed: false };
|
|
2160
|
+
if (!before.position) return { wasOpen: false, closed: true };
|
|
2161
|
+
let limitError = null;
|
|
2162
|
+
if (houseLimits) {
|
|
2163
|
+
try {
|
|
2164
|
+
assertHouseLimitPosition(before.position, symbol);
|
|
2165
|
+
} catch (error) {
|
|
2166
|
+
limitError = error;
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
const result = await page.evaluate(`(async () => {
|
|
2170
|
+
${boundContextGuard(bound)}
|
|
2171
|
+
return globalThis.stateHooks.submitRequestToBackground('perpsClosePosition', [{ symbol: ${JSON.stringify(symbol)}, orderType: 'market' }]);
|
|
2172
|
+
})()`);
|
|
2173
|
+
if (result?.changed) return { changed: true, closed: false };
|
|
2174
|
+
const deadline = Date.now() + 30_000;
|
|
2175
|
+
let closed = false;
|
|
2176
|
+
while (Date.now() < deadline) {
|
|
2177
|
+
const after = await own();
|
|
2178
|
+
if (after.changed) return { changed: true, closed: false, closeSent: true };
|
|
2179
|
+
if (!after.position) {
|
|
2180
|
+
closed = true;
|
|
2181
|
+
break;
|
|
2182
|
+
}
|
|
2183
|
+
await sleep(1_000);
|
|
2184
|
+
}
|
|
2185
|
+
return {
|
|
2186
|
+
wasOpen: true,
|
|
2187
|
+
closed,
|
|
2188
|
+
limitError,
|
|
2189
|
+
opened: { symbol, size: before.position.size ?? null, leverage: before.position.leverage ?? null },
|
|
2190
|
+
result,
|
|
2191
|
+
};
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
// The run's close record, next to its execution ledger: `closing` before the
|
|
2195
|
+
// close is sent, `closed` after. A resume reads it first, so it never closes a
|
|
2196
|
+
// position opened in that market later, and a crash between the two leaves a
|
|
2197
|
+
// record that refuses to guess.
|
|
2198
|
+
function closeMarkerPath(input) {
|
|
2199
|
+
const trusted = input.context?.trustedMutation;
|
|
2200
|
+
const root = trusted?.executionLedgerRoot;
|
|
2201
|
+
const nodeId = String(input.context?.nodeId ?? '');
|
|
2202
|
+
if (typeof root !== 'string' || !path.isAbsolute(root) || !/^[a-f0-9]{64}$/u.test(String(trusted?.runId ?? '')) || !nodeId) {
|
|
2203
|
+
throw new Error('close_immediately needs the run-owned execution ledger.');
|
|
2204
|
+
}
|
|
2205
|
+
// Same id as the receipt identity's executionId.
|
|
2206
|
+
const executionId = createHash('sha256').update(`${trusted.runId}\0${nodeId}`).digest('hex');
|
|
2207
|
+
return path.join(root, `${executionId}.close.json`);
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
async function readCloseMarker(marker) {
|
|
2211
|
+
return readFile(marker, 'utf8').then(JSON.parse, (error) => {
|
|
2212
|
+
if (error?.code === 'ENOENT') return null;
|
|
2213
|
+
throw error;
|
|
2214
|
+
});
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
async function writeCloseMarker(marker, record, { exclusive = false } = {}) {
|
|
2218
|
+
await writeFile(marker, `${JSON.stringify({ schemaVersion: 1, kind: 'perps-immediate-close', ...record })}\n`, {
|
|
2219
|
+
mode: 0o600,
|
|
2220
|
+
flag: exclusive ? 'wx' : 'w',
|
|
2221
|
+
});
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
async function closeAfterFailedPlacement(input, symbol, error, bound, marker) {
|
|
2225
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2226
|
+
let outcome;
|
|
2227
|
+
try {
|
|
2228
|
+
await writeCloseMarker(marker, { state: 'closing', market: symbol }, { exclusive: true });
|
|
2229
|
+
// withExtensionPage merges the callback's result into its runtime fields.
|
|
2230
|
+
const { wasOpen, closed, changed } = await withExtensionPage(input, (page) => closeOwnPosition(page, symbol, { bound }));
|
|
2231
|
+
outcome = changed
|
|
2232
|
+
? `the selected account or network changed, so nothing was closed; check ${symbol} on the original account now`
|
|
2233
|
+
: !wasOpen
|
|
2234
|
+
? `no ${symbol} position is open`
|
|
2235
|
+
: closed
|
|
2236
|
+
? `the ${symbol} position it opened was closed`
|
|
2237
|
+
: `the ${symbol} position is STILL OPEN after a close request; close it now`;
|
|
2238
|
+
// Nothing open, or closed now: the run is settled and a resume rethrows the failure.
|
|
2239
|
+
if (!changed && (closed || !wasOpen)) {
|
|
2240
|
+
await writeCloseMarker(marker, { state: 'closed', market: symbol, error: `${message} close_immediately: ${outcome}.` });
|
|
2241
|
+
}
|
|
2242
|
+
} catch (closeError) {
|
|
2243
|
+
outcome = `the ${symbol} position could not be checked or closed (${closeError instanceof Error ? closeError.message : String(closeError)}); check it now`;
|
|
2244
|
+
}
|
|
2245
|
+
return new Error(`${message} close_immediately: ${outcome}.`);
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
async function closeOpenedPosition(input, { symbol, bound, marker, placed }) {
|
|
2249
|
+
await writeCloseMarker(marker, { state: 'closing', market: symbol }, { exclusive: true });
|
|
2250
|
+
const mainnet = bound?.network === 'mainnet';
|
|
2251
|
+
const { wasOpen, closed, changed, limitError, opened, result } = await withExtensionPage(
|
|
2252
|
+
input,
|
|
2253
|
+
(page) => closeOwnPosition(page, symbol, { houseLimits: mainnet, bound }),
|
|
2254
|
+
);
|
|
2255
|
+
if (changed) {
|
|
2256
|
+
throw new Error(`close_immediately did not finish closing ${symbol}: the selected account or network changed after the order. Check ${symbol} on the original account now and close it if it is open.`);
|
|
2257
|
+
}
|
|
2258
|
+
if (!wasOpen) throw new Error(`close_immediately could not find the ${symbol} position the order opened; check ${symbol} now.`);
|
|
2259
|
+
if (!closed) {
|
|
2260
|
+
throw new Error(`close_immediately could not close the ${symbol} position this order opened; it is still open. Close it now.`);
|
|
2261
|
+
}
|
|
2262
|
+
const roundTrip = { opened, closed: true, result, proofPath: 'background-close-request' };
|
|
2263
|
+
await writeCloseMarker(marker, {
|
|
2264
|
+
state: 'closed',
|
|
2265
|
+
market: symbol,
|
|
2266
|
+
placed,
|
|
2267
|
+
roundTrip,
|
|
2268
|
+
...(limitError ? { error: limitError.message } : {}),
|
|
2269
|
+
});
|
|
2270
|
+
if (limitError) throw limitError;
|
|
2271
|
+
return roundTrip;
|
|
1955
2272
|
}
|
|
1956
2273
|
|
|
1957
2274
|
export async function ensurePositions(input) {
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// Place a Perps order and return visible-control evidence or assert the resulting order or position state.
|
|
2
|
+
// With close_immediately the order's position is already closed again, so the market must be flat.
|
|
2
3
|
import { runAdapter } from '../platform/cdp.mjs';
|
|
4
|
+
import { closeImmediatelyRequested } from '../../shared/perps/order-policy.mjs';
|
|
3
5
|
import { assertOrders, assertPositions, placeOrder } from './perps.mjs';
|
|
4
6
|
runAdapter(async (input) => {
|
|
5
7
|
const order = await placeOrder(input);
|
|
6
8
|
if (order.proofPath === 'visible-product-controls') return order;
|
|
7
9
|
const assertion = order.orderType === 'limit'
|
|
8
10
|
? await assertOrders(input, true)
|
|
9
|
-
: await assertPositions(input,
|
|
11
|
+
: await assertPositions(input, !closeImmediatelyRequested(input.node));
|
|
10
12
|
return { ...assertion, submitted: order.submitted === true, order };
|
|
11
13
|
});
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
readExternalMutationReceipt,
|
|
8
8
|
runGuardedExternalMutation,
|
|
9
9
|
trustedExternalMutationIdentity,
|
|
10
|
+
withFixturePolicyContext,
|
|
10
11
|
withExternalMutationRecovery,
|
|
11
12
|
} from './mutation-receipt.mjs';
|
|
12
13
|
import { requireCurrentVisibleFundingBalance, requirePerpsTradingContext } from './perps.mjs';
|
|
@@ -165,6 +166,9 @@ export async function updatePositionTpsl(input) {
|
|
|
165
166
|
if (input.node?.confirm_live !== true) {
|
|
166
167
|
throw new Error('metamask.perps.update_position_tpsl requires confirm_live=true for live submission.');
|
|
167
168
|
}
|
|
169
|
+
({ input } = await withFixturePolicyContext(input, page, {
|
|
170
|
+
operations: [{ operation: 'perps-tpsl-update', amount: fundingAmount }],
|
|
171
|
+
}));
|
|
168
172
|
const identity = trustedExternalMutationIdentity(input, page, {
|
|
169
173
|
operation: 'perps-tpsl-update',
|
|
170
174
|
provider: input.node?.provider,
|
|
@@ -195,6 +195,55 @@ function captureHelperPath() {
|
|
|
195
195
|
return process.env.CAPTURE_HELPER_PATH || 'capture-helper';
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
// A snapshot helper that outlived its harness process (a crashed run, or a
|
|
199
|
+
// timeout from before timed-out helpers were killed) is reparented to launchd
|
|
200
|
+
// and makes later snapshots hang. Kill those, and what they started (the npm
|
|
201
|
+
// wrapper `node …/capture-helper snapshot` runs the native binary as its
|
|
202
|
+
// child), before starting a new one. Only this user's processes are touched.
|
|
203
|
+
async function reapOrphanedCaptureHelpers(context) {
|
|
204
|
+
const ps = await runProcess('ps', ['-axo', 'pid=,ppid=,uid=,command='], {
|
|
205
|
+
cwd: context.projectRoot,
|
|
206
|
+
env: process.env,
|
|
207
|
+
timeoutMs: 5000,
|
|
208
|
+
});
|
|
209
|
+
if (ps.exitCode !== 0) throw new Error(`ps failed while reaping capture-helper: ${ps.stderr}`);
|
|
210
|
+
const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
211
|
+
const configured = captureHelperPath();
|
|
212
|
+
// The executable (or the script node runs) and its subcommand, never an
|
|
213
|
+
// argument. The configured path may contain spaces; other install paths may not.
|
|
214
|
+
const dir = configured.includes('/') ? `${escape(path.dirname(configured))}/|` : '';
|
|
215
|
+
const snapshotHelper = new RegExp(
|
|
216
|
+
`^(?:(?:\\S*/)?node\\s+)?(?:${dir}\\S*/)?${escape(path.basename(configured))} snapshot(?:\\s|$)`,
|
|
217
|
+
'u',
|
|
218
|
+
);
|
|
219
|
+
const processes = ps.stdout.split('\n').flatMap((line) => {
|
|
220
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/u.exec(line);
|
|
221
|
+
return match && Number(match[3]) === process.getuid()
|
|
222
|
+
? [{ pid: Number(match[1]), ppid: Number(match[2]), command: match[4] }]
|
|
223
|
+
: [];
|
|
224
|
+
});
|
|
225
|
+
const doomed = new Set(
|
|
226
|
+
processes.filter((entry) => entry.ppid === 1 && snapshotHelper.test(entry.command)).map((entry) => entry.pid),
|
|
227
|
+
);
|
|
228
|
+
for (let grew = true; grew;) {
|
|
229
|
+
grew = false;
|
|
230
|
+
for (const entry of processes) {
|
|
231
|
+
if (doomed.has(entry.ppid) && !doomed.has(entry.pid)) {
|
|
232
|
+
doomed.add(entry.pid);
|
|
233
|
+
grew = true;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
for (const pid of doomed) {
|
|
238
|
+
try {
|
|
239
|
+
process.kill(pid, 'SIGKILL');
|
|
240
|
+
} catch (error) {
|
|
241
|
+
// It exited between ps and kill: already reaped.
|
|
242
|
+
if (error?.code !== 'ESRCH') throw error;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
198
247
|
function parsePositivePid(value) {
|
|
199
248
|
const pid = Number(String(value ?? '').trim());
|
|
200
249
|
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
@@ -280,7 +329,7 @@ async function captureCdpSnapshot(page, context, relPath, metadata, captureHelpe
|
|
|
280
329
|
provider: 'cdp',
|
|
281
330
|
mode: 'Page.captureScreenshot',
|
|
282
331
|
fullPage: metadata?.fullPage === true,
|
|
283
|
-
...(captureHelperError ? { fallbackFrom: 'capture-helper', captureHelperError } : {}),
|
|
332
|
+
...(captureHelperError ? { fallbackFrom: 'capture-helper', fallbackReason: captureHelperError, captureHelperError } : {}),
|
|
284
333
|
},
|
|
285
334
|
};
|
|
286
335
|
}
|
|
@@ -318,6 +367,7 @@ export async function captureHelperSnapshot(page, context, relPath, metadata) {
|
|
|
318
367
|
'native snapshot skipped while full-run recording owns the browser window',
|
|
319
368
|
);
|
|
320
369
|
} else {
|
|
370
|
+
await reapOrphanedCaptureHelpers(context);
|
|
321
371
|
const result = await runProcess(
|
|
322
372
|
captureHelperPath(),
|
|
323
373
|
['snapshot', '--pid', String(pid), '--output', destination.stagedPath],
|
|
@@ -413,17 +463,31 @@ async function execNodeScript(script, args, options) {
|
|
|
413
463
|
|
|
414
464
|
function runProcess(command, args, options) {
|
|
415
465
|
return new Promise((resolve, reject) => {
|
|
466
|
+
// A bounded command runs in its own process group so a timeout kills
|
|
467
|
+
// everything it started: the npm capture-helper wrapper runs the native
|
|
468
|
+
// binary as a child, which would otherwise keep our pipes open.
|
|
416
469
|
const child = spawn(command, args, {
|
|
417
470
|
cwd: options.cwd,
|
|
418
471
|
env: options.env || process.env,
|
|
419
472
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
473
|
+
detached: Boolean(options.timeoutMs),
|
|
420
474
|
});
|
|
421
475
|
let timer = null;
|
|
422
476
|
let stdout = '';
|
|
423
477
|
let stderr = '';
|
|
424
478
|
if (options.timeoutMs) {
|
|
425
479
|
timer = setTimeout(() => {
|
|
426
|
-
|
|
480
|
+
timer = null;
|
|
481
|
+
// SIGKILL: capture-helper can ignore SIGTERM while it holds a window capture.
|
|
482
|
+
try {
|
|
483
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
484
|
+
} catch (error) {
|
|
485
|
+
// ESRCH: the whole group already exited; only the pipe close is pending.
|
|
486
|
+
if (error?.code !== 'ESRCH') {
|
|
487
|
+
reject(error);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
427
491
|
reject(new Error(`Command timed out after ${options.timeoutMs}ms: ${command} ${args.join(' ')}`));
|
|
428
492
|
}, options.timeoutMs);
|
|
429
493
|
}
|
|
@@ -650,7 +714,11 @@ export async function openExtensionHomePage(port, extensionId) {
|
|
|
650
714
|
|
|
651
715
|
async function ensureExtensionTarget(input, port) {
|
|
652
716
|
let launch = null;
|
|
653
|
-
|
|
717
|
+
// A fixture-policy run (F21) has no target until its first mutation pins the
|
|
718
|
+
// live one; every later page in that action uses it.
|
|
719
|
+
const trusted = input.context?.trustedMutation;
|
|
720
|
+
const unpinnedPolicyLane = trusted?.proofLane === 'fixture-policy' && trusted.cdpTargetId === '';
|
|
721
|
+
const trustedTargetId = unpinnedPolicyLane ? null : trusted?.cdpTargetId ?? null;
|
|
654
722
|
if (trustedTargetId !== null && !/^[A-Za-z0-9._:-]{1,128}$/u.test(String(trustedTargetId))) {
|
|
655
723
|
throw new Error('Funded Extension action has no valid trusted CDP target id.');
|
|
656
724
|
}
|