@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
|
@@ -13,7 +13,16 @@ import {
|
|
|
13
13
|
redactPosition,
|
|
14
14
|
runAdapter,
|
|
15
15
|
selectedItems,
|
|
16
|
+
symbolForItem,
|
|
16
17
|
} from './_controller.mjs';
|
|
18
|
+
import { waitForPositionsAbsent } from './close_positions.mjs';
|
|
19
|
+
import {
|
|
20
|
+
assertFlatBeforeRoundTrip,
|
|
21
|
+
assertHouseLimitPosition,
|
|
22
|
+
closeImmediatelyRequested,
|
|
23
|
+
requireHouseNotional,
|
|
24
|
+
requirePerpsOrderAllowed,
|
|
25
|
+
} from '../../shared/perps/order-policy.mjs';
|
|
17
26
|
|
|
18
27
|
// LIMIT orders (orderType=limit) place a RESTING order on the book instead of
|
|
19
28
|
// filling: the provider sends tif=Gtc and uses the provided `price` verbatim
|
|
@@ -359,7 +368,7 @@ export async function placeOrder(input) {
|
|
|
359
368
|
if (usdAmount === undefined) {
|
|
360
369
|
throw new Error('metamask.perps.place_order requires amount=<usd> or notional=<usd>.');
|
|
361
370
|
}
|
|
362
|
-
const { controller, accountAddress, network } = await getCoreControllerWithSigner(input);
|
|
371
|
+
const { controller, accountAddress, network, signerAddress } = await getCoreControllerWithSigner(input);
|
|
363
372
|
if (side !== 'long' && side !== 'short') {
|
|
364
373
|
throw new Error(
|
|
365
374
|
`metamask.perps.place_order received invalid side: ${side} (expected long or short).`,
|
|
@@ -421,7 +430,37 @@ export async function placeOrder(input) {
|
|
|
421
430
|
throw new Error(`metamask.perps.place_order received invalid leverage: ${leverage}.`);
|
|
422
431
|
}
|
|
423
432
|
|
|
433
|
+
const closeImmediately = closeImmediatelyRequested(input.node);
|
|
434
|
+
const policy = await requirePerpsOrderAllowed({
|
|
435
|
+
network,
|
|
436
|
+
amount: usdAmount,
|
|
437
|
+
leverage,
|
|
438
|
+
orderType,
|
|
439
|
+
closeImmediately,
|
|
440
|
+
projectRoot: input.context?.projectRoot,
|
|
441
|
+
selectedAddress: signerAddress,
|
|
442
|
+
});
|
|
443
|
+
if (closeImmediately) {
|
|
444
|
+
assertFlatBeforeRoundTrip(
|
|
445
|
+
await controller.getPositions({ standalone: true, userAddress: accountAddress }),
|
|
446
|
+
symbol,
|
|
447
|
+
symbolForItem,
|
|
448
|
+
await controller.getOpenOrders({ standalone: true, userAddress: accountAddress }),
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
424
452
|
const currentPrice = await currentMarketPrice(controller, symbol);
|
|
453
|
+
const houseNotional = requireHouseNotional({
|
|
454
|
+
network,
|
|
455
|
+
symbol,
|
|
456
|
+
amount: usdAmount,
|
|
457
|
+
leverage,
|
|
458
|
+
price: currentPrice,
|
|
459
|
+
szDecimals: network === 'mainnet'
|
|
460
|
+
? (await controller.getMarkets({ symbols: [symbol] }))?.find((market) => market?.name === symbol)?.szDecimals
|
|
461
|
+
: null,
|
|
462
|
+
maxSlippageBps,
|
|
463
|
+
});
|
|
425
464
|
|
|
426
465
|
const reduceOnly = optionalBooleanParam(
|
|
427
466
|
input.node ?? {},
|
|
@@ -524,59 +563,86 @@ export async function placeOrder(input) {
|
|
|
524
563
|
};
|
|
525
564
|
}
|
|
526
565
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
566
|
+
// close_immediately started from a flat market (checked above), so a position
|
|
567
|
+
// in it after a failed placement is this order's: close it rather than leave it open.
|
|
568
|
+
const restsOnBook = orderType === 'limit' || isTriggerOrderType(orderType);
|
|
569
|
+
const placeAndVerify = async () => {
|
|
570
|
+
const result = await controller.placeOrder(orderParams);
|
|
571
|
+
if (!result || result.success !== true) {
|
|
572
|
+
throw controllerRejection({
|
|
573
|
+
action: 'core placeOrder',
|
|
574
|
+
detail: `${symbol} (${orderType}), currentPrice=${currentPrice}${limitPrice ? `, limitPrice=${limitPrice}` : ''}`,
|
|
575
|
+
code: result?.error ?? 'unknown error',
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// For limit orders, confirm a RESTING open order exists (not a filled position);
|
|
580
|
+
// for market orders, confirm the position opened. Both read the real
|
|
581
|
+
// provider/exchange state (not the submit ack) before reporting success.
|
|
582
|
+
let matchingOrders = [];
|
|
583
|
+
let positions = [];
|
|
584
|
+
if (restsOnBook) {
|
|
585
|
+
matchingOrders = await waitForPlacedOrder(
|
|
586
|
+
controller,
|
|
587
|
+
accountAddress,
|
|
588
|
+
input,
|
|
589
|
+
{
|
|
590
|
+
orderId: result.orderId,
|
|
591
|
+
triggerOrderType: isTriggerOrderType(orderType) ? orderType : null,
|
|
592
|
+
triggerPrice,
|
|
593
|
+
limitPrice,
|
|
594
|
+
size: result.submittedSize ?? orderParams.size,
|
|
595
|
+
reduceOnly: orderParams.reduceOnly ?? false,
|
|
596
|
+
},
|
|
597
|
+
Number(optionalParam(input.node ?? {}, 'timeout_ms', 'timeoutMs') ?? 30000),
|
|
598
|
+
);
|
|
599
|
+
} else {
|
|
600
|
+
positions = await controller.getPositions({
|
|
601
|
+
standalone: true,
|
|
602
|
+
userAddress: accountAddress,
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
const matchingPositions = selectedItems(input, positions, 'position');
|
|
606
|
+
|
|
607
|
+
if (restsOnBook && matchingOrders.length === 0) {
|
|
608
|
+
throw new Error(
|
|
609
|
+
`core placeOrder (${orderType}) for ${symbol} reported success but no resting open order is visible (orderId=${result.orderId ?? 'null'}, limitPrice=${limitPrice}, triggerPrice=${triggerPrice}).`,
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
if (!restsOnBook && matchingPositions.length === 0) {
|
|
613
|
+
throw new Error(
|
|
614
|
+
`core placeOrder (market) for ${symbol} reported success but no matching position is visible (orderId=${result.orderId ?? 'null'}).`,
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
return { result, matchingOrders, matchingPositions };
|
|
618
|
+
};
|
|
619
|
+
let placed;
|
|
620
|
+
try {
|
|
621
|
+
placed = await placeAndVerify();
|
|
622
|
+
} catch (error) {
|
|
623
|
+
if (!closeImmediately) throw error;
|
|
624
|
+
throw await closeAfterFailedPlacement({ controller, accountAddress, symbol, maxSlippageBps, error });
|
|
534
625
|
}
|
|
626
|
+
const { result, matchingOrders, matchingPositions } = placed;
|
|
535
627
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
// provider/exchange state (not the submit ack) before reporting success.
|
|
539
|
-
const restsOnBook = orderType === 'limit' || isTriggerOrderType(orderType);
|
|
540
|
-
let matchingOrders = [];
|
|
541
|
-
let positions = [];
|
|
542
|
-
if (restsOnBook) {
|
|
543
|
-
matchingOrders = await waitForPlacedOrder(
|
|
628
|
+
const roundTrip = closeImmediately
|
|
629
|
+
? await closeOpenedPosition({
|
|
544
630
|
controller,
|
|
545
631
|
accountAddress,
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
size: result.submittedSize ?? orderParams.size,
|
|
553
|
-
reduceOnly: orderParams.reduceOnly ?? false,
|
|
554
|
-
},
|
|
555
|
-
Number(optionalParam(input.node ?? {}, 'timeout_ms', 'timeoutMs') ?? 30000),
|
|
556
|
-
);
|
|
557
|
-
} else {
|
|
558
|
-
positions = await controller.getPositions({
|
|
559
|
-
standalone: true,
|
|
560
|
-
userAddress: accountAddress,
|
|
561
|
-
});
|
|
562
|
-
}
|
|
563
|
-
const matchingPositions = selectedItems(input, positions, 'position');
|
|
564
|
-
|
|
565
|
-
if (restsOnBook && matchingOrders.length === 0) {
|
|
566
|
-
throw new Error(
|
|
567
|
-
`core placeOrder (${orderType}) for ${symbol} reported success but no resting open order is visible (orderId=${result.orderId ?? 'null'}, limitPrice=${limitPrice}, triggerPrice=${triggerPrice}).`,
|
|
568
|
-
);
|
|
569
|
-
}
|
|
570
|
-
if (!restsOnBook && matchingPositions.length === 0) {
|
|
571
|
-
throw new Error(
|
|
572
|
-
`core placeOrder (market) for ${symbol} reported success but no matching position is visible (orderId=${result.orderId ?? 'null'}).`,
|
|
573
|
-
);
|
|
574
|
-
}
|
|
632
|
+
symbol,
|
|
633
|
+
network,
|
|
634
|
+
maxSlippageBps,
|
|
635
|
+
timeoutMs: Number(optionalParam(input.node ?? {}, 'timeout_ms', 'timeoutMs') ?? 30000),
|
|
636
|
+
})
|
|
637
|
+
: null;
|
|
575
638
|
|
|
576
639
|
return {
|
|
577
640
|
action: input.action,
|
|
578
641
|
source: 'perps-controller-placeOrder',
|
|
579
642
|
network,
|
|
643
|
+
policy: policy.lane,
|
|
644
|
+
...(houseNotional ? { houseNotional } : {}),
|
|
645
|
+
...(roundTrip ? { roundTrip } : {}),
|
|
580
646
|
account: accountAddress,
|
|
581
647
|
market: symbol,
|
|
582
648
|
side,
|
|
@@ -602,4 +668,60 @@ export async function placeOrder(input) {
|
|
|
602
668
|
};
|
|
603
669
|
}
|
|
604
670
|
|
|
671
|
+
// close_immediately: close the position this order opened and prove the market
|
|
672
|
+
// is flat. On mainnet the opened position must be isolated 1x; it is closed
|
|
673
|
+
// either way before that check fails the action.
|
|
674
|
+
async function closeOpenedPosition({ controller, accountAddress, symbol, network, maxSlippageBps, timeoutMs }) {
|
|
675
|
+
const positions = await controller.getPositions({ standalone: true, userAddress: accountAddress });
|
|
676
|
+
const position = positions.find((candidate) => symbolForItem(candidate) === symbol);
|
|
677
|
+
if (!position) throw new Error(`close_immediately could not find the ${symbol} position the order opened.`);
|
|
678
|
+
let limitError = null;
|
|
679
|
+
if (network === 'mainnet') {
|
|
680
|
+
try {
|
|
681
|
+
assertHouseLimitPosition(position, symbol);
|
|
682
|
+
} catch (error) {
|
|
683
|
+
limitError = error;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
const currentPrice = await currentMarketPrice(controller, symbol);
|
|
687
|
+
const result = await controller.closePosition({
|
|
688
|
+
symbol,
|
|
689
|
+
orderType: 'market',
|
|
690
|
+
currentPrice,
|
|
691
|
+
priceAtCalculation: currentPrice,
|
|
692
|
+
maxSlippageBps,
|
|
693
|
+
position,
|
|
694
|
+
});
|
|
695
|
+
const after = await waitForPositionsAbsent(controller, accountAddress, [symbol], timeoutMs);
|
|
696
|
+
if (result?.success !== true || after.some((candidate) => symbolForItem(candidate) === symbol)) {
|
|
697
|
+
throw new Error(
|
|
698
|
+
`close_immediately left the ${symbol} position open (${result?.error ?? 'still visible after close'}): ${JSON.stringify(redactPosition(position))}. Close it now.`,
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
if (limitError) throw limitError;
|
|
702
|
+
return { opened: redactPosition(position), closed: true, result };
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
async function closeAfterFailedPlacement({ controller, accountAddress, symbol, maxSlippageBps, error }) {
|
|
706
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
707
|
+
let outcome;
|
|
708
|
+
try {
|
|
709
|
+
const positions = await controller.getPositions({ standalone: true, userAddress: accountAddress });
|
|
710
|
+
const position = positions.find((candidate) => symbolForItem(candidate) === symbol);
|
|
711
|
+
if (!position) {
|
|
712
|
+
outcome = `no ${symbol} position is open`;
|
|
713
|
+
} else {
|
|
714
|
+
const currentPrice = await currentMarketPrice(controller, symbol);
|
|
715
|
+
await controller.closePosition({ symbol, orderType: 'market', currentPrice, priceAtCalculation: currentPrice, maxSlippageBps, position });
|
|
716
|
+
const after = await waitForPositionsAbsent(controller, accountAddress, [symbol], 30000);
|
|
717
|
+
outcome = after.some((candidate) => symbolForItem(candidate) === symbol)
|
|
718
|
+
? `the ${symbol} position is STILL OPEN after a close; close it now`
|
|
719
|
+
: `the ${symbol} position it opened was closed`;
|
|
720
|
+
}
|
|
721
|
+
} catch (closeError) {
|
|
722
|
+
outcome = `the ${symbol} position could not be checked or closed (${closeError instanceof Error ? closeError.message : String(closeError)}); check it now`;
|
|
723
|
+
}
|
|
724
|
+
return new Error(`${message} close_immediately: ${outcome}.`);
|
|
725
|
+
}
|
|
726
|
+
|
|
605
727
|
if (isDirectRun(import.meta.url)) runAdapter(placeOrder);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Quote an order's fees through the controller's calculateFees: the preview the
|
|
2
2
|
// clients show, with the rewards and subscription sources the fee resolver uses.
|
|
3
|
-
// Read-only: nothing is
|
|
3
|
+
// Read-only: nothing is placed, and the only signature is the rewards API login.
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
configuredSymbols,
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
requestedRewardsDiscountBips,
|
|
12
12
|
runAdapter,
|
|
13
13
|
} from './_controller.mjs';
|
|
14
|
+
import { rewardsApiUrl } from './_rewards.mjs';
|
|
14
15
|
|
|
15
16
|
const ORDER_TYPES = ['market', 'limit'];
|
|
16
17
|
|
|
@@ -29,7 +30,7 @@ export async function quoteFees(input) {
|
|
|
29
30
|
throw new Error(`metamask.perps.quote_fees amount must be a positive USD notional; got ${amount}.`);
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
const { controller, accountAddress, network } = await getCoreController(input);
|
|
33
|
+
const { controller, accountAddress, network, rewardsClient } = await getCoreController(input);
|
|
33
34
|
// calculateFees routes to the active provider, which init() brings up.
|
|
34
35
|
await controller.init();
|
|
35
36
|
const fees = await controller.calculateFees({
|
|
@@ -48,8 +49,10 @@ export async function quoteFees(input) {
|
|
|
48
49
|
isMaker: isMaker ?? null,
|
|
49
50
|
amount: amount ?? null,
|
|
50
51
|
rewardsDiscountBips: requestedRewardsDiscountBips(input) ?? null,
|
|
51
|
-
//
|
|
52
|
-
rewardsSource: requestedRewardsDiscountBips(input) === undefined ?
|
|
52
|
+
// The node's discount, or the rewards API's answer for the account.
|
|
53
|
+
rewardsSource: requestedRewardsDiscountBips(input) === undefined ? 'rewards-api' : 'node.rewards_discount_bips',
|
|
54
|
+
// Each discount the controller asked the rewards API for (no token).
|
|
55
|
+
rewardsApi: { url: rewardsApiUrl(), calls: rewardsClient.calls },
|
|
53
56
|
fees,
|
|
54
57
|
proofPath: 'perps-controller-calculateFees',
|
|
55
58
|
};
|
|
@@ -5,7 +5,7 @@ import { readFile, writeFile } from 'node:fs/promises';
|
|
|
5
5
|
import { pathToFileURL } from 'node:url';
|
|
6
6
|
|
|
7
7
|
import { walletFixturePath } from '../../harness-exports.mjs';
|
|
8
|
-
import { readFixtureAccounts } from '
|
|
8
|
+
import { readFixtureAccounts } from '../../shared/wallet/fixture-accounts.mjs';
|
|
9
9
|
|
|
10
10
|
async function listAccounts(input) {
|
|
11
11
|
const scope = input.node?.scope == null ? 'named' : String(input.node.scope);
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
trustedExternalMutationBudget,
|
|
12
12
|
trustedExternalMutationIdentity,
|
|
13
13
|
trustedExternalMutationPlanIsExact,
|
|
14
|
+
withFixturePolicyContext,
|
|
14
15
|
withExternalMutationRecovery,
|
|
15
16
|
} from './mutation-receipt.mjs';
|
|
16
17
|
import { observeHyperliquidSubmission, placementReceiptSelection, requireCurrentVisibleFundingBalance, requirePerpsTradingContext } from './perps.mjs';
|
|
@@ -248,6 +249,10 @@ export async function closeVisiblePosition(input) {
|
|
|
248
249
|
if (input.node?.confirm_live !== true) {
|
|
249
250
|
throw new Error('metamask.perps.close_visible_position requires confirm_live=true.');
|
|
250
251
|
}
|
|
252
|
+
({ input } = await withFixturePolicyContext(input, page, {
|
|
253
|
+
operations: [{ operation: 'perps-close-position', amount: String(input.node?.funding_amount ?? '') }],
|
|
254
|
+
mainnetAllowed: true,
|
|
255
|
+
}));
|
|
251
256
|
const fundingSide = String(input.node?.funding_side ?? '').toLowerCase();
|
|
252
257
|
const fundingAmount = String(input.node?.funding_amount ?? '');
|
|
253
258
|
const fundingPositionSize = String(input.node?.funding_position_size ?? '');
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
readPriorMutationReceipt,
|
|
9
9
|
runGuardedExternalMutation,
|
|
10
10
|
trustedExternalMutationIdentity,
|
|
11
|
+
withFixturePolicyContext,
|
|
11
12
|
withExternalMutationRecovery,
|
|
12
13
|
} from './mutation-receipt.mjs';
|
|
13
14
|
import { requireCurrentVisibleFundingBalance, requirePerpsTradingContext } from './perps.mjs';
|
|
@@ -182,6 +183,9 @@ export async function editMargin(input) {
|
|
|
182
183
|
if (input.node?.confirm_live !== true) {
|
|
183
184
|
throw new Error('metamask.perps.edit_margin requires confirm_live=true for live submission.');
|
|
184
185
|
}
|
|
186
|
+
({ input } = await withFixturePolicyContext(input, page, {
|
|
187
|
+
operations: [{ operation: 'perps-margin-adjust', amount, count: 2 }],
|
|
188
|
+
}));
|
|
185
189
|
const identity = trustedExternalMutationIdentity(input, page, {
|
|
186
190
|
operation: 'perps-margin-adjust',
|
|
187
191
|
authorizationCount: 2,
|
|
@@ -17,6 +17,11 @@ import {
|
|
|
17
17
|
} from 'node:fs/promises';
|
|
18
18
|
import path from 'node:path';
|
|
19
19
|
|
|
20
|
+
import { isFixtureAccount } from '../../shared/wallet/fixture-accounts.mjs';
|
|
21
|
+
import { MAINNET_HOUSE_LIMITS, orderNetwork, requirePerpsOrderAllowed } from '../../shared/perps/order-policy.mjs';
|
|
22
|
+
import { readWalletStateExpression } from '../wallet/state.mjs';
|
|
23
|
+
import { readPerpsRuntimeStateExpression } from './state.mjs';
|
|
24
|
+
|
|
20
25
|
const SHA256 = /^[a-f0-9]{64}$/u;
|
|
21
26
|
const EXECUTION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
|
|
22
27
|
const NODE_ID = /^(?=.{1,256}$)[A-Za-z0-9][A-Za-z0-9._-]{0,63}(?:\/[A-Za-z0-9][A-Za-z0-9._-]{0,63})*$/u;
|
|
@@ -185,6 +190,90 @@ function trustedString(value, label, pattern) {
|
|
|
185
190
|
return text;
|
|
186
191
|
}
|
|
187
192
|
|
|
193
|
+
// Without funding files the runner gives Extension mutations a fixture-policy
|
|
194
|
+
// context (F21) that names no account, runtime or operation. Before its receipt
|
|
195
|
+
// identity, a mutating action completes it from the live app: the network
|
|
196
|
+
// (checked with the node's), the selected account, the CDP runtime, and the
|
|
197
|
+
// one or two operations it is about to run, after the shared order policy
|
|
198
|
+
// (library/actions/shared/perps/order-policy.mjs) allows them. Everything after
|
|
199
|
+
// that (identity, budget, receipts, ledger, resume) is the funded path's code.
|
|
200
|
+
export const FIXTURE_POLICY_LANE = 'fixture-policy';
|
|
201
|
+
|
|
202
|
+
export function inFixturePolicyLane(input) {
|
|
203
|
+
return input.context?.trustedMutation?.proofLane === FIXTURE_POLICY_LANE;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* @param input - Action input carrying the runner's fixture-policy context.
|
|
208
|
+
* @param page - The Extension page (runtimeIdentity, evaluate).
|
|
209
|
+
* @param options.operations - [{ operation, amount, count?, order? }]: what this action may run
|
|
210
|
+
* (order: true caps it at the mainnet house limit on mainnet).
|
|
211
|
+
* @param options.order - Perps order to check with the shared policy, or null for a
|
|
212
|
+
* non-order mutation (allowed on testnet; on mainnet only a close from a fixture account).
|
|
213
|
+
* @returns {{ input, network, policy }} input with the completed context.
|
|
214
|
+
*/
|
|
215
|
+
export async function withFixturePolicyContext(input, page, { operations, order = null, mainnetAllowed = false }) {
|
|
216
|
+
// mainnetAllowed: a non-order mutation (a close) a fixture account may run on mainnet.
|
|
217
|
+
if (!inFixturePolicyLane(input)) return { input, network: null, policy: null };
|
|
218
|
+
const state = await page.evaluate(readPerpsRuntimeStateExpression());
|
|
219
|
+
if (!state?.available || !state?.networkKnown) {
|
|
220
|
+
throw new Error('Perps mutation cannot verify the current provider and network.');
|
|
221
|
+
}
|
|
222
|
+
const network = orderNetwork({ declared: input.node?.network, liveIsTestnet: state.isTestnet });
|
|
223
|
+
const wallet = await page.evaluate(readWalletStateExpression());
|
|
224
|
+
const address = String(wallet?.selectedAccount?.address ?? '').toLowerCase();
|
|
225
|
+
if (!/^0x[0-9a-f]{40}$/u.test(address)) throw new Error('Perps mutation cannot read the selected account.');
|
|
226
|
+
const projectRoot = input.context?.projectRoot;
|
|
227
|
+
const policy = order
|
|
228
|
+
? await requirePerpsOrderAllowed({ ...order, network, projectRoot, selectedAddress: address })
|
|
229
|
+
: null;
|
|
230
|
+
if (!order && network === 'mainnet'
|
|
231
|
+
&& (!mainnetAllowed || !(await isFixtureAccount(projectRoot, address)))) {
|
|
232
|
+
throw Object.assign(
|
|
233
|
+
new Error(`PERPS_MAINNET_ORDER_REFUSED: ${input.action} on mainnet needs the funding-file release lane${mainnetAllowed ? ' or a synced test account' : ''}; refused before dispatch.`),
|
|
234
|
+
{ code: 'PERPS_MAINNET_ORDER_REFUSED' },
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
const runtime = page?.runtimeIdentity ?? {};
|
|
238
|
+
const requirements = [{
|
|
239
|
+
kind: 'metric',
|
|
240
|
+
id: 'perps-available-margin-usdc',
|
|
241
|
+
minBaseUnits: '1',
|
|
242
|
+
actualBaseUnits: '1',
|
|
243
|
+
evidenceSha256: digest(`${FIXTURE_POLICY_LANE}:no-funding-minimum`),
|
|
244
|
+
}];
|
|
245
|
+
const trustedMutation = {
|
|
246
|
+
...input.context.trustedMutation,
|
|
247
|
+
selectedIdentitySha256: digest(address),
|
|
248
|
+
runtimeSessionId: runtime.runtimeSessionId,
|
|
249
|
+
cdpPort: runtime.cdpPort,
|
|
250
|
+
cdpTargetId: runtime.cdpTargetId,
|
|
251
|
+
extensionOrigin: runtime.extensionOrigin,
|
|
252
|
+
operations: operations.map((entry) => ({
|
|
253
|
+
operation: entry.operation,
|
|
254
|
+
unit: 'usdc-base-units',
|
|
255
|
+
amountBaseUnits: decimalBaseUnits(entry.amount),
|
|
256
|
+
count: entry.count ?? 1,
|
|
257
|
+
})),
|
|
258
|
+
budgets: operations.map((entry) => ({
|
|
259
|
+
operation: entry.operation,
|
|
260
|
+
unit: 'usdc-base-units',
|
|
261
|
+
// A mainnet order's exposure is capped at the house limit; testnet orders
|
|
262
|
+
// and closes are bounded by the policy alone.
|
|
263
|
+
maxBaseUnits: entry.order && network === 'mainnet'
|
|
264
|
+
? `${MAINNET_HOUSE_LIMITS.maxNotionalBaseUnits}`
|
|
265
|
+
: MAX_POLICY_BUDGET_BASE_UNITS,
|
|
266
|
+
maxCount: entry.count ?? 1,
|
|
267
|
+
})),
|
|
268
|
+
requirements,
|
|
269
|
+
fundingRequirementsSha256: digest(canonical(requirements)),
|
|
270
|
+
};
|
|
271
|
+
return { input: { ...input, context: { ...input.context, trustedMutation } }, network, policy };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Testnet and close budgets: the policy, not a funding request, bounds them.
|
|
275
|
+
const MAX_POLICY_BUDGET_BASE_UNITS = '999999999999999999999999999999';
|
|
276
|
+
|
|
188
277
|
export function trustedExternalMutationIdentity(input, page, operation) {
|
|
189
278
|
const trusted = input.context?.trustedMutation;
|
|
190
279
|
if (!trusted || typeof trusted !== 'object' || Array.isArray(trusted)) {
|
|
@@ -803,9 +892,12 @@ export async function runGuardedExternalMutation({ input, identity, dispatch, ob
|
|
|
803
892
|
} catch (error) {
|
|
804
893
|
if (error instanceof ExternalMutationNotDispatchedError) {
|
|
805
894
|
await markExternalMutationNotDispatched(input, receipt, error, receiptIo);
|
|
806
|
-
throw
|
|
807
|
-
|
|
808
|
-
|
|
895
|
+
throw Object.assign(
|
|
896
|
+
new Error(
|
|
897
|
+
`External mutation was safely not dispatched (${error.message}). ` +
|
|
898
|
+
'Next: correct the observed state and create a fresh consent request, claim, and artifact root.',
|
|
899
|
+
),
|
|
900
|
+
{ knownNotDispatched: true },
|
|
809
901
|
);
|
|
810
902
|
}
|
|
811
903
|
throw recoveryError(receipt, `pointer dispatch may have occurred (${error instanceof Error ? error.message : String(error)})`);
|