@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.
Files changed (76) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/adapters/manifest.json +14 -30
  3. package/adapters/terminal/launch.mjs +17 -13
  4. package/adapters/terminal/lib/mainnet-guard.mjs +9 -0
  5. package/adapters/terminal/lib/readiness.mjs +20 -12
  6. package/adapters/terminal/policy.mjs +35 -0
  7. package/adapters/terminal/verify.mjs +10 -3
  8. package/adapters/terminal/wallet-host.mjs +43 -225
  9. package/dist/adapters/core/actions.js +4 -1
  10. package/dist/adapters/extension/actions.js +6 -2
  11. package/dist/adapters/extension/network-observer.js +13 -0
  12. package/dist/adapters/extension/run.js +112 -5
  13. package/dist/adapters/mobile/actions.js +4 -1
  14. package/dist/adapters/mobile/network-observer.js +68 -0
  15. package/dist/adapters/mobile/performance-observer.js +1 -3
  16. package/dist/adapters/mobile/run.js +32 -9
  17. package/dist/adapters/perps-order-inputs.js +36 -0
  18. package/dist/adapters/proof-failure.js +11 -0
  19. package/dist/adapters/terminal/actions.js +4 -1
  20. package/dist/adapters/terminal/doctor-checks.js +2 -0
  21. package/dist/adapters/terminal/launch.js +3 -0
  22. package/dist/adapters/terminal/overlay.js +8 -1
  23. package/dist/adapters/terminal/run.js +8 -3
  24. package/dist/adapters/terminal/surface.js +2 -0
  25. package/dist/adapters.js +20 -4
  26. package/dist/cli.js +31 -6
  27. package/dist/commands/check.js +30 -6
  28. package/dist/commands/completion-candidates.js +5 -5
  29. package/dist/commands/self-test.js +4 -3
  30. package/dist/engine.js +57 -0
  31. package/dist/funding-execution-context.js +81 -0
  32. package/dist/mm-harness-cli.js +3 -3
  33. package/dist/performance-observation.js +1 -3
  34. package/dist/recipe-assistance-context.js +6 -7
  35. package/dist/runner.js +2 -1
  36. package/docs/CONTRIBUTING.md +4 -2
  37. package/docs/RECIPES.md +31 -3
  38. package/library/actions/core/perps/_controller.mjs +24 -28
  39. package/library/actions/core/perps/_rewards.mjs +186 -0
  40. package/library/actions/core/perps/close_positions.mjs +1 -1
  41. package/library/actions/core/perps/place_order.mjs +167 -45
  42. package/library/actions/core/perps/quote_fees.mjs +7 -4
  43. package/library/actions/core/wallet/list_accounts.mjs +1 -1
  44. package/library/actions/extension/perps/close_visible_position.mjs +5 -0
  45. package/library/actions/extension/perps/edit_margin.mjs +4 -0
  46. package/library/actions/extension/perps/mutation-receipt.mjs +95 -3
  47. package/library/actions/extension/perps/perps.mjs +320 -3
  48. package/library/actions/extension/perps/place_order.mjs +3 -1
  49. package/library/actions/extension/perps/update_position_tpsl.mjs +4 -0
  50. package/library/actions/extension/platform/cdp.mjs +71 -3
  51. package/library/actions/mobile/perps/perps.mjs +216 -11
  52. package/library/actions/mobile/perps/place_order.mjs +3 -1
  53. package/library/actions/shared/perps/order-policy.mjs +177 -0
  54. package/library/actions/{core/wallet/_fixture-accounts.mjs → shared/wallet/fixture-accounts.mjs} +18 -3
  55. package/library/actions/terminal/app/launch.mjs +3 -2
  56. package/library/actions/terminal/platform/hud.mjs +2 -1
  57. package/library/actions/terminal/platform/page.mjs +2 -1
  58. package/library/actions/terminal/wallet/_signature-log.mjs +40 -161
  59. package/library/actions/terminal/wallet/assert_signatures.mjs +6 -12
  60. package/library/actions/terminal/wallet/read_signatures.mjs +10 -9
  61. package/library/manifests/core.action-manifest.json +6 -2
  62. package/library/manifests/extension.action-manifest.json +4 -0
  63. package/library/manifests/mobile.action-manifest.json +12 -0
  64. package/package.json +4 -4
  65. package/site/assets/help-recipes.json +1 -0
  66. package/adapters/terminal/lib/origin.mjs +0 -30
  67. package/adapters/terminal/lib/page-script.mjs +0 -246
  68. package/adapters/terminal/lib/strict-wallet.mjs +0 -138
  69. package/dist/commands/call.js +0 -671
  70. package/dist/commands/list-executables.js +0 -245
  71. package/dist/commands/manifest.js +0 -477
  72. package/dist/commands/run-engine.js +0 -1301
  73. package/dist/commands/run.js +0 -730
  74. package/dist/discovery.js +0 -15
  75. package/dist/metamask-action-validation.js +0 -68
  76. 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
- const result = await controller.placeOrder(orderParams);
528
- if (!result || result.success !== true) {
529
- throw controllerRejection({
530
- action: 'core placeOrder',
531
- detail: `${symbol} (${orderType}), currentPrice=${currentPrice}${limitPrice ? `, limitPrice=${limitPrice}` : ''}`,
532
- code: result?.error ?? 'unknown error',
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
- // For limit orders, confirm a RESTING open order exists (not a filled position);
537
- // for market orders, confirm the position opened. Both read the real
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
- input,
547
- {
548
- orderId: result.orderId,
549
- triggerOrderType: isTriggerOrderType(orderType) ? orderType : null,
550
- triggerPrice,
551
- limitPrice,
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 signed or placed.
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
- // Core has no RewardsController: a discount here is the one the node supplied.
52
- rewardsSource: requestedRewardsDiscountBips(input) === undefined ? null : 'node.rewards_discount_bips',
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 './_fixture-accounts.mjs';
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 new Error(
807
- `External mutation was safely not dispatched (${error.message}). ` +
808
- 'Next: correct the observed state and create a fresh consent request, claim, and artifact root.',
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)})`);