@deeeed/metamask-harness 0.70.1 → 0.71.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 (73) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/adapters/extension/console-tail.mjs +38 -6
  3. package/adapters/manifest.json +128 -0
  4. package/adapters/terminal/cleanup.mjs +68 -0
  5. package/adapters/terminal/inject.mjs +74 -0
  6. package/adapters/terminal/launch.mjs +628 -0
  7. package/adapters/terminal/lib/cdp-client.mjs +84 -0
  8. package/adapters/terminal/lib/display.mjs +115 -0
  9. package/adapters/terminal/lib/hud.mjs +148 -0
  10. package/adapters/terminal/lib/mainnet-guard.mjs +177 -0
  11. package/adapters/terminal/lib/origin.mjs +30 -0
  12. package/adapters/terminal/lib/page-script.mjs +246 -0
  13. package/adapters/terminal/lib/processes.mjs +187 -0
  14. package/adapters/terminal/lib/readiness.mjs +274 -0
  15. package/adapters/terminal/lib/strict-wallet.mjs +138 -0
  16. package/adapters/terminal/stop.mjs +37 -0
  17. package/adapters/terminal/verify.mjs +48 -0
  18. package/adapters/terminal/wallet-host.mjs +703 -0
  19. package/dist/adapters/console-capture.js +122 -0
  20. package/dist/adapters/extension/console-capture.js +22 -85
  21. package/dist/adapters/surface.js +3 -1
  22. package/dist/adapters/terminal/console-capture.js +46 -0
  23. package/dist/adapters/terminal/stop.js +19 -0
  24. package/dist/adapters/terminal/surface.js +104 -0
  25. package/dist/adapters.js +27 -4
  26. package/dist/cli.js +2 -2
  27. package/dist/command-contract.js +12 -4
  28. package/dist/commands/check.js +1 -1
  29. package/dist/commands/config.js +1 -1
  30. package/dist/commands/doctor.js +24 -1
  31. package/dist/commands/domain.js +1 -1
  32. package/dist/commands/farmslot-ready.js +2 -0
  33. package/dist/commands/help.js +2 -2
  34. package/dist/commands/launch/index.js +97 -2
  35. package/dist/commands/manifest.js +3 -3
  36. package/dist/commands/recipe-advice.js +1 -1
  37. package/dist/commands/review.js +1 -1
  38. package/dist/commands/run-engine.js +1 -1
  39. package/dist/commands/shared.js +1 -1
  40. package/dist/doctor.js +3 -0
  41. package/dist/harness.js +17 -11
  42. package/dist/manifest.js +5 -0
  43. package/dist/mm-harness-cli.js +34 -20
  44. package/dist/paths.js +2 -2
  45. package/dist/recipe-security.js +12 -3
  46. package/dist/review/knowledge.js +1 -1
  47. package/dist/run-diagnostics.js +150 -33
  48. package/dist/runner.js +1 -1
  49. package/dist/runtime-context.js +3 -3
  50. package/docs/CONTRIBUTING.md +1 -1
  51. package/library/actions/shared/console-allowlist-add.mjs +137 -0
  52. package/library/actions/shared/console-collector.mjs +230 -0
  53. package/library/actions/shared/console-findings.mjs +511 -0
  54. package/library/actions/terminal/app/assert_no_console_errors.mjs +119 -0
  55. package/library/actions/terminal/app/launch.mjs +45 -0
  56. package/library/actions/terminal/perps/_venue.mjs +242 -0
  57. package/library/actions/terminal/perps/assert_orders.mjs +54 -0
  58. package/library/actions/terminal/perps/assert_positions.mjs +40 -0
  59. package/library/actions/terminal/perps/read_orders.mjs +26 -0
  60. package/library/actions/terminal/perps/read_positions.mjs +25 -0
  61. package/library/actions/terminal/perps/read_snapshot.mjs +40 -0
  62. package/library/actions/terminal/perps/revoke_agent.mjs +58 -0
  63. package/library/actions/terminal/perps/teardown_state.mjs +104 -0
  64. package/library/actions/terminal/platform/hud.mjs +71 -0
  65. package/library/actions/terminal/platform/page.mjs +247 -0
  66. package/library/actions/terminal/platform/runtime.mjs +200 -0
  67. package/library/actions/terminal/ui/navigate.mjs +31 -0
  68. package/library/actions/terminal/wallet/_signature-log.mjs +177 -0
  69. package/library/actions/terminal/wallet/assert_signatures.mjs +43 -0
  70. package/library/actions/terminal/wallet/list_accounts.mjs +27 -0
  71. package/library/actions/terminal/wallet/read_signatures.mjs +37 -0
  72. package/library/manifests/terminal.action-manifest.json +1786 -0
  73. package/package.json +1 -1
