@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.
Files changed (118) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/adapters/manifest.json +48 -64
  3. package/adapters/{terminal → web-dapp}/cleanup.mjs +9 -9
  4. package/adapters/{terminal → web-dapp}/inject.mjs +14 -14
  5. package/adapters/{terminal → web-dapp}/launch.mjs +27 -24
  6. package/adapters/web-dapp/lib/blocked-hosts.mjs +24 -0
  7. package/adapters/{terminal → web-dapp}/lib/cdp-client.mjs +1 -1
  8. package/adapters/{terminal → web-dapp}/lib/display.mjs +1 -1
  9. package/adapters/{terminal → web-dapp}/lib/hud.mjs +1 -1
  10. package/adapters/web-dapp/lib/policy.mjs +79 -0
  11. package/adapters/{terminal → web-dapp}/lib/processes.mjs +8 -8
  12. package/adapters/{terminal → web-dapp}/lib/readiness.mjs +32 -34
  13. package/adapters/{terminal → web-dapp}/stop.mjs +5 -4
  14. package/adapters/{terminal → web-dapp}/verify.mjs +9 -6
  15. package/adapters/{terminal → web-dapp}/wallet-host.mjs +44 -227
  16. package/dist/adapters/core/actions.js +4 -1
  17. package/dist/adapters/extension/actions.js +6 -2
  18. package/dist/adapters/extension/network-observer.js +13 -0
  19. package/dist/adapters/extension/run.js +115 -31
  20. package/dist/adapters/mobile/actions.js +4 -1
  21. package/dist/adapters/mobile/network-observer.js +68 -0
  22. package/dist/adapters/mobile/performance-observer.js +1 -3
  23. package/dist/adapters/mobile/run.js +32 -9
  24. package/dist/adapters/perps-order-inputs.js +36 -0
  25. package/dist/adapters/plugin-surface.js +98 -0
  26. package/dist/adapters/proof-failure.js +11 -0
  27. package/dist/adapters/surface.js +1 -1
  28. package/dist/adapters/{terminal → web-dapp}/actions.js +11 -8
  29. package/dist/adapters/web-dapp/console-capture.js +63 -0
  30. package/dist/adapters/web-dapp/doctor-checks.js +33 -0
  31. package/dist/adapters/{terminal → web-dapp}/launch.js +21 -19
  32. package/dist/adapters/web-dapp/overlay.js +21 -0
  33. package/dist/adapters/web-dapp/readiness.js +10 -0
  34. package/dist/adapters/{terminal → web-dapp}/run.js +22 -17
  35. package/dist/adapters/{terminal → web-dapp}/stop.js +5 -4
  36. package/dist/adapters/web-dapp/surface.js +149 -0
  37. package/dist/adapters.js +20 -4
  38. package/dist/cli.js +34 -6
  39. package/dist/command-contract.js +29 -7
  40. package/dist/commands/check.js +32 -8
  41. package/dist/commands/completion-candidates.js +5 -5
  42. package/dist/commands/doctor.js +19 -2
  43. package/dist/commands/farmslot-ready.js +1 -0
  44. package/dist/commands/help.js +1 -1
  45. package/dist/commands/launch/device.js +5 -5
  46. package/dist/commands/self-test.js +10 -5
  47. package/dist/engine.js +47 -0
  48. package/dist/funding-execution-context.js +81 -0
  49. package/dist/manifest.js +48 -24
  50. package/dist/mm-harness-cli.js +20 -3
  51. package/dist/performance-observation.js +7 -15
  52. package/dist/recipe-assistance-context.js +6 -7
  53. package/dist/runner.js +28 -8
  54. package/docs/CONTRIBUTING.md +4 -2
  55. package/docs/RECIPES.md +31 -3
  56. package/docs/cli-contract.md +5 -3
  57. package/library/actions/core/perps/_controller.mjs +24 -28
  58. package/library/actions/core/perps/_rewards.mjs +186 -0
  59. package/library/actions/core/perps/close_positions.mjs +1 -1
  60. package/library/actions/core/perps/place_order.mjs +167 -45
  61. package/library/actions/core/perps/quote_fees.mjs +7 -4
  62. package/library/actions/core/wallet/list_accounts.mjs +1 -1
  63. package/library/actions/extension/perps/close_visible_position.mjs +5 -0
  64. package/library/actions/extension/perps/edit_margin.mjs +4 -0
  65. package/library/actions/extension/perps/mutation-receipt.mjs +95 -3
  66. package/library/actions/extension/perps/perps.mjs +320 -3
  67. package/library/actions/extension/perps/place_order.mjs +3 -1
  68. package/library/actions/extension/perps/update_position_tpsl.mjs +4 -0
  69. package/library/actions/extension/platform/cdp.mjs +71 -3
  70. package/library/actions/mobile/perps/perps.mjs +216 -11
  71. package/library/actions/mobile/perps/place_order.mjs +3 -1
  72. package/library/actions/shared/console-collector.mjs +1 -1
  73. package/library/actions/shared/perps/order-policy.mjs +177 -0
  74. package/library/actions/{core/wallet/_fixture-accounts.mjs → shared/wallet/fixture-accounts.mjs} +18 -3
  75. package/library/actions/{terminal → web-dapp}/app/assert_no_console_errors.mjs +4 -4
  76. package/library/actions/{terminal → web-dapp}/app/launch.mjs +8 -7
  77. package/library/actions/{terminal → web-dapp}/platform/hud.mjs +7 -6
  78. package/library/actions/{terminal → web-dapp}/platform/page.mjs +13 -12
  79. package/library/actions/{terminal → web-dapp}/platform/runtime.mjs +16 -12
  80. package/library/actions/web-dapp/ui/navigate.mjs +24 -0
  81. package/library/actions/web-dapp/wallet/_signature-log.mjs +32 -0
  82. package/library/actions/{terminal → web-dapp}/wallet/assert_signatures.mjs +8 -14
  83. package/library/actions/{terminal → web-dapp}/wallet/list_accounts.mjs +2 -2
  84. package/library/actions/web-dapp/wallet/read_signatures.mjs +38 -0
  85. package/library/manifests/core.action-manifest.json +6 -2
  86. package/library/manifests/extension.action-manifest.json +4 -0
  87. package/library/manifests/mobile.action-manifest.json +12 -0
  88. package/library/manifests/{terminal.action-manifest.json → web-dapp.action-manifest.json} +18 -702
  89. package/package.json +6 -6
  90. package/site/assets/help-recipes.json +1 -0
  91. package/adapters/terminal/lib/mainnet-guard.mjs +0 -177
  92. package/adapters/terminal/lib/origin.mjs +0 -30
  93. package/adapters/terminal/lib/page-script.mjs +0 -246
  94. package/adapters/terminal/lib/strict-wallet.mjs +0 -138
  95. package/dist/adapters/terminal/console-capture.js +0 -59
  96. package/dist/adapters/terminal/doctor-checks.js +0 -22
  97. package/dist/adapters/terminal/overlay.js +0 -18
  98. package/dist/adapters/terminal/readiness.js +0 -11
  99. package/dist/adapters/terminal/surface.js +0 -133
  100. package/dist/commands/call.js +0 -671
  101. package/dist/commands/list-executables.js +0 -245
  102. package/dist/commands/manifest.js +0 -477
  103. package/dist/commands/run-engine.js +0 -1301
  104. package/dist/commands/run.js +0 -730
  105. package/dist/discovery.js +0 -15
  106. package/dist/metamask-action-validation.js +0 -68
  107. package/dist/network-observation.js +0 -290
  108. package/library/actions/terminal/perps/_venue.mjs +0 -242
  109. package/library/actions/terminal/perps/assert_orders.mjs +0 -54
  110. package/library/actions/terminal/perps/assert_positions.mjs +0 -40
  111. package/library/actions/terminal/perps/read_orders.mjs +0 -26
  112. package/library/actions/terminal/perps/read_positions.mjs +0 -25
  113. package/library/actions/terminal/perps/read_snapshot.mjs +0 -40
  114. package/library/actions/terminal/perps/revoke_agent.mjs +0 -58
  115. package/library/actions/terminal/perps/teardown_state.mjs +0 -104
  116. package/library/actions/terminal/ui/navigate.mjs +0 -31
  117. package/library/actions/terminal/wallet/_signature-log.mjs +0 -177
  118. package/library/actions/terminal/wallet/read_signatures.mjs +0 -37
