@deeeed/metamask-harness 0.80.0 → 0.82.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 +43 -0
- package/adapters/manifest.json +48 -64
- package/adapters/{terminal → web-dapp}/cleanup.mjs +9 -9
- package/adapters/{terminal → web-dapp}/inject.mjs +14 -14
- package/adapters/{terminal → web-dapp}/launch.mjs +27 -24
- package/adapters/web-dapp/lib/blocked-hosts.mjs +24 -0
- package/adapters/{terminal → web-dapp}/lib/cdp-client.mjs +1 -1
- package/adapters/{terminal → web-dapp}/lib/display.mjs +1 -1
- package/adapters/{terminal → web-dapp}/lib/hud.mjs +1 -1
- package/adapters/web-dapp/lib/policy.mjs +79 -0
- package/adapters/{terminal → web-dapp}/lib/processes.mjs +8 -8
- package/adapters/{terminal → web-dapp}/lib/readiness.mjs +32 -34
- package/adapters/{terminal → web-dapp}/stop.mjs +5 -4
- package/adapters/{terminal → web-dapp}/verify.mjs +9 -6
- package/adapters/{terminal → web-dapp}/wallet-host.mjs +44 -227
- 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 +115 -31
- 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/plugin-surface.js +98 -0
- package/dist/adapters/proof-failure.js +11 -0
- package/dist/adapters/surface.js +1 -1
- package/dist/adapters/{terminal → web-dapp}/actions.js +11 -8
- package/dist/adapters/web-dapp/console-capture.js +63 -0
- package/dist/adapters/web-dapp/doctor-checks.js +33 -0
- package/dist/adapters/{terminal → web-dapp}/launch.js +21 -19
- package/dist/adapters/web-dapp/overlay.js +21 -0
- package/dist/adapters/web-dapp/readiness.js +10 -0
- package/dist/adapters/{terminal → web-dapp}/run.js +22 -17
- package/dist/adapters/{terminal → web-dapp}/stop.js +5 -4
- package/dist/adapters/web-dapp/surface.js +149 -0
- package/dist/adapters.js +20 -4
- package/dist/cli.js +34 -6
- package/dist/command-contract.js +29 -7
- package/dist/commands/check.js +32 -8
- package/dist/commands/completion-candidates.js +5 -5
- package/dist/commands/doctor.js +19 -2
- package/dist/commands/farmslot-ready.js +1 -0
- package/dist/commands/help.js +1 -1
- package/dist/commands/launch/device.js +5 -5
- package/dist/commands/self-test.js +10 -5
- package/dist/engine.js +47 -0
- package/dist/funding-execution-context.js +81 -0
- package/dist/manifest.js +48 -24
- package/dist/mm-harness-cli.js +20 -3
- package/dist/performance-observation.js +7 -15
- package/dist/recipe-assistance-context.js +6 -7
- package/dist/runner.js +28 -8
- package/docs/CONTRIBUTING.md +4 -2
- package/docs/RECIPES.md +31 -3
- package/docs/cli-contract.md +5 -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/console-collector.mjs +1 -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 → web-dapp}/app/assert_no_console_errors.mjs +4 -4
- package/library/actions/{terminal → web-dapp}/app/launch.mjs +8 -7
- package/library/actions/{terminal → web-dapp}/platform/hud.mjs +7 -6
- package/library/actions/{terminal → web-dapp}/platform/page.mjs +13 -12
- package/library/actions/{terminal → web-dapp}/platform/runtime.mjs +16 -12
- package/library/actions/web-dapp/ui/navigate.mjs +24 -0
- package/library/actions/web-dapp/wallet/_signature-log.mjs +32 -0
- package/library/actions/{terminal → web-dapp}/wallet/assert_signatures.mjs +8 -14
- package/library/actions/{terminal → web-dapp}/wallet/list_accounts.mjs +2 -2
- package/library/actions/web-dapp/wallet/read_signatures.mjs +38 -0
- 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/library/manifests/{terminal.action-manifest.json → web-dapp.action-manifest.json} +18 -702
- package/package.json +6 -6
- package/site/assets/help-recipes.json +1 -0
- package/adapters/terminal/lib/mainnet-guard.mjs +0 -177
- 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/adapters/terminal/console-capture.js +0 -59
- package/dist/adapters/terminal/doctor-checks.js +0 -22
- package/dist/adapters/terminal/overlay.js +0 -18
- package/dist/adapters/terminal/readiness.js +0 -11
- package/dist/adapters/terminal/surface.js +0 -133
- 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
- package/library/actions/terminal/perps/_venue.mjs +0 -242
- package/library/actions/terminal/perps/assert_orders.mjs +0 -54
- package/library/actions/terminal/perps/assert_positions.mjs +0 -40
- package/library/actions/terminal/perps/read_orders.mjs +0 -26
- package/library/actions/terminal/perps/read_positions.mjs +0 -25
- package/library/actions/terminal/perps/read_snapshot.mjs +0 -40
- package/library/actions/terminal/perps/revoke_agent.mjs +0 -58
- package/library/actions/terminal/perps/teardown_state.mjs +0 -104
- package/library/actions/terminal/ui/navigate.mjs +0 -31
- package/library/actions/terminal/wallet/_signature-log.mjs +0 -177
- package/library/actions/terminal/wallet/read_signatures.mjs +0 -37
|
@@ -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
|
}
|
|
@@ -14,6 +14,14 @@ import {
|
|
|
14
14
|
import { ensureUnlocked } from '../wallet/ensure_unlocked.mjs';
|
|
15
15
|
import { selectAccount } from '../wallet/select_account.mjs';
|
|
16
16
|
import { observeNativeUi } from '../platform/observe-ui.mjs';
|
|
17
|
+
import {
|
|
18
|
+
assertFlatBeforeRoundTrip,
|
|
19
|
+
assertHouseLimitPosition,
|
|
20
|
+
closeImmediatelyRequested,
|
|
21
|
+
orderNetwork,
|
|
22
|
+
requireHouseNotional,
|
|
23
|
+
requirePerpsOrderAllowed,
|
|
24
|
+
} from '../../shared/perps/order-policy.mjs';
|
|
17
25
|
|
|
18
26
|
function sleep(ms) {
|
|
19
27
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -1281,7 +1289,7 @@ function closePositionParams(input, position) {
|
|
|
1281
1289
|
return params;
|
|
1282
1290
|
}
|
|
1283
1291
|
|
|
1284
|
-
async function closePositionItem(input, position) {
|
|
1292
|
+
async function closePositionItem(input, position, bound = null) {
|
|
1285
1293
|
const maxAttempts = Number(input.node?.close_attempts ?? 3);
|
|
1286
1294
|
const retryDelayMs = Number(input.node?.close_retry_delay_ms ?? 1000);
|
|
1287
1295
|
const deadline = deadlineFromTimeout(
|
|
@@ -1293,7 +1301,7 @@ async function closePositionItem(input, position) {
|
|
|
1293
1301
|
let lastResult = null;
|
|
1294
1302
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
1295
1303
|
if (Date.now() >= deadline) break;
|
|
1296
|
-
const currentPositions = await readPositions(input);
|
|
1304
|
+
const currentPositions = bound ? await readBoundPositions(input, bound) : await readPositions(input);
|
|
1297
1305
|
const currentPosition = currentPositions.find((candidate) => symbolForItem(candidate) === baseParams.symbol);
|
|
1298
1306
|
if (!currentPosition) return { symbol: baseParams.symbol, result: { success: true, alreadyClosed: true }, attempts };
|
|
1299
1307
|
|
|
@@ -1301,8 +1309,12 @@ async function closePositionItem(input, position) {
|
|
|
1301
1309
|
const params = { ...baseParams, position: currentPosition, priceAtCalculation };
|
|
1302
1310
|
const result = await evalAsync(
|
|
1303
1311
|
input,
|
|
1304
|
-
`
|
|
1312
|
+
`(function(){ ${boundContextGuard(bound)}
|
|
1313
|
+
return Engine.context.PerpsController.closePosition(${sourceLiteral(params)}).then(function(r){return JSON.stringify(r)}); })()`,
|
|
1305
1314
|
);
|
|
1315
|
+
if (result?.contextChanged) {
|
|
1316
|
+
throw new Error(`Close of ${baseParams.symbol} stopped: the selected account or network changed; nothing more was sent.`);
|
|
1317
|
+
}
|
|
1306
1318
|
attempts.push({ attempt, priceAtCalculation, result });
|
|
1307
1319
|
lastResult = result;
|
|
1308
1320
|
if (result?.success === false) {
|
|
@@ -1394,7 +1406,37 @@ export async function placeOrder(input) {
|
|
|
1394
1406
|
const maxSlippageBps = Number(input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300);
|
|
1395
1407
|
const isBuy = orderSideIsBuy(side);
|
|
1396
1408
|
const orderType = resolveOrderType(input);
|
|
1409
|
+
const closeImmediately = closeImmediatelyRequested(input.node);
|
|
1410
|
+
const runtime = await readPerpsRuntimeState(input);
|
|
1411
|
+
const network = orderNetwork({ declared: input.node?.network, liveIsTestnet: runtime?.isTestnet });
|
|
1412
|
+
// The account and network the rule allows, rechecked at dispatch and before any close.
|
|
1413
|
+
const bound = { network, address: String((await readSelectedAddress(input)) ?? '').toLowerCase() };
|
|
1414
|
+
const policy = await requirePerpsOrderAllowed({
|
|
1415
|
+
network,
|
|
1416
|
+
amount,
|
|
1417
|
+
leverage,
|
|
1418
|
+
orderType,
|
|
1419
|
+
closeImmediately,
|
|
1420
|
+
projectRoot: input.context?.projectRoot,
|
|
1421
|
+
selectedAddress: bound.address,
|
|
1422
|
+
});
|
|
1423
|
+
if ((closeImmediately || network === 'mainnet') && input.node?.size != null) {
|
|
1424
|
+
throw new Error('close_immediately and mainnet orders are sized from amount; drop size.');
|
|
1425
|
+
}
|
|
1426
|
+
if (closeImmediately) {
|
|
1427
|
+
const book = await readBoundBook(input, bound);
|
|
1428
|
+
assertFlatBeforeRoundTrip(book.positions, symbol, symbolForItem, book.orders);
|
|
1429
|
+
}
|
|
1397
1430
|
const currentPrice = await currentPriceForOrder(input, symbol);
|
|
1431
|
+
const houseNotional = requireHouseNotional({
|
|
1432
|
+
network,
|
|
1433
|
+
symbol,
|
|
1434
|
+
amount,
|
|
1435
|
+
leverage,
|
|
1436
|
+
price: currentPrice,
|
|
1437
|
+
szDecimals: network === 'mainnet' ? await readSizeDecimals(input, symbol) : null,
|
|
1438
|
+
maxSlippageBps,
|
|
1439
|
+
});
|
|
1398
1440
|
const limitPrice = orderType === 'limit'
|
|
1399
1441
|
? resolveLimitPrice(input, isBuy, currentPrice)
|
|
1400
1442
|
: null;
|
|
@@ -1426,16 +1468,179 @@ export async function placeOrder(input) {
|
|
|
1426
1468
|
priceAtCalculation: currentPrice,
|
|
1427
1469
|
maxSlippageBps,
|
|
1428
1470
|
};
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1471
|
+
if (await tradingContextChanged(input, bound)) {
|
|
1472
|
+
throw new Error('Perps order not dispatched: the selected account or network changed after the order rule allowed it.');
|
|
1473
|
+
}
|
|
1474
|
+
let result;
|
|
1475
|
+
try {
|
|
1476
|
+
result = await evalAsync(input, `(function(){ ${boundContextGuard(bound)}
|
|
1477
|
+
return Engine.context.PerpsController.placeOrder(${sourceLiteral(orderParams)}).then(function(r){return JSON.stringify(r)}); })()`);
|
|
1478
|
+
if (result?.contextChanged) {
|
|
1479
|
+
throw Object.assign(
|
|
1480
|
+
new Error('Perps order not dispatched: the selected account or network changed after the order rule allowed it.'),
|
|
1481
|
+
{ knownNotDispatched: true },
|
|
1482
|
+
);
|
|
1483
|
+
}
|
|
1484
|
+
if (result?.success === false || result == null) {
|
|
1485
|
+
throw new Error(`Failed to place ${symbol} ${side}: ${result?.error || JSON.stringify(result)}`);
|
|
1486
|
+
}
|
|
1487
|
+
if (orderType === 'limit') {
|
|
1488
|
+
await waitForSelectedState(input, readOpenOrders, true, Number(input.node?.timeout_ms ?? 30000));
|
|
1489
|
+
} else {
|
|
1490
|
+
await waitForPositionPresent(input, symbol, Number(input.node?.timeout_ms ?? 30000));
|
|
1491
|
+
}
|
|
1492
|
+
} catch (error) {
|
|
1493
|
+
if (!closeImmediately || error?.knownNotDispatched) throw error;
|
|
1494
|
+
throw await closeAfterFailedPlacement(input, symbol, error, bound);
|
|
1495
|
+
}
|
|
1496
|
+
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' };
|
|
1497
|
+
if (!closeImmediately) return placed;
|
|
1498
|
+
return { ...placed, roundTrip: await closeOpenedPosition(input, symbol, bound) };
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
// close_immediately started from a flat market (checked before dispatch), so a
|
|
1502
|
+
// position in it after a failed placement is this order's: close it rather
|
|
1503
|
+
// than leave it open, and say what happened.
|
|
1504
|
+
async function closeAfterFailedPlacement(input, symbol, error, bound) {
|
|
1505
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1506
|
+
let outcome;
|
|
1507
|
+
try {
|
|
1508
|
+
if (await tradingContextChanged(input, bound)) {
|
|
1509
|
+
return new Error(`${message} close_immediately: the selected account or network changed, so nothing was closed; check ${symbol} on the original account now.`);
|
|
1510
|
+
}
|
|
1511
|
+
const position = (await readBoundPositions(input, bound)).find((candidate) => symbolForItem(candidate) === symbol);
|
|
1512
|
+
if (!position) {
|
|
1513
|
+
outcome = `no ${symbol} position is open`;
|
|
1514
|
+
} else {
|
|
1515
|
+
await closePositionItem(fullCloseInput(input), position, bound);
|
|
1516
|
+
outcome = await waitForBoundPositionAbsent(input, bound, symbol, Number(input.node?.timeout_ms ?? 30000))
|
|
1517
|
+
? `the ${symbol} position it opened was closed`
|
|
1518
|
+
: `the ${symbol} position is STILL OPEN after a close; close it now`;
|
|
1519
|
+
}
|
|
1520
|
+
} catch (closeError) {
|
|
1521
|
+
outcome = `the ${symbol} position could not be checked or closed (${closeError instanceof Error ? closeError.message : String(closeError)}); check it now`;
|
|
1522
|
+
}
|
|
1523
|
+
return new Error(`${message} close_immediately: ${outcome}.`);
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
async function readSizeDecimals(input, symbol) {
|
|
1527
|
+
const markets = await evalAsync(
|
|
1528
|
+
input,
|
|
1529
|
+
`Engine.context.PerpsController.getMarkets({ symbols: [${sourceLiteral(symbol)}] }).then(function(r){return JSON.stringify((r||[]).map(function(m){return { name: m.name, szDecimals: m.szDecimals };}))})`,
|
|
1530
|
+
);
|
|
1531
|
+
return (Array.isArray(markets) ? markets : []).find((market) => normalizeMarketSymbol(market?.name ?? '') === symbol)?.szDecimals ?? null;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// The close takes the whole position: the order's own size or amount never
|
|
1535
|
+
// becomes a partial close size.
|
|
1536
|
+
function fullCloseInput(input) {
|
|
1537
|
+
const { size: _size, amount: _amount, notional: _notional, ...node } = input.node ?? {};
|
|
1538
|
+
return { ...input, node };
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
// Bridge sources embed values as JSON literals. JSON.stringify leaves `<`, `>`,
|
|
1542
|
+
// `/` and U+2028/U+2029 raw; escape them so no value can end a script context
|
|
1543
|
+
// or a statement in the evaluated source.
|
|
1544
|
+
const UNSAFE_SOURCE_CHARS = { '<': '\\u003C', '>': '\\u003E', '/': '\\u002F', '\u2028': '\\u2028', '\u2029': '\\u2029' };
|
|
1545
|
+
export function sourceLiteral(value) {
|
|
1546
|
+
return JSON.stringify(value).replace(/[<>/\u2028\u2029]/gu, (char) => UNSAFE_SOURCE_CHARS[char]);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
// Bridge source that resolves to { contextChanged: true } unless the selected
|
|
1550
|
+
// account and Perps network are still the bound ones. It runs in the same
|
|
1551
|
+
// evaluation as the controller call it guards, so a switch can't slip between.
|
|
1552
|
+
function boundContextGuard(bound) {
|
|
1553
|
+
if (!bound) return '';
|
|
1554
|
+
return `var __account = Engine.context.AccountsController.getSelectedAccount();
|
|
1555
|
+
var __address = String((__account && __account.address) || '').toLowerCase();
|
|
1556
|
+
var __network = Engine.context.PerpsController.state.isTestnet ? 'testnet' : 'mainnet';
|
|
1557
|
+
if (__address !== ${sourceLiteral(bound.address)} || __network !== ${sourceLiteral(bound.network)}) {
|
|
1558
|
+
return Promise.resolve(JSON.stringify({ success: false, contextChanged: true }));
|
|
1559
|
+
}`;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
// The positions of the bound account on the bound network, read in one guarded
|
|
1563
|
+
// evaluation; a changed context throws instead of reading another account.
|
|
1564
|
+
async function readBoundPositions(input, bound) {
|
|
1565
|
+
const positions = await evalAsync(input, `(function(){ ${boundContextGuard(bound)}
|
|
1566
|
+
return Engine.context.PerpsController.getPositions().then(function(r){return JSON.stringify(r)}); })()`);
|
|
1567
|
+
if (positions?.contextChanged) {
|
|
1568
|
+
throw new Error('Perps positions not read: the selected account or network changed after the order rule allowed it. Check the order\'s market on the original account now.');
|
|
1569
|
+
}
|
|
1570
|
+
if (!Array.isArray(positions)) throw new Error('PerpsController.getPositions returned a non-array result.');
|
|
1571
|
+
return positions;
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// The flat-market baseline: the bound account's positions and resting orders,
|
|
1575
|
+
// read in one evaluation with the context guard before, between and after the
|
|
1576
|
+
// reads, so a switch to another account's book and back can't pass as flat.
|
|
1577
|
+
async function readBoundBook(input, bound) {
|
|
1578
|
+
const guard = `(function(){ ${boundContextGuard(bound)} return null; })()`;
|
|
1579
|
+
const book = await evalAsync(input, `(function(){
|
|
1580
|
+
var controller = Engine.context.PerpsController;
|
|
1581
|
+
var positions;
|
|
1582
|
+
return ${guard} || controller.getPositions().then(function(r){
|
|
1583
|
+
positions = r;
|
|
1584
|
+
return ${guard} || controller.getOpenOrders().then(function(orders){
|
|
1585
|
+
return ${guard} || JSON.stringify({ positions: positions, orders: orders });
|
|
1586
|
+
});
|
|
1587
|
+
}); })()`);
|
|
1588
|
+
if (book?.contextChanged) {
|
|
1589
|
+
throw new Error('Perps order not dispatched: the selected account or network changed while the market was checked flat.');
|
|
1590
|
+
}
|
|
1591
|
+
if (!Array.isArray(book?.positions) || !Array.isArray(book?.orders)) {
|
|
1592
|
+
throw new Error('PerpsController.getPositions or getOpenOrders returned a non-array result.');
|
|
1593
|
+
}
|
|
1594
|
+
return book;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
async function waitForBoundPositionAbsent(input, bound, symbol, timeoutMs) {
|
|
1598
|
+
const deadline = Date.now() + timeoutMs;
|
|
1599
|
+
for (;;) {
|
|
1600
|
+
const positions = await readBoundPositions(input, bound);
|
|
1601
|
+
if (!positions.some((position) => symbolForItem(position) === symbol)) return true;
|
|
1602
|
+
if (Date.now() >= deadline) return false;
|
|
1603
|
+
await sleep(1000);
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
async function tradingContextChanged(input, bound) {
|
|
1608
|
+
const runtime = await readPerpsRuntimeState(input);
|
|
1609
|
+
const network = runtime?.isTestnet === true ? 'testnet' : runtime?.isTestnet === false ? 'mainnet' : null;
|
|
1610
|
+
const address = String((await readSelectedAddress(input)) ?? '').toLowerCase();
|
|
1611
|
+
return network !== bound.network || address !== bound.address;
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
async function readSelectedAddress(input) {
|
|
1615
|
+
return evalAsync(input, 'JSON.stringify(Engine.context.AccountsController.getSelectedAccount()?.address ?? null)');
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// close_immediately: close the position this order opened and prove the market
|
|
1619
|
+
// is flat. On mainnet the opened position must be isolated 1x; it is closed
|
|
1620
|
+
// either way before that check fails the action.
|
|
1621
|
+
async function closeOpenedPosition(input, symbol, bound) {
|
|
1622
|
+
const { network } = bound;
|
|
1623
|
+
if (await tradingContextChanged(input, bound)) {
|
|
1624
|
+
throw new Error(`close_immediately did not close ${symbol}: the selected account or network changed after the order. The order's position is still open on the original account; close it now.`);
|
|
1625
|
+
}
|
|
1626
|
+
const position = (await readBoundPositions(input, bound)).find((candidate) => symbolForItem(candidate) === symbol);
|
|
1627
|
+
if (!position) {
|
|
1628
|
+
throw new Error(`close_immediately could not find the ${symbol} position the order opened; it may still fill. Check ${symbol} now and close it if it opened.`);
|
|
1629
|
+
}
|
|
1630
|
+
let limitError = null;
|
|
1631
|
+
if (network === 'mainnet') {
|
|
1632
|
+
try {
|
|
1633
|
+
assertHouseLimitPosition(position, symbol);
|
|
1634
|
+
} catch (error) {
|
|
1635
|
+
limitError = error;
|
|
1636
|
+
}
|
|
1432
1637
|
}
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
await waitForPositionPresent(input, symbol, Number(input.node?.timeout_ms ?? 30000));
|
|
1638
|
+
const closed = await closePositionItem(fullCloseInput(input), position, bound);
|
|
1639
|
+
if (!(await waitForBoundPositionAbsent(input, bound, symbol, Number(input.node?.timeout_ms ?? 30000)))) {
|
|
1640
|
+
throw new Error(`close_immediately left the ${symbol} position open: ${JSON.stringify(redactPosition(position))}. Close it now.`);
|
|
1437
1641
|
}
|
|
1438
|
-
|
|
1642
|
+
if (limitError) throw limitError;
|
|
1643
|
+
return { opened: redactPosition(position), closed: true, result: closed.result };
|
|
1439
1644
|
}
|
|
1440
1645
|
|
|
1441
1646
|
export async function ensurePositions(input) {
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// Submit a Perps order through the Mobile controller and assert a matching open position.
|
|
2
|
+
// With close_immediately the order's position is already closed again, so the market must be flat.
|
|
2
3
|
import { runAdapter } from '../platform/bridge.mjs';
|
|
4
|
+
import { closeImmediatelyRequested } from '../../shared/perps/order-policy.mjs';
|
|
3
5
|
import { assertPositions, placeOrder } from './perps.mjs';
|
|
4
6
|
runAdapter(async (input) => {
|
|
5
7
|
const order = await placeOrder(input);
|
|
6
|
-
const assertion = await assertPositions(input,
|
|
8
|
+
const assertion = await assertPositions(input, !closeImmediatelyRequested(input.node));
|
|
7
9
|
return { ...assertion, submitted: order.submitted === true, order };
|
|
8
10
|
});
|
|
@@ -15,7 +15,7 @@ export const COLLECTOR_SCRIPT_NAME = 'console-tail.mjs';
|
|
|
15
15
|
export const CAPTURE_CONTROL_MARK = '[mm-harness-capture-control]';
|
|
16
16
|
|
|
17
17
|
// Same derivation as ownerMarker('console-collector', …) in
|
|
18
|
-
// adapters/
|
|
18
|
+
// adapters/web-dapp/lib/processes.mjs, so the web-dapp stop path finds it.
|
|
19
19
|
export function collectorOwnerMarker(runtimeDir) {
|
|
20
20
|
const id = createHash('sha256').update(path.resolve(runtimeDir)).digest('hex').slice(0, 32);
|
|
21
21
|
return `--mm-harness-owner=console-collector-${id}`;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// The Perps order rule every adapter applies before dispatch (F21).
|
|
2
|
+
// Testnet orders need no funding proof. A mainnet order is allowed only from a
|
|
3
|
+
// synced fixture account within the house limits: 1x isolated, notional at
|
|
4
|
+
// most $15, closed again inside the same action (close_immediately: true).
|
|
5
|
+
|
|
6
|
+
import { isFixtureAccount } from '../wallet/fixture-accounts.mjs';
|
|
7
|
+
|
|
8
|
+
export const MAINNET_HOUSE_LIMITS = Object.freeze({
|
|
9
|
+
maxNotionalUsd: '15',
|
|
10
|
+
maxNotionalBaseUnits: 15_000_000n,
|
|
11
|
+
leverage: 1,
|
|
12
|
+
marginType: 'isolated',
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const USDC_DECIMAL = /^(?:0|[1-9][0-9]{0,11})(?:\.([0-9]{1,6}))?$/u;
|
|
16
|
+
|
|
17
|
+
// USDC base units (6 decimals) of a non-negative decimal amount.
|
|
18
|
+
export function usdcBaseUnits(value) {
|
|
19
|
+
const text = String(value ?? '').trim();
|
|
20
|
+
const match = USDC_DECIMAL.exec(text);
|
|
21
|
+
if (!match) throw new Error(`Perps order amount must be a USDC decimal with at most 6 decimals, got ${JSON.stringify(text)}.`);
|
|
22
|
+
const [whole, fraction = ''] = text.split('.');
|
|
23
|
+
return BigInt(whole) * 1_000_000n + BigInt(fraction.padEnd(6, '0'));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The order's network: the live app's, which must agree with the node's
|
|
27
|
+
// declared one. A mainnet order must declare network: mainnet, so a recipe
|
|
28
|
+
// written for testnet never trades real funds because the app moved.
|
|
29
|
+
export function orderNetwork({ declared, liveIsTestnet }) {
|
|
30
|
+
const live = liveIsTestnet === true ? 'testnet' : liveIsTestnet === false ? 'mainnet' : null;
|
|
31
|
+
if (!live) throw new Error('Perps order cannot verify whether the app is on testnet or mainnet.');
|
|
32
|
+
const wanted = declared == null || declared === '' ? null : String(declared).toLowerCase();
|
|
33
|
+
if (wanted && wanted !== 'testnet' && wanted !== 'mainnet') {
|
|
34
|
+
throw new Error(`Perps order network must be testnet or mainnet, got ${JSON.stringify(declared)}.`);
|
|
35
|
+
}
|
|
36
|
+
if (wanted && wanted !== live) throw new Error(`Perps order expected ${wanted}, but the app is on ${live}.`);
|
|
37
|
+
if (live === 'mainnet' && wanted !== 'mainnet') {
|
|
38
|
+
throw Object.assign(
|
|
39
|
+
new Error('PERPS_MAINNET_ORDER_REFUSED: Perps app is on mainnet but the order does not declare network: mainnet. Next: switch to testnet, or declare network: mainnet within the house limits.'),
|
|
40
|
+
{ code: 'PERPS_MAINNET_ORDER_REFUSED' },
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return live;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Whether the node asks the action to close the position it opens.
|
|
47
|
+
export function closeImmediatelyRequested(node) {
|
|
48
|
+
return node?.close_immediately === true || node?.closeImmediately === true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Decide whether a Perps order may be dispatched.
|
|
53
|
+
* @param {object} order
|
|
54
|
+
* @param {'testnet'|'mainnet'} order.network - The live app network, already cross-checked with the node.
|
|
55
|
+
* @param {string|number} order.amount - Margin in USDC.
|
|
56
|
+
* @param {number} order.leverage
|
|
57
|
+
* @param {'market'|'limit'} [order.orderType]
|
|
58
|
+
* @param {boolean} order.closeImmediately
|
|
59
|
+
* @param {boolean} order.fixtureAccount - The selected account is a synced fixture account.
|
|
60
|
+
* @returns {{ lane: 'testnet' } | { lane: 'mainnet-house-limits', notionalBaseUnits: string }}
|
|
61
|
+
*/
|
|
62
|
+
export function decidePerpsOrder({ network, amount, leverage, orderType = 'market', closeImmediately, fixtureAccount }) {
|
|
63
|
+
if (closeImmediately && orderType !== 'market') {
|
|
64
|
+
throw new Error('close_immediately needs a market order: a resting limit order is not a position the action can close.');
|
|
65
|
+
}
|
|
66
|
+
// Testnet keeps whatever amount format the adapter accepts; only mainnet sizes are parsed strictly.
|
|
67
|
+
if (network === 'testnet') return { lane: 'testnet' };
|
|
68
|
+
if (network !== 'mainnet') throw new Error(`Perps order network must be testnet or mainnet, got ${JSON.stringify(network)}.`);
|
|
69
|
+
const leverageValue = Number(leverage);
|
|
70
|
+
const amountBaseUnits = usdcBaseUnits(amount);
|
|
71
|
+
const notionalBaseUnits = amountBaseUnits * BigInt(Number.isInteger(leverageValue) && leverageValue > 0 ? leverageValue : 1);
|
|
72
|
+
const broken = [];
|
|
73
|
+
if (amountBaseUnits <= 0n) broken.push('the amount is not positive');
|
|
74
|
+
if (!fixtureAccount) broken.push('the selected account is not one of the synced test accounts');
|
|
75
|
+
if (leverageValue !== MAINNET_HOUSE_LIMITS.leverage) broken.push(`leverage is ${leverage}, not 1`);
|
|
76
|
+
if (notionalBaseUnits > MAINNET_HOUSE_LIMITS.maxNotionalBaseUnits) {
|
|
77
|
+
broken.push(`notional ${formatUsd(notionalBaseUnits)} is above $${MAINNET_HOUSE_LIMITS.maxNotionalUsd}`);
|
|
78
|
+
}
|
|
79
|
+
if (orderType !== 'market') broken.push(`order type is ${orderType}, not market`);
|
|
80
|
+
if (!closeImmediately) broken.push('close_immediately is not true');
|
|
81
|
+
if (broken.length > 0) {
|
|
82
|
+
throw Object.assign(
|
|
83
|
+
new Error(
|
|
84
|
+
// The code leads the message: it is what survives a live adapter's process
|
|
85
|
+
// boundary, and the wording avoids the words bounded healing reads as wallet state.
|
|
86
|
+
`PERPS_MAINNET_ORDER_REFUSED: Mainnet Perps order refused before dispatch: ${broken.join('; ')}. ` +
|
|
87
|
+
`Mainnet orders need a synced test account, a 1x isolated market order, notional at most $${MAINNET_HOUSE_LIMITS.maxNotionalUsd} and close_immediately: true. ` +
|
|
88
|
+
'Next: use testnet, or stay within those limits.',
|
|
89
|
+
),
|
|
90
|
+
{ code: 'PERPS_MAINNET_ORDER_REFUSED' },
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return { lane: 'mainnet-house-limits', notionalBaseUnits: `${notionalBaseUnits}` };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// decidePerpsOrder with the fixture check resolved from the checkout's fixture.
|
|
97
|
+
export async function requirePerpsOrderAllowed({ projectRoot, selectedAddress, ...order }) {
|
|
98
|
+
const fixtureAccount = order.network === 'mainnet'
|
|
99
|
+
? await isFixtureAccount(projectRoot, selectedAddress)
|
|
100
|
+
: false;
|
|
101
|
+
return decidePerpsOrder({ ...order, fixtureAccount });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// close_immediately closes the market's whole position (positions are netted
|
|
105
|
+
// per market), so it must start flat: no position and no resting order that
|
|
106
|
+
// could fill into the position it then closes.
|
|
107
|
+
export function assertFlatBeforeRoundTrip(positions, symbol, symbolOf, orders = []) {
|
|
108
|
+
if (positions.some((position) => symbolOf(position) === symbol)) {
|
|
109
|
+
throw new Error(`close_immediately needs no open ${symbol} position before the order; close it first.`);
|
|
110
|
+
}
|
|
111
|
+
if (orders.some((order) => symbolOf(order) === symbol)) {
|
|
112
|
+
throw new Error(`close_immediately needs no resting ${symbol} order before the order; cancel it first.`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The most a market order for `amount` USDC margin can fill at. The controller
|
|
117
|
+
// rounds the size to the market's szDecimals and adds one step when that falls
|
|
118
|
+
// short of the USD amount (perps-controller utils/orderCalculations.ts), and the
|
|
119
|
+
// fill may slip by maxSlippageBps: (amount × leverage + one step × price) × (1 + slippage).
|
|
120
|
+
export function worstCaseNotionalUsd({ amount, leverage, price, szDecimals, maxSlippageBps }) {
|
|
121
|
+
const usd = Number(amount) * Number(leverage);
|
|
122
|
+
const step = 10 ** -Number(szDecimals);
|
|
123
|
+
return (usd + step * Number(price)) * (1 + Number(maxSlippageBps) / 10_000);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Mainnet only: refuse before dispatch when the size the controller would submit
|
|
127
|
+
// could fill above the house notional limit. Testnet needs no bound.
|
|
128
|
+
export function requireHouseNotional({ network, symbol, amount, leverage, price, szDecimals, maxSlippageBps }) {
|
|
129
|
+
if (network !== 'mainnet') return null;
|
|
130
|
+
// A missing value is unknown, never zero (Number(null) is 0).
|
|
131
|
+
const known = (value) => (value == null || value === '' ? Number.NaN : Number(value));
|
|
132
|
+
const inputs = { price: known(price), szDecimals: known(szDecimals), maxSlippageBps: known(maxSlippageBps) };
|
|
133
|
+
if (!(inputs.price > 0) || !Number.isInteger(inputs.szDecimals) || inputs.szDecimals < 0
|
|
134
|
+
|| !Number.isFinite(inputs.maxSlippageBps) || inputs.maxSlippageBps < 0) {
|
|
135
|
+
throw Object.assign(
|
|
136
|
+
new Error(`PERPS_MAINNET_ORDER_REFUSED: Mainnet Perps order refused before dispatch: cannot bound the ${symbol} order size (price, size decimals or slippage unknown).`),
|
|
137
|
+
{ code: 'PERPS_MAINNET_ORDER_REFUSED' },
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
const worst = worstCaseNotionalUsd({ amount, leverage, ...inputs });
|
|
141
|
+
if (worst > Number(MAINNET_HOUSE_LIMITS.maxNotionalUsd)) {
|
|
142
|
+
throw Object.assign(
|
|
143
|
+
new Error(
|
|
144
|
+
`PERPS_MAINNET_ORDER_REFUSED: Mainnet Perps order refused before dispatch: a ${symbol} order for $${Number(amount) * Number(leverage)} can fill at up to $${worst.toFixed(2)} ` +
|
|
145
|
+
`(size step ${10 ** -inputs.szDecimals} at $${inputs.price}, max slippage ${inputs.maxSlippageBps} bps), above $${MAINNET_HOUSE_LIMITS.maxNotionalUsd}. ` +
|
|
146
|
+
'Next: order less, or lower max_slippage_bps.',
|
|
147
|
+
),
|
|
148
|
+
{ code: 'PERPS_MAINNET_ORDER_REFUSED' },
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
return { worstCaseNotionalUsd: Number(worst.toFixed(6)) };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The opened position must be the isolated 1x one the policy allowed, worth at
|
|
155
|
+
// most the house limit, before the action closes it (mainnet), so a drift is
|
|
156
|
+
// reported, not hidden. The caller closes the position first either way.
|
|
157
|
+
export function assertHouseLimitPosition(position, symbol) {
|
|
158
|
+
const type = String(position?.leverage?.type ?? position?.marginType ?? '').toLowerCase();
|
|
159
|
+
const value = Number(position?.leverage?.value ?? position?.leverage);
|
|
160
|
+
if (type !== MAINNET_HOUSE_LIMITS.marginType || value !== MAINNET_HOUSE_LIMITS.leverage) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`Mainnet ${symbol} position opened as ${type || 'unknown'} ${Number.isFinite(value) ? `${value}x` : 'unknown leverage'}, not isolated 1x; it was closed.`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
const opened = Math.abs(Number(position?.size ?? position?.szi)) * Number(position?.entryPrice ?? position?.entryPx);
|
|
166
|
+
if (!Number.isFinite(opened) || opened > Number(MAINNET_HOUSE_LIMITS.maxNotionalUsd) + 1e-6) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`Mainnet ${symbol} position opened at ${Number.isFinite(opened) ? `$${opened.toFixed(2)}` : 'an unknown value'}, above $${MAINNET_HOUSE_LIMITS.maxNotionalUsd}; it was closed.`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function formatUsd(baseUnits) {
|
|
174
|
+
const whole = baseUnits / 1_000_000n;
|
|
175
|
+
const fraction = `${baseUnits % 1_000_000n}`.padStart(6, '0').replace(/0+$/u, '');
|
|
176
|
+
return `$${whole}${fraction ? `.${fraction}` : ''}`;
|
|
177
|
+
}
|
package/library/actions/{core/wallet/_fixture-accounts.mjs → shared/wallet/fixture-accounts.mjs}
RENAMED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
// The checkout's wallet fixture
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// The checkout's synced wallet fixture (temp/recipe/runtime/wallet-fixture.json)
|
|
2
|
+
// expanded into accounts: a mnemonic entry with count N yields N accounts at
|
|
3
|
+
// address indexes 0..N-1, named dev1..devN from the entry's name. list_accounts
|
|
4
|
+
// shows them, Core Perps actions sign with them, and the Perps order rule
|
|
5
|
+
// (../perps/order-policy.mjs) checks a mainnet order's account against them.
|
|
4
6
|
|
|
5
7
|
import { readFile } from 'node:fs/promises';
|
|
6
8
|
import { mnemonicToAccount, privateKeyToAccount } from 'viem/accounts';
|
|
@@ -67,3 +69,16 @@ export async function readFixtureAccounts(projectRoot) {
|
|
|
67
69
|
}
|
|
68
70
|
return { fixturePath, accounts: deriveFixtureAccounts(JSON.parse(raw), fixturePath) };
|
|
69
71
|
}
|
|
72
|
+
|
|
73
|
+
// Whether an address is one of the fixture's accounts. A missing or unreadable
|
|
74
|
+
// fixture is "not a fixture account": the caller refuses, it never assumes.
|
|
75
|
+
export async function isFixtureAccount(projectRoot, address) {
|
|
76
|
+
const wanted = String(address ?? '').toLowerCase();
|
|
77
|
+
if (!/^0x[0-9a-f]{40}$/u.test(wanted) || !projectRoot) return false;
|
|
78
|
+
try {
|
|
79
|
+
const loaded = await readFixtureAccounts(projectRoot);
|
|
80
|
+
return Boolean(loaded?.accounts.some((account) => account.address.toLowerCase() === wanted));
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Fail the recipe when the
|
|
1
|
+
// Fail the recipe when the app page logged an error or uncaught exception
|
|
2
2
|
// that the recipe libraries' console allowlist does not mark as known
|
|
3
3
|
// third-party noise. Reads the slot console capture (app-console.log, and
|
|
4
4
|
// extension-console.log when asked) from where the current slot browser's
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
loadConsoleAllowlist,
|
|
21
21
|
} from '../../shared/console-findings.mjs';
|
|
22
22
|
import { collectorOwnerMarker, collectorStatus, verifyCapture } from '../../shared/console-collector.mjs';
|
|
23
|
-
import { isDirectRun, readBrowserState, runAdapter,
|
|
23
|
+
import { isDirectRun, readBrowserState, runAdapter, webDappPolicy, webDappRuntimeDir } from '../platform/runtime.mjs';
|
|
24
24
|
|
|
25
25
|
const LOGS = Object.freeze({ page: 'app-console.log', extension: 'extension-console.log' });
|
|
26
26
|
|
|
@@ -55,7 +55,7 @@ export async function assertNoConsoleErrors(input, env = process.env, deps = {})
|
|
|
55
55
|
const projectRoot = input.context?.projectRoot;
|
|
56
56
|
const sources = Array.isArray(node.sources) && node.sources.length ? node.sources.map(String) : ['page', 'browser'];
|
|
57
57
|
const levels = new Set(Array.isArray(node.levels) && node.levels.length ? node.levels.map(String) : ['error', 'exception']);
|
|
58
|
-
const runtime =
|
|
58
|
+
const runtime = webDappRuntimeDir(projectRoot);
|
|
59
59
|
const state = await readBrowserState(projectRoot);
|
|
60
60
|
if (!state?.cdpPort || !state.appOrigin) throw unavailable('no slot browser state (browser.json)');
|
|
61
61
|
const capture = (deps.collectorStatus ?? collectorStatus)({
|
|
@@ -107,7 +107,7 @@ export async function assertNoConsoleErrors(input, env = process.env, deps = {})
|
|
|
107
107
|
// The recorded launch offset was past the end of a replaced capture
|
|
108
108
|
// file; that file was read from its start.
|
|
109
109
|
offsetResets,
|
|
110
|
-
proofPath:
|
|
110
|
+
proofPath: `${webDappPolicy().adapterId}-console-capture`,
|
|
111
111
|
};
|
|
112
112
|
if (failing.size > 0) {
|
|
113
113
|
const list = [...failing.values()].map((item) => `${item.level} [${item.source}] ${item.example} ×${item.count}`).join('\n ');
|