@@ -0,0 +1,242 @@
1
+ // Read and clean up Hyperliquid venue state for a terminal fixture account.
2
+ // Reads use the public info API. Writes (cleanup, agent revoke) are testnet-only,
3
+ // signed in this process by the fixture account through the target checkout's
4
+ // own @nktkas/hyperliquid, and never pass through the browser wallet.
5
+
6
+ import { createRequire } from 'node:module';
7
+ import path from 'node:path';
8
+ import { pathToFileURL } from 'node:url';
9
+
10
+ export const INFO_URLS = Object.freeze({
11
+ testnet: 'https://api.hyperliquid-testnet.xyz/info',
12
+ mainnet: 'https://api.hyperliquid.xyz/info',
13
+ });
14
+
15
+ // Agents the Web Terminal approves are named "mm-terminal" plus an optional
16
+ // " valid_until <ms>" expiry suffix.
17
+ export const TERMINAL_AGENT_PREFIX = 'mm-terminal';
18
+
19
+ export function resolveNetwork(node = {}) {
20
+ const network = node.network == null ? 'testnet' : String(node.network);
21
+ if (network !== 'testnet' && network !== 'mainnet') {
22
+ throw new Error(`network must be testnet or mainnet, got ${JSON.stringify(node.network)}.`);
23
+ }
24
+ return network;
25
+ }
26
+
27
+ export const MAINNET_CONFIRMATION = 'REAL_FUNDS';
28
+
29
+ // Venue mutations default to testnet. Mainnet needs the explicit
30
+ // mainnet_confirmation=REAL_FUNDS on the node, as every library mutation does.
31
+ export function requireTestnet(node = {}, action = 'this action') {
32
+ const network = resolveNetwork(node);
33
+ if (network === 'mainnet' && node.mainnet_confirmation === MAINNET_CONFIRMATION) return network;
34
+ if (network !== 'testnet') {
35
+ throw new Error(
36
+ `${action} mutates venue state; refusing network=${network} without mainnet_confirmation=${MAINNET_CONFIRMATION}.`,
37
+ );
38
+ }
39
+ return network;
40
+ }
41
+
42
+ export async function info(network, body, fetchImpl = globalThis.fetch) {
43
+ const response = await fetchImpl(INFO_URLS[network], {
44
+ method: 'POST',
45
+ headers: { 'content-type': 'application/json' },
46
+ body: JSON.stringify(body),
47
+ });
48
+ if (!response.ok) {
49
+ throw new Error(`Hyperliquid ${network} info ${body.type} returned HTTP ${response.status}.`);
50
+ }
51
+ return response.json();
52
+ }
53
+
54
+ export function normalizePosition(entry) {
55
+ const position = entry?.position ?? entry;
56
+ const size = Number(position.szi);
57
+ return {
58
+ market: String(position.coin).toUpperCase(),
59
+ side: size >= 0 ? 'long' : 'short',
60
+ size: String(Math.abs(size)),
61
+ entryPrice: position.entryPx ?? null,
62
+ // Numeric entry notional in USD, for cap assertions.
63
+ notional: position.entryPx == null ? null : Number((Math.abs(size) * Number(position.entryPx)).toFixed(2)),
64
+ leverage: position.leverage?.value ?? null,
65
+ marginMode: position.leverage?.type ?? null,
66
+ liquidationPrice: position.liquidationPx ?? null,
67
+ };
68
+ }
69
+
70
+ const TRIGGER_TYPES = {
71
+ 'Stop Market': 'stop_market',
72
+ 'Stop Limit': 'stop_limit',
73
+ 'Take Profit Market': 'take_profit_market',
74
+ 'Take Profit Limit': 'take_profit_limit',
75
+ };
76
+
77
+ export function normalizeOrder(order) {
78
+ return {
79
+ orderId: String(order.oid),
80
+ market: String(order.coin).toUpperCase(),
81
+ side: order.side === 'B' ? 'buy' : 'sell',
82
+ price: order.limitPx ?? null,
83
+ size: order.sz ?? null,
84
+ orderType: order.orderType ?? null,
85
+ triggerType: TRIGGER_TYPES[order.orderType] ?? null,
86
+ isTrigger: Boolean(order.isTrigger),
87
+ triggerPrice: order.isTrigger ? order.triggerPx ?? null : null,
88
+ reduceOnly: Boolean(order.reduceOnly),
89
+ isPositionTpsl: Boolean(order.isPositionTpsl),
90
+ timestamp: order.timestamp ?? null,
91
+ };
92
+ }
93
+
94
+ // Only a public EVM address ever leaves this process for the info API.
95
+ export function venueUser(address) {
96
+ const value = String(address ?? '');
97
+ if (!/^0x[0-9a-fA-F]{40}$/u.test(value)) throw new Error('venue reads need a 0x-prefixed 20-byte account address.');
98
+ return `0x${value.slice(2).toLowerCase()}`;
99
+ }
100
+
101
+ export async function readVenueState({ network, user: rawUser, fetchImpl }) {
102
+ const user = venueUser(rawUser);
103
+ const [clearinghouse, openOrders, agents] = await Promise.all([
104
+ info(network, { type: 'clearinghouseState', user }, fetchImpl),
105
+ info(network, { type: 'frontendOpenOrders', user }, fetchImpl),
106
+ info(network, { type: 'extraAgents', user }, fetchImpl),
107
+ ]);
108
+ return {
109
+ network,
110
+ user,
111
+ positions: (clearinghouse.assetPositions ?? []).map(normalizePosition),
112
+ orders: (openOrders ?? []).map(normalizeOrder),
113
+ agents: (agents ?? []).map((agent) => ({
114
+ name: agent.name,
115
+ address: agent.address,
116
+ validUntil: agent.validUntil ?? null,
117
+ })),
118
+ accountValue: clearinghouse.marginSummary?.accountValue ?? null,
119
+ withdrawable: clearinghouse.withdrawable ?? null,
120
+ };
121
+ }
122
+
123
+ // Market selection mirrors the core adapter: market/symbol or markets/symbols,
124
+ // with mode=all selecting every item. Positions match side long|short, orders
125
+ // match the side they would open (buy=long, sell=short).
126
+ export function requestedMarkets(node = {}) {
127
+ const selector = node.selector && typeof node.selector === 'object' ? node.selector : {};
128
+ if ((node.mode ?? selector.mode) === 'all') return null;
129
+ const list = [
130
+ node.market,
131
+ node.symbol,
132
+ selector.market,
133
+ selector.symbol,
134
+ ...(Array.isArray(node.markets) ? node.markets : []),
135
+ ...(Array.isArray(node.symbols) ? node.symbols : []),
136
+ ...(Array.isArray(selector.markets) ? selector.markets : []),
137
+ ...(Array.isArray(selector.symbols) ? selector.symbols : []),
138
+ ]
139
+ .filter((value) => typeof value === 'string' && value.trim())
140
+ .map((value) => value.trim().toUpperCase());
141
+ if (list.length === 0) {
142
+ throw new Error('Select a market (market, markets) or pass mode=all; refusing an implicit account-wide selection.');
143
+ }
144
+ return new Set(list);
145
+ }
146
+
147
+ export function selectItems(node, items, kind) {
148
+ const markets = requestedMarkets(node);
149
+ const rawSide = node.side ?? node.selector?.side;
150
+ const side = rawSide == null ? null : String(rawSide).toLowerCase();
151
+ return items.filter((item) => {
152
+ if (markets && !markets.has(item.market)) return false;
153
+ if (!side) return true;
154
+ if (kind === 'position') return item.side === side;
155
+ return (item.side === 'buy' ? 'long' : 'short') === side;
156
+ });
157
+ }
158
+
159
+ export function expectedOpen(node, action) {
160
+ if (node.state == null) throw new Error(`${action} requires state=open or state=none.`);
161
+ const state = String(node.state).toLowerCase();
162
+ if (state === 'open' || state === 'present') return true;
163
+ if (state === 'none' || state === 'closed' || state === 'absent') return false;
164
+ throw new Error(`${action} received unsupported state: ${state}`);
165
+ }
166
+
167
+ function sameNumber(actual, expected, tolerancePct = 0) {
168
+ const a = Number(actual);
169
+ const e = Number(expected);
170
+ if (!Number.isFinite(a) || !Number.isFinite(e)) return false;
171
+ if (tolerancePct <= 0) return a === e;
172
+ return Math.abs(a - e) <= Math.abs(e) * (tolerancePct / 100);
173
+ }
174
+
175
+ // Order expectations beyond presence; each failing order is reported.
176
+ export function orderExpectationFailures(node, orders) {
177
+ const failures = [];
178
+ const tolerance = Number(node.price_tolerance_pct ?? 0);
179
+ for (const order of orders) {
180
+ if (node.expect_limit_price != null && !sameNumber(order.price, node.expect_limit_price, tolerance)) {
181
+ failures.push(`order ${order.orderId} limit price ${order.price} != ${node.expect_limit_price}`);
182
+ }
183
+ if (node.expect_trigger_price != null && !sameNumber(order.triggerPrice, node.expect_trigger_price, tolerance)) {
184
+ failures.push(`order ${order.orderId} trigger price ${order.triggerPrice} != ${node.expect_trigger_price}`);
185
+ }
186
+ if (node.expect_trigger_order_type != null && order.triggerType !== node.expect_trigger_order_type) {
187
+ failures.push(`order ${order.orderId} trigger type ${order.triggerType} != ${node.expect_trigger_order_type}`);
188
+ }
189
+ if (node.expect_reduce_only != null && order.reduceOnly !== Boolean(node.expect_reduce_only)) {
190
+ failures.push(`order ${order.orderId} reduceOnly ${order.reduceOnly} != ${node.expect_reduce_only}`);
191
+ }
192
+ if (node.expect_size != null && !sameNumber(order.size, node.expect_size)) {
193
+ failures.push(`order ${order.orderId} size ${order.size} != ${node.expect_size}`);
194
+ }
195
+ }
196
+ return failures;
197
+ }
198
+
199
+ export function filterOrderKind(node, orders) {
200
+ let selected = orders;
201
+ if (node.only_trigger_orders === true || node.order_kind === 'trigger') selected = selected.filter((order) => order.isTrigger);
202
+ if (node.order_kind === 'limit') selected = selected.filter((order) => !order.isTrigger);
203
+ if (node.trigger_kind === 'take_profit') selected = selected.filter((order) => order.triggerType?.startsWith('take_profit'));
204
+ if (node.trigger_kind === 'stop_loss') selected = selected.filter((order) => order.triggerType?.startsWith('stop'));
205
+ if (Array.isArray(node.order_ids) && node.order_ids.length > 0) {
206
+ const ids = new Set(node.order_ids.map(String));
207
+ selected = selected.filter((order) => ids.has(order.orderId));
208
+ }
209
+ return selected;
210
+ }
211
+
212
+ export async function pollUntil(read, done, timeoutMs, intervalMs = 750) {
213
+ const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0);
214
+ let value = await read();
215
+ while (!done(value) && Date.now() < deadline) {
216
+ await new Promise((resolve) => setTimeout(resolve, Math.min(intervalMs, Math.max(1, deadline - Date.now()))));
217
+ value = await read();
218
+ }
219
+ return value;
220
+ }
221
+
222
+ // The terminal checkout ships the SDK it trades with; use that exact copy.
223
+ export async function loadTargetHyperliquid(projectRoot) {
224
+ const require = createRequire(path.join(projectRoot, 'package.json'));
225
+ let entry;
226
+ try {
227
+ entry = require.resolve('@nktkas/hyperliquid');
228
+ } catch {
229
+ throw new Error(
230
+ `@nktkas/hyperliquid is not installed in ${projectRoot}.\nNext: npm ci in the terminal checkout.`,
231
+ );
232
+ }
233
+ return import(pathToFileURL(entry).href);
234
+ }
235
+
236
+ // Hyperliquid prices: at most 5 significant figures and (6 - szDecimals)
237
+ // decimals for perps.
238
+ export function venuePrice(raw, szDecimals) {
239
+ const decimals = Math.max(0, 6 - szDecimals);
240
+ const significant = Number(Number(raw).toPrecision(5));
241
+ return String(Number(significant.toFixed(decimals)));
242
+ }
@@ -0,0 +1,54 @@
1
+ // Assert presence, absence, count and field expectations (limit/trigger price,
2
+ // trigger type, reduce-only, size) of the selected venue open orders.
3
+
4
+ import { isDirectRun, resolveAccountAddress, runAdapter } from '../platform/runtime.mjs';
5
+ import {
6
+ expectedOpen,
7
+ filterOrderKind,
8
+ orderExpectationFailures,
9
+ pollUntil,
10
+ readVenueState,
11
+ resolveNetwork,
12
+ selectItems,
13
+ } from './_venue.mjs';
14
+
15
+ export async function assertOrders(input, deps = {}) {
16
+ const node = input.node ?? {};
17
+ const expectOpen = expectedOpen(node, 'metamask.perps.assert_orders');
18
+ const network = resolveNetwork(node);
19
+ const { name, address } = await resolveAccountAddress(input);
20
+ const expectCount = node.expect_count ?? node.expectCount;
21
+ const reached = (matching) => {
22
+ if (!expectOpen) return matching.length === 0;
23
+ const countOk = expectCount == null ? matching.length > 0 : matching.length === Number(expectCount);
24
+ return countOk && orderExpectationFailures(node, matching).length === 0;
25
+ };
26
+ const matching = await pollUntil(
27
+ async () => filterOrderKind(node, selectItems(node, (await readVenueState({ network, user: address, fetchImpl: deps.fetchImpl })).orders, 'order')),
28
+ reached,
29
+ node.timeout_ms,
30
+ );
31
+ if (!reached(matching)) {
32
+ const failures = expectOpen ? orderExpectationFailures(node, matching) : [];
33
+ const wanted = !expectOpen ? 'no' : expectCount == null ? 'at least one' : `exactly ${expectCount}`;
34
+ throw new Error(
35
+ `Expected ${wanted} matching venue order(s), found ${matching.length}` +
36
+ `${failures.length ? `; ${failures.join('; ')}` : ''}: ${JSON.stringify(matching)}`,
37
+ );
38
+ }
39
+ return {
40
+ action: input.action,
41
+ source: 'hyperliquid-info-frontendOpenOrders',
42
+ network,
43
+ account: address,
44
+ accountName: name,
45
+ expectedOpen: expectOpen,
46
+ matchingCount: matching.length,
47
+ expectedCount: expectCount == null ? null : Number(expectCount),
48
+ orders: matching,
49
+ orderIds: matching.map((order) => order.orderId),
50
+ proofPath: 'venue-frontendOpenOrders',
51
+ };
52
+ }
53
+
54
+ if (isDirectRun(import.meta.url)) runAdapter((input) => assertOrders(input));
@@ -0,0 +1,40 @@
1
+ // Assert presence, absence or exact count of the selected venue positions,
2
+ // polling up to timeout_ms for the expected state.
3
+
4
+ import { isDirectRun, resolveAccountAddress, runAdapter } from '../platform/runtime.mjs';
5
+ import { expectedOpen, pollUntil, readVenueState, resolveNetwork, selectItems } from './_venue.mjs';
6
+
7
+ export async function assertPositions(input, deps = {}) {
8
+ const node = input.node ?? {};
9
+ const expectOpen = expectedOpen(node, 'metamask.perps.assert_positions');
10
+ const network = resolveNetwork(node);
11
+ const { name, address } = await resolveAccountAddress(input);
12
+ const expectCount = node.expect_count ?? node.expectCount;
13
+ const reached = (matching) => {
14
+ if (!expectOpen) return matching.length === 0;
15
+ return expectCount == null ? matching.length > 0 : matching.length === Number(expectCount);
16
+ };
17
+ const matching = await pollUntil(
18
+ async () => selectItems(node, (await readVenueState({ network, user: address, fetchImpl: deps.fetchImpl })).positions, 'position'),
19
+ reached,
20
+ node.timeout_ms,
21
+ );
22
+ if (!reached(matching)) {
23
+ const wanted = !expectOpen ? 'no' : expectCount == null ? 'at least one' : `exactly ${expectCount}`;
24
+ throw new Error(`Expected ${wanted} matching venue position(s), found ${matching.length}: ${JSON.stringify(matching)}`);
25
+ }
26
+ return {
27
+ action: input.action,
28
+ source: 'hyperliquid-info-clearinghouseState',
29
+ network,
30
+ account: address,
31
+ accountName: name,
32
+ expectedOpen: expectOpen,
33
+ matchingCount: matching.length,
34
+ expectedCount: expectCount == null ? null : Number(expectCount),
35
+ positions: matching,
36
+ proofPath: 'venue-clearinghouseState',
37
+ };
38
+ }
39
+
40
+ if (isDirectRun(import.meta.url)) runAdapter((input) => assertPositions(input));
@@ -0,0 +1,26 @@
1
+ // Read the fixture account's Hyperliquid open orders for the selected markets.
2
+
3
+ import { isDirectRun, resolveAccountAddress, runAdapter } from '../platform/runtime.mjs';
4
+ import { filterOrderKind, readVenueState, resolveNetwork, selectItems } from './_venue.mjs';
5
+
6
+ export async function readOrders(input, deps = {}) {
7
+ const node = input.node ?? {};
8
+ const network = resolveNetwork(node);
9
+ const { name, address } = await resolveAccountAddress(input);
10
+ const state = await readVenueState({ network, user: address, fetchImpl: deps.fetchImpl });
11
+ const matching = filterOrderKind(node, selectItems(node, state.orders, 'order'));
12
+ return {
13
+ action: input.action,
14
+ source: 'hyperliquid-info-frontendOpenOrders',
15
+ network,
16
+ account: address,
17
+ accountName: name,
18
+ count: state.orders.length,
19
+ matchingCount: matching.length,
20
+ orders: matching,
21
+ orderIds: matching.map((order) => order.orderId),
22
+ proofPath: 'venue-frontendOpenOrders',
23
+ };
24
+ }
25
+
26
+ if (isDirectRun(import.meta.url)) runAdapter((input) => readOrders(input));
@@ -0,0 +1,25 @@
1
+ // Read the fixture account's Hyperliquid positions for the selected markets.
2
+
3
+ import { isDirectRun, resolveAccountAddress, runAdapter } from '../platform/runtime.mjs';
4
+ import { readVenueState, resolveNetwork, selectItems } from './_venue.mjs';
5
+
6
+ export async function readPositions(input, deps = {}) {
7
+ const node = input.node ?? {};
8
+ const network = resolveNetwork(node);
9
+ const { name, address } = await resolveAccountAddress(input);
10
+ const state = await readVenueState({ network, user: address, fetchImpl: deps.fetchImpl });
11
+ const matching = selectItems(node, state.positions, 'position');
12
+ return {
13
+ action: input.action,
14
+ source: 'hyperliquid-info-clearinghouseState',
15
+ network,
16
+ account: address,
17
+ accountName: name,
18
+ count: state.positions.length,
19
+ matchingCount: matching.length,
20
+ positions: matching,
21
+ proofPath: 'venue-clearinghouseState',
22
+ };
23
+ }
24
+
25
+ if (isDirectRun(import.meta.url)) runAdapter((input) => readPositions(input));
@@ -0,0 +1,40 @@
1
+ // Snapshot the fixture account's venue state: positions, open orders, agents and
2
+ // balances, optionally scoped to markets. Used for before/after comparison.
3
+
4
+ import { isDirectRun, resolveAccountAddress, runAdapter } from '../platform/runtime.mjs';
5
+ import { readVenueState, resolveNetwork, selectItems } from './_venue.mjs';
6
+
7
+ const MARKET_KEYS = ['market', 'markets', 'symbol', 'symbols'];
8
+
9
+ // The snapshot covers every market unless the node names some, through any of
10
+ // the selector spellings selectItems accepts.
11
+ export function snapshotSelector(raw = {}) {
12
+ const node = { ...raw };
13
+ const selector = node.selector && typeof node.selector === 'object' ? node.selector : {};
14
+ const named = MARKET_KEYS.some((key) => node[key] != null || selector[key] != null);
15
+ if (node.mode == null && selector.mode == null && !named) node.mode = 'all';
16
+ return node;
17
+ }
18
+
19
+ export async function readSnapshot(input, deps = {}) {
20
+ const node = snapshotSelector(input.node ?? {});
21
+ const network = resolveNetwork(node);
22
+ const { name, address } = await resolveAccountAddress(input);
23
+ const state = await readVenueState({ network, user: address, fetchImpl: deps.fetchImpl });
24
+ return {
25
+ action: input.action,
26
+ source: 'hyperliquid-info',
27
+ network,
28
+ account: address,
29
+ accountName: name,
30
+ at: new Date().toISOString(),
31
+ positions: selectItems(node, state.positions, 'position'),
32
+ orders: selectItems(node, state.orders, 'order'),
33
+ agents: state.agents,
34
+ accountValue: state.accountValue,
35
+ withdrawable: state.withdrawable,
36
+ proofPath: 'venue-snapshot',
37
+ };
38
+ }
39
+
40
+ if (isDirectRun(import.meta.url)) runAdapter((input) => readSnapshot(input));
@@ -0,0 +1,58 @@
1
+ // Lifecycle step (testnet; mainnet only with mainnet_confirmation=REAL_FUNDS): revoke the Web Terminal's Hyperliquid agent(s) for the
2
+ // fixture account outside the UI (approveAgent to the zero address under the
3
+ // same name, user-signed in Node), then assert none is left. The next UI write
4
+ // must therefore ask the wallet for a fresh ApproveAgent.
5
+
6
+ import { accountName, fixtureAccount, isDirectRun, readBrowserState, runAdapter } from '../platform/runtime.mjs';
7
+ import { TERMINAL_AGENT_PREFIX, loadTargetHyperliquid, pollUntil, readVenueState, requireTestnet } from './_venue.mjs';
8
+
9
+ const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';
10
+
11
+ export function agentBaseName(name) {
12
+ return String(name).replace(/\s+valid_until\s+\d+$/u, '');
13
+ }
14
+
15
+ export function terminalAgents(agents, prefix = TERMINAL_AGENT_PREFIX) {
16
+ return agents.filter((agent) => agentBaseName(agent.name) === prefix);
17
+ }
18
+
19
+ export async function revokeAgent(input, deps = {}) {
20
+ const node = input.node ?? {};
21
+ const network = requireTestnet(node, 'metamask.perps.revoke_agent');
22
+ const prefix = node.agent_name ?? TERMINAL_AGENT_PREFIX;
23
+ const projectRoot = input.context?.projectRoot;
24
+ const name = accountName(input, await readBrowserState(projectRoot));
25
+ const account = deps.account ?? (await fixtureAccount(projectRoot, name));
26
+ const read = () => readVenueState({ network, user: account.address, fetchImpl: deps.fetchImpl });
27
+ const before = terminalAgents((await read()).agents, prefix);
28
+ const responses = [];
29
+ if (before.length > 0) {
30
+ const hl = deps.hl ?? (await loadTargetHyperliquid(projectRoot));
31
+ const exchange = new hl.ExchangeClient({ wallet: account, transport: new hl.HttpTransport({ isTestnet: network === 'testnet' }) });
32
+ for (const agentName of new Set(before.map((agent) => agentBaseName(agent.name)))) {
33
+ const response = await exchange.approveAgent({ agentAddress: ZERO_ADDRESS, agentName });
34
+ responses.push({ agentName, status: response?.status ?? null });
35
+ }
36
+ }
37
+ const after = await pollUntil(
38
+ async () => terminalAgents((await read()).agents, prefix),
39
+ (agents) => agents.length === 0,
40
+ node.timeout_ms ?? 20000,
41
+ );
42
+ if (after.length > 0) {
43
+ throw new Error(`terminal agent still approved after revoke: ${JSON.stringify(after.map((agent) => agent.address))}`);
44
+ }
45
+ return {
46
+ action: input.action,
47
+ source: 'hyperliquid-exchange-fixture-signer',
48
+ network,
49
+ account: account.address,
50
+ accountName: name,
51
+ revokedAgents: before.map((agent) => ({ name: agent.name, address: agent.address })),
52
+ responses,
53
+ remainingAgents: after.length,
54
+ proofPath: 'venue-extraAgents',
55
+ };
56
+ }
57
+
58
+ if (isDirectRun(import.meta.url)) runAdapter((input) => revokeAgent(input));
@@ -0,0 +1,104 @@
1
+ // Testnet cleanup for one reserved market: cancel its open orders (or only the
2
+ // listed order_ids) and close its position with a reduce-only IOC order, signed
3
+ // in Node by the fixture account, then assert the requested end state. Testnet by
4
+ // default; mainnet only with mainnet_confirmation=REAL_FUNDS. Refuses
5
+ // account-wide selection.
6
+
7
+ import { fixtureAccount, isDirectRun, readBrowserState, accountName, runAdapter } from '../platform/runtime.mjs';
8
+ import {
9
+ filterOrderKind,
10
+ info,
11
+ loadTargetHyperliquid,
12
+ pollUntil,
13
+ readVenueState,
14
+ requireTestnet,
15
+ requestedMarkets,
16
+ selectItems,
17
+ venuePrice,
18
+ } from './_venue.mjs';
19
+
20
+ const CLOSE_SLIPPAGE = 0.05;
21
+
22
+ function wantsNone(spec, label) {
23
+ if (spec == null) return false;
24
+ const state = String(spec.state ?? spec).toLowerCase();
25
+ if (state === 'none' || state === 'closed' || state === 'absent') return true;
26
+ throw new Error(`metamask.perps.teardown_state only converges ${label} to state=none, got ${state}.`);
27
+ }
28
+
29
+ export async function teardownState(input, deps = {}) {
30
+ const node = input.node ?? {};
31
+ const network = requireTestnet(node, 'metamask.perps.teardown_state');
32
+ const markets = requestedMarkets(node);
33
+ if (!markets) throw new Error('metamask.perps.teardown_state requires an explicit market; mode=all is refused.');
34
+ const closePositions = wantsNone(node.positions ?? { state: 'none' }, 'positions');
35
+ const cancelOrders = wantsNone(node.orders ?? { state: 'none' }, 'orders');
36
+ const projectRoot = input.context?.projectRoot;
37
+ const name = accountName(input, await readBrowserState(projectRoot));
38
+ const account = deps.account ?? (await fixtureAccount(projectRoot, name));
39
+ const user = account.address;
40
+ const read = () => readVenueState({ network, user, fetchImpl: deps.fetchImpl });
41
+ const before = await read();
42
+ const orders = cancelOrders ? filterOrderKind(node, selectItems(node, before.orders, 'order')) : [];
43
+ const positions = closePositions ? selectItems(node, before.positions, 'position') : [];
44
+ const actions = { canceledOrderIds: [], closedPositions: [], responses: [] };
45
+
46
+ if (orders.length > 0 || positions.length > 0) {
47
+ const hl = deps.hl ?? (await loadTargetHyperliquid(projectRoot));
48
+ const exchange = new hl.ExchangeClient({ wallet: account, transport: new hl.HttpTransport({ isTestnet: network === 'testnet' }) });
49
+ const [meta, mids] = await Promise.all([
50
+ info(network, { type: 'meta' }, deps.fetchImpl),
51
+ info(network, { type: 'allMids' }, deps.fetchImpl),
52
+ ]);
53
+ const assetOf = (market) => {
54
+ const index = meta.universe.findIndex((asset) => asset.name.toUpperCase() === market);
55
+ if (index < 0) throw new Error(`market ${market} is not in the ${network} perps universe.`);
56
+ return { index, szDecimals: meta.universe[index].szDecimals };
57
+ };
58
+ if (orders.length > 0) {
59
+ const response = await exchange.cancel({
60
+ cancels: orders.map((order) => ({ a: assetOf(order.market).index, o: Number(order.orderId) })),
61
+ });
62
+ actions.responses.push({ cancel: response?.response?.data ?? response });
63
+ actions.canceledOrderIds.push(...orders.map((order) => order.orderId));
64
+ }
65
+ for (const position of positions) {
66
+ const { index, szDecimals } = assetOf(position.market);
67
+ const mid = Number(mids[position.market]);
68
+ if (!Number.isFinite(mid)) throw new Error(`no ${network} mid price for ${position.market}.`);
69
+ const isBuy = position.side === 'short';
70
+ const price = venuePrice(mid * (isBuy ? 1 + CLOSE_SLIPPAGE : 1 - CLOSE_SLIPPAGE), szDecimals);
71
+ const response = await exchange.order({
72
+ orders: [{ a: index, b: isBuy, p: price, s: position.size, r: true, t: { limit: { tif: 'Ioc' } } }],
73
+ grouping: 'na',
74
+ });
75
+ actions.responses.push({ close: response?.response?.data ?? response });
76
+ actions.closedPositions.push({ market: position.market, side: position.side, size: position.size });
77
+ }
78
+ }
79
+
80
+ const done = (state) =>
81
+ (!cancelOrders || filterOrderKind(node, selectItems(node, state.orders, 'order')).length === 0) &&
82
+ (!closePositions || selectItems(node, state.positions, 'position').length === 0);
83
+ const after = await pollUntil(read, done, node.timeout_ms ?? 30000);
84
+ const remaining = {
85
+ orders: cancelOrders ? filterOrderKind(node, selectItems(node, after.orders, 'order')) : [],
86
+ positions: closePositions ? selectItems(node, after.positions, 'position') : [],
87
+ };
88
+ if (!done(after)) {
89
+ throw new Error(`testnet cleanup left state behind for ${[...markets].join(',')}: ${JSON.stringify(remaining)}`);
90
+ }
91
+ return {
92
+ action: input.action,
93
+ source: 'hyperliquid-exchange-fixture-signer',
94
+ network,
95
+ account: user,
96
+ accountName: name,
97
+ markets: [...markets],
98
+ ...actions,
99
+ remaining,
100
+ proofPath: 'venue-cleanup',
101
+ };
102
+ }
103
+
104
+ if (isDirectRun(import.meta.url)) runAdapter((input) => teardownState(input));
@@ -0,0 +1,71 @@
1
+ // app.hud for the terminal: fold the runner's progress update into
2
+ // <runtime>/hud.json and draw it on the app tab now. The wallet host redraws
3
+ // the same state after every navigation. The HUD is advisory: a missing
4
+ // browser or a busy tab skips the update instead of failing the recipe.
5
+
6
+ import { clearHudState, hudRenderExpression, nextHudState, readHudState, writeHudState } from '../../../../adapters/terminal/lib/hud.mjs';
7
+ import { isAppUrl } from '../../../../adapters/terminal/lib/origin.mjs';
8
+ import { appOrigin, cdpPort, readBrowserState, terminalRuntimeDir } from './runtime.mjs';
9
+ import { importRecipeHarnessRuntimeCdp } from '../../harness-exports.mjs';
10
+
11
+ // --hud show draws in any mode; --hud auto (the default) only for a visible
12
+ // browser. --hud hide never reaches this handler.
13
+ export function hudVisible(policy, browserState) {
14
+ if (policy === 'show') return true;
15
+ return browserState?.browser?.headless !== true;
16
+ }
17
+
18
+ // Every CDP step is bounded: a busy tab must never stall the recipe.
19
+ export function withDeadline(promise, timeoutMs, label) {
20
+ let timer;
21
+ const timeout = new Promise((_, reject) => {
22
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
23
+ });
24
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
25
+ }
26
+
27
+ async function drawOnAppTabs(input, expression, timeoutMs = 3000) {
28
+ const { CdpSession, listCdpTargets } = await importRecipeHarnessRuntimeCdp();
29
+ const origin = appOrigin(input);
30
+ const targets = (await withDeadline(listCdpTargets('127.0.0.1', cdpPort(input)), timeoutMs, 'HUD target list'))
31
+ .filter((target) => target.type === 'page' && isAppUrl(target.url, origin));
32
+ const results = [];
33
+ for (const target of targets) {
34
+ const connecting = CdpSession.connect(target.webSocketDebuggerUrl, { timeoutMs });
35
+ let session;
36
+ try {
37
+ session = await withDeadline(connecting, timeoutMs, 'HUD connect');
38
+ const response = await withDeadline(session.call('Runtime.evaluate', { expression, returnByValue: true }), timeoutMs, 'HUD draw');
39
+ results.push(response?.result?.value ?? null);
40
+ } finally {
41
+ if (session) session.close();
42
+ // A connect that lands after its deadline is closed as soon as it opens.
43
+ else connecting.then((late) => late.close(), () => {});
44
+ }
45
+ }
46
+ return results;
47
+ }
48
+
49
+ export async function showTerminalHud(input, { hudPolicy = 'auto' } = {}) {
50
+ const node = input.node ?? {};
51
+ const projectRoot = input.context?.projectRoot;
52
+ const nodeId = input.context?.nodeId ?? '';
53
+ const browserState = projectRoot ? await readBrowserState(projectRoot) : null;
54
+ if (!browserState) return { hud: false, skipped: true, reason: 'no slot browser yet' };
55
+ if (!hudVisible(hudPolicy, browserState)) return { hud: false, skipped: true, reason: 'headless browser (--hud show draws anyway)' };
56
+ const runtimeDir = terminalRuntimeDir(projectRoot);
57
+ let state = null;
58
+ if (node.clear === true) {
59
+ clearHudState(runtimeDir);
60
+ } else {
61
+ state = nextHudState(readHudState(runtimeDir), node, nodeId);
62
+ writeHudState(runtimeDir, state);
63
+ }
64
+ try {
65
+ const drawn = await drawOnAppTabs(input, hudRenderExpression(state, appOrigin(input)));
66
+ return { hud: Boolean(state), seq: state?.seq ?? null, status: node.status ?? 'running', tabs: drawn.length };
67
+ } catch (error) {
68
+ process.stderr.write(`app.hud skipped (slot browser busy or gone): ${error?.message ?? error}\n`);
69
+ return { hud: false, skipped: true, seq: state?.seq ?? null, reason: String(error?.message ?? error) };
70
+ }
71
+ }