@@ -6,7 +6,8 @@ import path from 'node:path';
6
6
  import { pathToFileURL } from 'node:url';
7
7
 
8
8
  import { walletFixturePath } from '../../harness-exports.mjs';
9
- import { readFixtureAccounts } from '../wallet/_fixture-accounts.mjs';
9
+ import { readFixtureAccounts } from '../../shared/wallet/fixture-accounts.mjs';
10
+ import { createRewardsClient, rewardsApiUrl } from './_rewards.mjs';
10
11
 
11
12
  function controllerEntry(projectRoot) {
12
13
  return pathToFileURL(path.join(projectRoot, 'packages/perps-controller/src/index.ts')).href;
@@ -70,7 +71,7 @@ function buildInfrastructure(stubbed, input, rewardsSource) {
70
71
  ...(terminalApi ? { terminalApi } : {}),
71
72
  rewards: {
72
73
  getPerpsDiscountForAccount: (caipAccountId, baseFeeBips) =>
73
- rewardsDiscount(rewardsSource.messenger, caipAccountId, baseFeeBips),
74
+ rewardsSource.messenger.call(REWARDS_DISCOUNT_ACTION, caipAccountId, baseFeeBips),
74
75
  },
75
76
  };
76
77
  }
@@ -79,28 +80,19 @@ function buildInfrastructure(stubbed, input, rewardsSource) {
79
80
  //
80
81
  // The clients answer the rewards dependency through the messenger's
81
82
  // RewardsController:getPerpsDiscountForAccount (extension perps-controller-init,
82
- // mobile mobileInfrastructure). Core has no RewardsController, so the handler
83
- // exists when the node supplies rewards_discount_bips, or when a driver
84
- // registers one on rootMessenger. Without a handler the answer is null: the
85
- // controller's "no discount known yet".
83
+ // mobile mobileInfrastructure). Core has no RewardsController, so the headless
84
+ // wallet registers one: the rewards client (_rewards.mjs) asks the rewards API
85
+ // for the selected fixture account, as Mobile's RewardsController does. A node's
86
+ // rewards_discount_bips replaces that answer, to prove a discount the fixture
87
+ // accounts (non-VIP) cannot get from the backend.
86
88
  //
87
89
  // The controller asks for the discount with the account's CAIP id, built from
88
90
  // the wallet's selected network (NetworkController:getState and
89
- // getNetworkClientById). A supplied discount therefore comes with the network a
90
- // fresh client wallet starts on, Ethereum mainnet; without one the headless
91
- // controller keeps having no NetworkController, as before.
91
+ // getNetworkClientById), so the wallet also answers those with the network a
92
+ // fresh client wallet starts on, Ethereum mainnet.
92
93
 
93
94
  const REWARDS_DISCOUNT_ACTION = 'RewardsController:getPerpsDiscountForAccount';
94
95
 
95
- async function rewardsDiscount(rootMessenger, caipAccountId, baseFeeBips) {
96
- if (!rootMessenger) return null;
97
- try {
98
- return await rootMessenger.call(REWARDS_DISCOUNT_ACTION, caipAccountId, baseFeeBips);
99
- } catch (error) {
100
- if (String(error?.message ?? '').includes(`${REWARDS_DISCOUNT_ACTION} has not been registered`)) return null;
101
- throw error;
102
- }
103
- }
104
96
 
105
97
  /**
106
98
  * The rewards discount a node supplies, in basis points (0-10000), or undefined.
@@ -119,9 +111,13 @@ export function requestedRewardsDiscountBips(input) {
119
111
 
120
112
  const REWARDS_NETWORK_ACTIONS = ['NetworkController:getState', 'NetworkController:getNetworkClientById'];
121
113
 
122
- function registerRewardsHandler(rootMessenger, childMessenger, discountBips) {
123
- if (discountBips === undefined) return;
124
- rootMessenger.registerActionHandler(REWARDS_DISCOUNT_ACTION, async () => discountBips);
114
+ function registerRewardsHandler(rootMessenger, childMessenger, discountBips, rewardsClient) {
115
+ rootMessenger.registerActionHandler(
116
+ REWARDS_DISCOUNT_ACTION,
117
+ discountBips === undefined
118
+ ? (caipAccountId, baseFeeBips) => rewardsClient.getPerpsDiscountForAccount(caipAccountId, baseFeeBips)
119
+ : async () => discountBips,
120
+ );
125
121
  rootMessenger.registerActionHandler('NetworkController:getState', () => ({
126
122
  selectedNetworkClientId: 'mainnet',
127
123
  }));
@@ -228,11 +224,6 @@ async function resolveSignerFromFixture(input) {
228
224
  return { account: selected.signer, address: selected.address };
229
225
  }
230
226
 
231
- /** The address a read acts on (no signing required). */
232
- async function requireAccountAddress(input) {
233
- return (await selectAccount(input)).address;
234
- }
235
-
236
227
  let cached = null;
237
228
 
238
229
  /**
@@ -286,7 +277,8 @@ export function requireRuntimeExport(moduleNamespace, exportName, sourceLabel) {
286
277
  export async function getCoreController(input) {
287
278
  const projectRoot = input.context?.projectRoot;
288
279
  if (!projectRoot) throw new Error('core adapter requires context.projectRoot.');
289
- const accountAddress = await requireAccountAddress(input);
280
+ const selected = await selectAccount(input);
281
+ const accountAddress = selected.address;
290
282
  const network = resolveNetwork(input);
291
283
  const terminalApi = resolveTerminalApi(input);
292
284
  const terminalApiKey = JSON.stringify(terminalApi ?? null);
@@ -341,7 +333,10 @@ export async function getCoreController(input) {
341
333
  parent: rootMessenger,
342
334
  });
343
335
  registerSelectedAccountHandler(rootMessenger, messenger, accountAddress);
344
- registerRewardsHandler(rootMessenger, messenger, rewardsDiscountBips);
336
+ // An address outside the fixture has no signer, so the client answers null:
337
+ // no subscription, as on Mobile for an account that never signed in.
338
+ const rewardsClient = createRewardsClient({ signer: selected.signer, baseUrl: rewardsApiUrl() });
339
+ registerRewardsHandler(rootMessenger, messenger, rewardsDiscountBips, rewardsClient);
345
340
  rewardsSource.messenger = rootMessenger;
346
341
 
347
342
  // Default testnet. Mainnet only on explicit node.network: "mainnet" — mainnet
@@ -386,6 +381,7 @@ export async function getCoreController(input) {
386
381
  accountAddress,
387
382
  terminalApiKey,
388
383
  rewardsDiscountBips,
384
+ rewardsClient,
389
385
  };
390
386
  return { ...cached, accountAddress };
391
387
  }
@@ -0,0 +1,186 @@
1
+ // Headless rewards client for the core Perps controller's `rewards` dependency.
2
+ // It makes the calls Mobile's RewardsController makes to answer
3
+ // getPerpsDiscountForAccount: public opt-in check, mobile-login with a
4
+ // personal_sign over `rewards,<address>,<unix seconds>`, then GET /vip/fees.
5
+ // The session token stays in memory and is never printed. Mobile also gates the
6
+ // call on its remote VIP feature flag (off: null); core has no remote flags, so
7
+ // the client answers as Mobile does with VIP enabled.
8
+
9
+ // Mobile's default outside production builds (METAMASK_ENVIRONMENT=dev on farm
10
+ // slots), from AppConstants.REWARDS_API_URL.UAT.
11
+ export const DEFAULT_REWARDS_API_URL = 'https://rewards.uat-api.cx.metamask.io';
12
+
13
+ const CLIENT_ID = 'mm-harness-core';
14
+
15
+ // Mobile's rewards-data-service DEFAULT_REQUEST_TIMEOUT_MS. A stalled backend
16
+ // must not hold a fee calculation (and the order behind it) open.
17
+ const REQUEST_TIMEOUT_MS = 10_000;
18
+
19
+ export function rewardsApiUrl(env = process.env) {
20
+ return (env.CORE_PERPS_REWARDS_API_URL?.trim() || DEFAULT_REWARDS_API_URL).replace(/\/+$/u, '');
21
+ }
22
+
23
+ /**
24
+ * Mobile's VIP math (RewardsController #getVipPerpsDiscountBips): the backend
25
+ * returns an absolute builder fee, converted to a discount off `baseFeeBips`.
26
+ *
27
+ * @param vipFees - The /vip/fees body.
28
+ * @param baseFeeBips - The perps MetaMask builder base fee in bips.
29
+ * @returns Discount bips; 0 without a VIP fee; null for an unusable fee.
30
+ */
31
+ export function vipDiscountBips(vipFees, baseFeeBips) {
32
+ if (!vipFees?.fees || !(vipFees.vipTier > 0)) return 0;
33
+ const raw = vipFees.fees.hyperliquid?.builderFeeBips;
34
+ if (raw === undefined || raw === null) return 0;
35
+ const builderFeeBips = Number.parseFloat(raw);
36
+ if (!Number.isFinite(builderFeeBips) || builderFeeBips < 0) return null;
37
+ const discount = Math.round(10000 * (1 - builderFeeBips / baseFeeBips));
38
+ return discount >= 0 && discount <= 10000 ? discount : 0;
39
+ }
40
+
41
+ // An error response keeps only its status and the fields the client acts on:
42
+ // the body itself may echo the request, credentials included, and controller
43
+ // logs print error messages.
44
+ class RewardsApiError extends Error {
45
+ constructor(method, route, status, body) {
46
+ super(`rewards API ${method} ${route} returned ${status}.`);
47
+ this.status = status;
48
+ const data = parseErrorBody(body);
49
+ this.invalidTimestamp = /Invalid timestamp/u.test(String(data?.message ?? ''));
50
+ this.serverTimestampMs = Number(data?.serverTimestamp);
51
+ }
52
+ }
53
+
54
+ function parseErrorBody(body) {
55
+ try {
56
+ return JSON.parse(body);
57
+ } catch (error) {
58
+ // A non-JSON error page carries no field the client acts on; the status
59
+ // already says what failed.
60
+ if (error instanceof SyntaxError) return null;
61
+ throw error;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * @param options.signer - viem account of the selected fixture account, or null.
67
+ * @param options.baseUrl - Rewards API base URL.
68
+ * @param options.fetchImpl - fetch, injectable for tests.
69
+ * @param options.now - clock in ms, injectable for tests.
70
+ * @param options.timeoutMs - per-request deadline, headers and body included.
71
+ * @returns The `rewards` platform dependency plus `calls`, one entry per
72
+ * answered request, for action output.
73
+ */
74
+ export function createRewardsClient({
75
+ signer,
76
+ baseUrl,
77
+ fetchImpl = fetch,
78
+ now = Date.now,
79
+ timeoutMs = REQUEST_TIMEOUT_MS,
80
+ }) {
81
+ const calls = [];
82
+ let feesPromise;
83
+
84
+ async function request(method, route, { body, token } = {}) {
85
+ const response = await fetchImpl(`${baseUrl}${route}`, {
86
+ method,
87
+ headers: {
88
+ 'Content-Type': 'application/json',
89
+ 'rewards-client-id': CLIENT_ID,
90
+ ...(token ? { 'rewards-access-token': token } : {}),
91
+ },
92
+ ...(body ? { body: JSON.stringify(body) } : {}),
93
+ signal: AbortSignal.timeout(timeoutMs),
94
+ });
95
+ const text = await response.text();
96
+ if (!response.ok) throw new RewardsApiError(method, route, response.status, text);
97
+ return JSON.parse(text);
98
+ }
99
+
100
+ async function login(address, timestamp, retried = false) {
101
+ const message = `rewards,${address},${timestamp}`;
102
+ const signature = await signer.signMessage({ message });
103
+ try {
104
+ return await request('POST', '/auth/mobile-login', { body: { account: address, timestamp, signature } });
105
+ } catch (error) {
106
+ // Mobile re-signs once with the server clock when the backend rejects ours.
107
+ if (
108
+ !(error instanceof RewardsApiError) || retried || !error.invalidTimestamp ||
109
+ !Number.isFinite(error.serverTimestampMs)
110
+ ) {
111
+ throw error;
112
+ }
113
+ return login(address, Math.floor(error.serverTimestampMs / 1000), true);
114
+ }
115
+ }
116
+
117
+ // Mobile's silent auth: skip login only when the public opt-in check says the
118
+ // account never opted in, and read a 401 from login as not opted in.
119
+ async function openSession(address) {
120
+ let optedIn;
121
+ try {
122
+ const status = await request('POST', '/public/rewards/ois', { body: { addresses: [address] } });
123
+ optedIn = status?.ois?.[0];
124
+ } catch (error) {
125
+ // Mobile logs in anyway when the opt-in check fails: login answers the
126
+ // same question. A failure to reach the backend at all still throws.
127
+ if (!(error instanceof RewardsApiError)) throw error;
128
+ }
129
+ if (optedIn === false) return { optedIn: false };
130
+ let sessionId;
131
+ try {
132
+ ({ sessionId } = await login(address, Math.floor(now() / 1000)));
133
+ } catch (error) {
134
+ if (error instanceof RewardsApiError && error.status === 401) return { optedIn: false };
135
+ throw error;
136
+ }
137
+ if (typeof sessionId !== 'string' || !sessionId) {
138
+ throw new Error('rewards API mobile-login returned no sessionId.');
139
+ }
140
+ return { optedIn: true, token: sessionId };
141
+ }
142
+
143
+ // One login and one /vip/fees read per process (Mobile caches the fees for
144
+ // five minutes; an action process is shorter). The token is fresh, so the
145
+ // 403 re-login Mobile needs for a stored token does not arise here.
146
+ async function vipFees(address) {
147
+ const session = await openSession(address);
148
+ if (!session.optedIn) return session;
149
+ return { optedIn: true, fees: await request('GET', '/vip/fees', { token: session.token }) };
150
+ }
151
+
152
+ async function getPerpsDiscountForAccount(caipAccountId, baseFeeBips) {
153
+ // Lowercase, as Mobile's internal accounts store EVM addresses; the backend
154
+ // matches addresses case-insensitively.
155
+ const address = String(caipAccountId).split(':')[2]?.toLowerCase();
156
+ const record = (entry) => {
157
+ calls.push({ caipAccountId, baseFeeBips, ...entry });
158
+ return entry.discountBips;
159
+ };
160
+ if (!(Number.isFinite(baseFeeBips) && baseFeeBips > 0)) {
161
+ return record({ discountBips: null, reason: 'invalid-base-fee' });
162
+ }
163
+ // Only the selected fixture account can sign in; any other account has no
164
+ // subscription here, which Mobile also answers with null.
165
+ if (!signer || address !== signer.address.toLowerCase()) {
166
+ return record({ discountBips: null, reason: 'no-signer-for-account' });
167
+ }
168
+ feesPromise ??= vipFees(address);
169
+ let result;
170
+ try {
171
+ result = await feesPromise;
172
+ } catch (error) {
173
+ // Not cached: the next fee calculation asks the backend again.
174
+ feesPromise = undefined;
175
+ throw error;
176
+ }
177
+ if (!result.optedIn) return record({ discountBips: null, reason: 'not-opted-in' });
178
+ return record({
179
+ discountBips: vipDiscountBips(result.fees, baseFeeBips),
180
+ vipTier: result.fees.vipTier,
181
+ builderFeeBips: result.fees.fees?.hyperliquid?.builderFeeBips ?? null,
182
+ });
183
+ }
184
+
185
+ return { getPerpsDiscountForAccount, calls };
186
+ }
@@ -19,7 +19,7 @@ function sleep(ms) {
19
19
  return new Promise((resolve) => setTimeout(resolve, ms));
20
20
  }
21
21
 
22
- async function waitForPositionsAbsent(controller, accountAddress, symbols, timeoutMs) {
22
+ export async function waitForPositionsAbsent(controller, accountAddress, symbols, timeoutMs) {
23
23
  const deadline = Date.now() + timeoutMs;
24
24
  let positions = [];
25
25
  for (;;) {
@@ -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,