@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
|
@@ -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 '
|
|
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
|
-
|
|
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
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
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)
|
|
90
|
-
// fresh client wallet starts on, Ethereum mainnet
|
|
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
|
-
|
|
124
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|