@deeeed/metamask-harness 0.2.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 (158) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/README.md +140 -0
  3. package/bin/mm-harness +99 -0
  4. package/docs/CHEATSHEET.md +61 -0
  5. package/docs/CLI-SPEC.md +915 -0
  6. package/docs/MENTAL-MODEL.md +295 -0
  7. package/docs/architecture.md +367 -0
  8. package/docs/extension-runtime-commands.md +60 -0
  9. package/docs/harness-cli.md +43 -0
  10. package/docs/live-adapter-contract.md +188 -0
  11. package/docs/package-boundaries.md +47 -0
  12. package/docs/perps-flow-catalog.md +235 -0
  13. package/docs/recipe-libraries.md +95 -0
  14. package/docs/runtime-file-conventions.md +36 -0
  15. package/library/actions/core/perps/_controller.mjs +727 -0
  16. package/library/actions/core/perps/assert_orders.mjs +53 -0
  17. package/library/actions/core/perps/assert_positions.mjs +52 -0
  18. package/library/actions/core/perps/close_orders.mjs +97 -0
  19. package/library/actions/core/perps/close_positions.mjs +118 -0
  20. package/library/actions/core/perps/ensure_orders.mjs +40 -0
  21. package/library/actions/core/perps/ensure_positions.mjs +37 -0
  22. package/library/actions/core/perps/place_order.mjs +201 -0
  23. package/library/actions/core/perps/read_account.mjs +30 -0
  24. package/library/actions/core/perps/read_orders.mjs +27 -0
  25. package/library/actions/core/perps/read_positions.mjs +27 -0
  26. package/library/actions/core/perps/start_state.mjs +92 -0
  27. package/library/actions/core/perps/teardown_state.mjs +86 -0
  28. package/library/actions/extension/perps/assert_orders.mjs +11 -0
  29. package/library/actions/extension/perps/assert_positions.mjs +11 -0
  30. package/library/actions/extension/perps/close_orders.mjs +8 -0
  31. package/library/actions/extension/perps/close_positions.mjs +8 -0
  32. package/library/actions/extension/perps/ensure_orders.mjs +4 -0
  33. package/library/actions/extension/perps/ensure_positions.mjs +4 -0
  34. package/library/actions/extension/perps/perps.mjs +730 -0
  35. package/library/actions/extension/perps/place_order.mjs +7 -0
  36. package/library/actions/extension/perps/read_orders.mjs +4 -0
  37. package/library/actions/extension/perps/read_positions.mjs +3 -0
  38. package/library/actions/extension/platform/cdp.mjs +541 -0
  39. package/library/actions/extension/ui/navigate.mjs +44 -0
  40. package/library/actions/extension/wallet/ensure_unlocked.mjs +36 -0
  41. package/library/actions/extension/wallet/read_state.mjs +27 -0
  42. package/library/actions/extension/wallet/select_account.mjs +48 -0
  43. package/library/actions/extension/wallet/setup.mjs +35 -0
  44. package/library/actions/mobile/app-overlay/app/dev-tools/AgenticService/AgentStepHud.tsx.patch +185 -0
  45. package/library/actions/mobile/app-overlay/app/dev-tools/AgenticService/AgenticService.ts.patch +1662 -0
  46. package/library/actions/mobile/bridge-runtime/cdp-bridge.cjs +686 -0
  47. package/library/actions/mobile/bridge-runtime/lib/cdp-eval.cjs +110 -0
  48. package/library/actions/mobile/bridge-runtime/lib/config.cjs +39 -0
  49. package/library/actions/mobile/bridge-runtime/lib/issue-capture.cjs +446 -0
  50. package/library/actions/mobile/bridge-runtime/lib/target-discovery.cjs +204 -0
  51. package/library/actions/mobile/bridge-runtime/lib/ws-client.cjs +108 -0
  52. package/library/actions/mobile/bridge-runtime/setup-wallet.sh +442 -0
  53. package/library/actions/mobile/perps/assert_orders.mjs +11 -0
  54. package/library/actions/mobile/perps/assert_positions.mjs +11 -0
  55. package/library/actions/mobile/perps/close_orders.mjs +8 -0
  56. package/library/actions/mobile/perps/close_positions.mjs +8 -0
  57. package/library/actions/mobile/perps/ensure_orders.mjs +4 -0
  58. package/library/actions/mobile/perps/ensure_positions.mjs +4 -0
  59. package/library/actions/mobile/perps/perps.mjs +709 -0
  60. package/library/actions/mobile/perps/place_order.mjs +7 -0
  61. package/library/actions/mobile/perps/read_orders.mjs +4 -0
  62. package/library/actions/mobile/perps/read_positions.mjs +3 -0
  63. package/library/actions/mobile/platform/bridge.mjs +283 -0
  64. package/library/actions/mobile/ui/navigate.mjs +38 -0
  65. package/library/actions/mobile/wallet/ensure_unlocked.mjs +107 -0
  66. package/library/actions/mobile/wallet/home.mjs +35 -0
  67. package/library/actions/mobile/wallet/read_state.mjs +40 -0
  68. package/library/actions/mobile/wallet/select_account.mjs +48 -0
  69. package/library/actions/mobile/wallet/setup.mjs +220 -0
  70. package/library/flows/perps.flows.json +64 -0
  71. package/library/library.json +7 -0
  72. package/library/manifests/core.action-manifest.json +1282 -0
  73. package/library/manifests/extension.action-manifest.json +1749 -0
  74. package/library/manifests/mobile.action-manifest.json +1753 -0
  75. package/library/recipes/action-validation.extension.recipe.json +417 -0
  76. package/library/recipes/action-validation.mobile.recipe.json +422 -0
  77. package/library/recipes/order-lifecycle.core.recipe.json +78 -0
  78. package/library/recipes/perps-lifecycle.recipe.json +194 -0
  79. package/library/recipes/read-markets.core.recipe.json +38 -0
  80. package/library/recipes/smoke.extension.recipe.json +31 -0
  81. package/library/recipes/smoke.mobile.recipe.json +31 -0
  82. package/library/recipes/trading-lifecycle.core.recipe.json +76 -0
  83. package/orchestration/compat-overlays/README.md +19 -0
  84. package/orchestration/compat-overlays/mobile/README.md +13 -0
  85. package/orchestration/compat-overlays/mobile/rn81-message-event-source.patch +42 -0
  86. package/orchestration/core/cleanup.sh +37 -0
  87. package/orchestration/core/inject.sh +154 -0
  88. package/orchestration/doctor.mjs +72 -0
  89. package/orchestration/extension/cleanup.mjs +60 -0
  90. package/orchestration/extension/console-tail.mjs +228 -0
  91. package/orchestration/extension/ensure-browser.sh +416 -0
  92. package/orchestration/extension/ensure-ready.ts +185 -0
  93. package/orchestration/extension/extension-id.ts +107 -0
  94. package/orchestration/extension/inject.mjs +266 -0
  95. package/orchestration/extension/launch-browser.cjs +216 -0
  96. package/orchestration/extension/launch.sh +175 -0
  97. package/orchestration/extension/live.sh +320 -0
  98. package/orchestration/extension/pin-remote-flags.cjs +45 -0
  99. package/orchestration/extension/readiness.mjs +414 -0
  100. package/orchestration/extension/refresh-build.sh +190 -0
  101. package/orchestration/extension/runtime-decision.ts +445 -0
  102. package/orchestration/extension/runtime.ts +407 -0
  103. package/orchestration/extension/seed-fixture.sh +177 -0
  104. package/orchestration/extension/sidepanel-toggle.sh +291 -0
  105. package/orchestration/extension/snapshot-dist.sh +84 -0
  106. package/orchestration/extension/start-watch.sh +339 -0
  107. package/orchestration/extension/wallet-fixture-state.cjs +1086 -0
  108. package/orchestration/lib/activate-repo-node.sh +144 -0
  109. package/orchestration/lib/cli-color.mjs +84 -0
  110. package/orchestration/lib/cli-commands.mjs +243 -0
  111. package/orchestration/lib/cli-home.mjs +354 -0
  112. package/orchestration/lib/cli-ux.sh +252 -0
  113. package/orchestration/lib/cli-version.mjs +123 -0
  114. package/orchestration/lib/ensure-runner-deps.sh +56 -0
  115. package/orchestration/lib/harness-path.sh +55 -0
  116. package/orchestration/lib/hash-helpers.sh +44 -0
  117. package/orchestration/lib/json-field.sh +23 -0
  118. package/orchestration/lib/log-tui.mjs +304 -0
  119. package/orchestration/lib/open-debug.mjs +317 -0
  120. package/orchestration/lib/path-defaults.json +4 -0
  121. package/orchestration/lib/progress.mjs +107 -0
  122. package/orchestration/lib/recipe-paths.mjs +26 -0
  123. package/orchestration/lib/resolve-farmslot-ports.sh +144 -0
  124. package/orchestration/manifest.json +358 -0
  125. package/orchestration/mobile/cleanup.sh +192 -0
  126. package/orchestration/mobile/deps-markers.ts +21 -0
  127. package/orchestration/mobile/inject.sh +681 -0
  128. package/orchestration/mobile/launch.sh +137 -0
  129. package/orchestration/mobile/live.sh +125 -0
  130. package/orchestration/mobile/runtime-decision.ts +292 -0
  131. package/orchestration/porcelain/metamask-recipe +99 -0
  132. package/orchestration/porcelain/mm-recipe +1591 -0
  133. package/orchestration/porcelain/mme-recipe +1181 -0
  134. package/package.json +59 -0
  135. package/runner/extension/verify.sh +511 -0
  136. package/runner/mobile/verify.sh +501 -0
  137. package/runner/src/adapters.ts +601 -0
  138. package/runner/src/cli.ts +1820 -0
  139. package/runner/src/commands/debug.ts +44 -0
  140. package/runner/src/commands/fixtures.ts +99 -0
  141. package/runner/src/commands/launch.ts +397 -0
  142. package/runner/src/commands/logs.ts +60 -0
  143. package/runner/src/commands/shared.ts +138 -0
  144. package/runner/src/completions-cache.ts +86 -0
  145. package/runner/src/doctor.ts +203 -0
  146. package/runner/src/harness.ts +516 -0
  147. package/runner/src/heal-bounds.ts +179 -0
  148. package/runner/src/index.ts +6 -0
  149. package/runner/src/live-adapter-contract.ts +274 -0
  150. package/runner/src/manifest.ts +47 -0
  151. package/runner/src/mm-harness-cli.ts +488 -0
  152. package/runner/src/paths.ts +198 -0
  153. package/runner/src/recording-target.ts +147 -0
  154. package/runner/src/run-recording.ts +329 -0
  155. package/runner/src/runner.ts +108 -0
  156. package/runner/src/types.ts +57 -0
  157. package/scripts/completions.sh +125 -0
  158. package/scripts/install-completions.sh +62 -0
@@ -0,0 +1,709 @@
1
+ import { pathToFileURL } from 'node:url';
2
+ import { evalAsync, navigate, runAdapter } from '../platform/bridge.mjs';
3
+
4
+ function sleep(ms) {
5
+ return new Promise((resolve) => setTimeout(resolve, ms));
6
+ }
7
+
8
+ function marketSymbol(input) {
9
+ return normalizeMarketSymbol(input.node?.market ?? input.node?.symbol ?? 'BTC');
10
+ }
11
+
12
+ function normalizeMarketSymbol(rawSymbol) {
13
+ const raw = String(rawSymbol);
14
+ if (raw.includes(':')) {
15
+ const [source, ...symbolParts] = raw.split(':');
16
+ return `${source.toLowerCase()}:${symbolParts.join(':').toUpperCase()}`;
17
+ }
18
+ return raw.toUpperCase();
19
+ }
20
+
21
+ function symbolForItem(item) {
22
+ return normalizeMarketSymbol(item?.symbol ?? item?.coin ?? '');
23
+ }
24
+
25
+ function sideForItem(item) {
26
+ return String(item?.side ?? item?.direction ?? '').toLowerCase();
27
+ }
28
+
29
+ function configuredSymbols(input, items) {
30
+ const selector = input.node?.selector && typeof input.node.selector === 'object' ? input.node.selector : {};
31
+ const mode = String(input.node?.mode ?? selector.mode ?? 'matching').toLowerCase();
32
+ if (mode === 'all') return uniqueSymbols(items.map(symbolForItem).filter(Boolean));
33
+ const explicit = input.node?.markets ?? input.node?.symbols ?? selector.markets ?? selector.symbols;
34
+ if (Array.isArray(explicit) && explicit.length > 0) return uniqueSymbols(explicit.map(normalizeMarketSymbol));
35
+ if (typeof explicit === 'string' && explicit.length > 0) return uniqueSymbols(explicit.split(',').map((part) => normalizeMarketSymbol(part.trim())).filter(Boolean));
36
+ return [marketSymbol(input)];
37
+ }
38
+
39
+ function selectedItems(input, items) {
40
+ const symbols = new Set(configuredSymbols(input, items));
41
+ const selector = input.node?.selector && typeof input.node.selector === 'object' ? input.node.selector : {};
42
+ const side = input.node?.side ?? selector.side;
43
+ return items.filter((item) => {
44
+ if (!symbols.has(symbolForItem(item))) return false;
45
+ if (side && sideForItem(item) && sideForItem(item) !== String(side).toLowerCase()) return false;
46
+ return true;
47
+ });
48
+ }
49
+
50
+ function uniqueSymbols(symbols) {
51
+ return Array.from(new Set(symbols.filter(Boolean)));
52
+ }
53
+
54
+ async function readPositions(input) {
55
+ const positions = await evalAsync(
56
+ input,
57
+ `(function(){
58
+ var controller = Engine && Engine.context && Engine.context.PerpsController;
59
+ if (!controller || typeof controller.getPositions !== 'function') {
60
+ throw new Error('Engine.context.PerpsController.getPositions is unavailable; cannot assert live Perps positions.');
61
+ }
62
+ return controller.getPositions().then(function(r){
63
+ if (!Array.isArray(r)) throw new Error('PerpsController.getPositions returned a non-array result.');
64
+ return JSON.stringify(r);
65
+ });
66
+ })()`,
67
+ );
68
+ if (!Array.isArray(positions)) throw new Error('PerpsController.getPositions returned a non-array result.');
69
+ return positions;
70
+ }
71
+
72
+ async function readOpenOrders(input) {
73
+ const orders = await evalAsync(
74
+ input,
75
+ `(function(){
76
+ var controller = Engine && Engine.context && Engine.context.PerpsController;
77
+ if (!controller || typeof controller.getOpenOrders !== 'function') {
78
+ throw new Error('Engine.context.PerpsController.getOpenOrders is unavailable; cannot assert live Perps orders.');
79
+ }
80
+ return controller.getOpenOrders().then(function(r){
81
+ if (!Array.isArray(r)) throw new Error('PerpsController.getOpenOrders returned a non-array result.');
82
+ return JSON.stringify(r);
83
+ });
84
+ })()`,
85
+ );
86
+ if (!Array.isArray(orders)) throw new Error('PerpsController.getOpenOrders returned a non-array result.');
87
+ return orders;
88
+ }
89
+
90
+ async function waitForPositionsAbsent(input, symbols, timeoutMs = 30000) {
91
+ const deadline = Date.now() + timeoutMs;
92
+ let last = [];
93
+ while (Date.now() < deadline) {
94
+ last = await readPositions(input);
95
+ const remaining = last.filter((position) => symbols.includes(symbolForItem(position)));
96
+ if (remaining.length === 0) return last;
97
+ await sleep(500);
98
+ }
99
+ return last;
100
+ }
101
+
102
+ async function waitForOrdersAbsent(input, symbols, timeoutMs = 30000) {
103
+ const deadline = Date.now() + timeoutMs;
104
+ let last = [];
105
+ while (Date.now() < deadline) {
106
+ last = await readOpenOrders(input);
107
+ const remaining = last.filter((order) => symbols.includes(symbolForItem(order)));
108
+ if (remaining.length === 0) return last;
109
+ await sleep(500);
110
+ }
111
+ return last;
112
+ }
113
+
114
+ async function waitForPositionPresent(input, symbol, timeoutMs = 30000) {
115
+ const deadline = Date.now() + timeoutMs;
116
+ let last = [];
117
+ while (Date.now() < deadline) {
118
+ last = await readPositions(input);
119
+ const matching = last.filter((position) => symbolForItem(position) === symbol);
120
+ if (matching.length > 0) return last;
121
+ await sleep(500);
122
+ }
123
+ return last;
124
+ }
125
+
126
+ function redactPosition(position) {
127
+ return {
128
+ symbol: position.symbol ?? position.coin ?? null,
129
+ size: position.size ?? position.szi ?? null,
130
+ side: position.side ?? null,
131
+ entryPrice: position.entryPrice ?? position.entryPx ?? null,
132
+ };
133
+ }
134
+
135
+ function orderIdForItem(order) {
136
+ return order?.orderId ?? order?.oid ?? order?.id ?? null;
137
+ }
138
+
139
+ function redactOrder(order) {
140
+ return {
141
+ orderId: orderIdForItem(order),
142
+ symbol: order.symbol ?? order.coin ?? null,
143
+ side: order.side ?? null,
144
+ size: order.size ?? order.sz ?? order.szi ?? null,
145
+ price: order.price ?? order.limitPx ?? order.px ?? null,
146
+ type: order.orderType ?? order.type ?? null,
147
+ };
148
+ }
149
+
150
+ function parsePrice(value) {
151
+ const priceMatch = /\d+(?:\.\d+)?/.exec(String(value ?? '').replace(/[$,]/g, ''));
152
+ const parsed = Number(priceMatch?.[0] ?? 0);
153
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
154
+ }
155
+
156
+ async function readCurrentMarketPrice(input, symbol, explicitPrice) {
157
+ if (explicitPrice === undefined || explicitPrice === null || explicitPrice === '') {
158
+ return readControllerMarketPrice(input, symbol);
159
+ }
160
+ const parsed = parsePrice(explicitPrice);
161
+ if (parsed) return parsed;
162
+ throw new Error(`Invalid explicit current price for ${symbol}: ${String(explicitPrice)}`);
163
+ }
164
+
165
+ async function readControllerMarketPrice(input, symbol) {
166
+ const market = await evalAsync(
167
+ input,
168
+ `Engine.context.PerpsController.getMarketDataWithPrices().then(function(markets){
169
+ var symbol = ${JSON.stringify(symbol)};
170
+ var market = Array.isArray(markets) ? markets.find(function(item){ return item && (item.symbol === symbol || item.coin === symbol); }) : null;
171
+ return JSON.stringify(market || null);
172
+ })`,
173
+ );
174
+ const parsed = parsePrice(market?.price ?? market?.markPrice ?? market?.oraclePrice ?? market?.currentPrice);
175
+ if (parsed) return parsed;
176
+ throw new Error(`Unable to determine current Perps price for ${symbol}.`);
177
+ }
178
+
179
+ function hasNodeValue(input, snakeName, camelName = snakeName) {
180
+ const node = input.node ?? {};
181
+ return Object.hasOwn(node, snakeName) || Object.hasOwn(node, camelName);
182
+ }
183
+
184
+ function nodeValue(input, snakeName, camelName = snakeName) {
185
+ return input.node?.[snakeName] ?? input.node?.[camelName];
186
+ }
187
+
188
+ async function currentPriceForCloseAttempt(input, symbol) {
189
+ if (hasNodeValue(input, 'price_at_calculation', 'priceAtCalculation')) {
190
+ return Number(nodeValue(input, 'price_at_calculation', 'priceAtCalculation'));
191
+ }
192
+ if (hasNodeValue(input, 'current_price', 'currentPrice')) {
193
+ return readCurrentMarketPrice(input, symbol, nodeValue(input, 'current_price', 'currentPrice'));
194
+ }
195
+ return readCurrentMarketPrice(input, symbol);
196
+ }
197
+
198
+ async function currentPriceForOrder(input, symbol) {
199
+ if (hasNodeValue(input, 'current_price', 'currentPrice')) {
200
+ return readCurrentMarketPrice(input, symbol, nodeValue(input, 'current_price', 'currentPrice'));
201
+ }
202
+ return readCurrentMarketPrice(input, symbol);
203
+ }
204
+
205
+ function orderSideIsBuy(side) {
206
+ if (side === 'short') return false;
207
+ return true;
208
+ }
209
+
210
+ function defaultNavigationTarget(source) {
211
+ if (source?.market || source?.symbol) return 'market';
212
+ return 'home';
213
+ }
214
+
215
+ function isTransientClosePriceError(result) {
216
+ const message = String(result?.error ?? result?.message ?? '');
217
+ // We retry local pricing/slippage races only. "Price too far from oracle"
218
+ // is a provider rejection observed on Hyperliquid testnet and should surface
219
+ // immediately so recipes do not hide an upstream market/oracle failure.
220
+ return /IOC_CANCEL|Slippage/i.test(message);
221
+ }
222
+
223
+ async function navigatePerps(input) {
224
+ const selected = String(
225
+ input.node?.target ?? input.node?.destination ?? defaultNavigationTarget(input.node),
226
+ ).toLowerCase();
227
+ if (selected === 'home' || selected === 'perps' || selected === 'perps_home') {
228
+ const navigation = await navigate(input, 'PerpsMarketListView', {});
229
+ return { action: input.action, target: selected, navigation, proofPath: 'agentic-navigation' };
230
+ }
231
+ if (selected === 'market' || selected === 'market_details') {
232
+ const symbol = marketSymbol(input);
233
+ const navigation = await navigate(input, 'PerpsMarketDetails', { market: { symbol } });
234
+ return { action: input.action, target: selected, market: symbol, navigation, proofPath: 'agentic-navigation' };
235
+ }
236
+ throw new Error(`Unsupported mobile Perps navigation target: ${selected}`);
237
+ }
238
+
239
+ export async function readPerpsPositions(input) {
240
+ const positions = await readPositions(input);
241
+ const matching = selectedItems(input, positions);
242
+ return { action: input.action, source: 'mobile-perps-controller', count: positions.length, matchingCount: matching.length, positions: matching.map(redactPosition) };
243
+ }
244
+
245
+ export async function readOrders(input) {
246
+ const orders = await readOpenOrders(input);
247
+ const matching = selectedItems(input, orders);
248
+ return { action: input.action, source: 'mobile-perps-controller-open-orders', count: orders.length, matchingCount: matching.length, orders: matching.map(redactOrder) };
249
+ }
250
+
251
+ export async function assertPositions(input, expectedOpen) {
252
+ const positions = await readPositions(input);
253
+ const matching = selectedItems(input, positions);
254
+ const hasPosition = matching.length > 0;
255
+ if (expectedOpen && !hasPosition) throw new Error(`Expected selected Perps position(s), but none matched ${JSON.stringify(input.node)}.`);
256
+ if (!expectedOpen && hasPosition) throw new Error(`Expected no selected Perps positions, but ${matching.length} matched ${JSON.stringify(input.node)}.`);
257
+ return { action: input.action, expectedOpen, matchingCount: matching.length, positions: matching.map(redactPosition), source: 'mobile-perps-controller' };
258
+ }
259
+
260
+ export async function assertOrders(input, expectedOpen) {
261
+ const orders = await readOpenOrders(input);
262
+ const matching = selectedItems(input, orders);
263
+ const hasOrders = matching.length > 0;
264
+ if (expectedOpen && !hasOrders) throw new Error(`Expected selected Perps order(s), but none matched ${JSON.stringify(input.node)}.`);
265
+ if (!expectedOpen && hasOrders) throw new Error(`Expected no selected Perps orders, but ${matching.length} matched ${JSON.stringify(input.node)}.`);
266
+ return { action: input.action, expectedOpen, matchingCount: matching.length, orders: matching.map(redactOrder), source: 'mobile-perps-controller-open-orders' };
267
+ }
268
+
269
+ function closePositionParams(input, position) {
270
+ const maxSlippageBps = Number(input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300);
271
+ const params = {
272
+ symbol: symbolForItem(position),
273
+ size: String(input.node?.size ?? ''),
274
+ orderType: String(input.node?.order_type ?? input.node?.orderType ?? 'market'),
275
+ position,
276
+ };
277
+ if (Number.isFinite(maxSlippageBps)) params.maxSlippageBps = maxSlippageBps;
278
+ if (hasNodeValue(input, 'price_at_calculation', 'priceAtCalculation')) {
279
+ params.priceAtCalculation = Number(nodeValue(input, 'price_at_calculation', 'priceAtCalculation'));
280
+ }
281
+ return params;
282
+ }
283
+
284
+ async function closePositionItem(input, position) {
285
+ const maxAttempts = Number(input.node?.close_attempts ?? 3);
286
+ const retryDelayMs = Number(input.node?.close_retry_delay_ms ?? 1000);
287
+ const baseParams = closePositionParams(input, position);
288
+ const attempts = [];
289
+ let lastResult = null;
290
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
291
+ const currentPositions = await readPositions(input);
292
+ const currentPosition = currentPositions.find((candidate) => symbolForItem(candidate) === baseParams.symbol);
293
+ if (!currentPosition) return { symbol: baseParams.symbol, result: { success: true, alreadyClosed: true }, attempts };
294
+
295
+ const priceAtCalculation = await currentPriceForCloseAttempt(input, baseParams.symbol);
296
+ const params = { ...baseParams, position: currentPosition, priceAtCalculation };
297
+ const result = await evalAsync(
298
+ input,
299
+ `Engine.context.PerpsController.closePosition(${JSON.stringify(params)}).then(function(r){return JSON.stringify(r)})`,
300
+ );
301
+ attempts.push({ attempt, priceAtCalculation, result });
302
+ lastResult = result;
303
+ if (result?.success === false) {
304
+ if (isTransientClosePriceError(result)) {
305
+ await sleep(retryDelayMs);
306
+ } else {
307
+ break;
308
+ }
309
+ } else {
310
+ return { symbol: params.symbol, result, attempts };
311
+ }
312
+ }
313
+ throw new Error(`Failed to close ${baseParams.symbol}: ${lastResult?.error || JSON.stringify(lastResult)} attempts=${JSON.stringify(attempts)}`);
314
+ }
315
+
316
+ export async function closePositions(input) {
317
+ const before = await readPositions(input);
318
+ const matching = selectedItems(input, before);
319
+ const symbols = uniqueSymbols(matching.map(symbolForItem));
320
+ if (symbols.length === 0) {
321
+ return { action: input.action, closed: false, matchingCount: 0, symbols, reason: 'no matching open positions', proofPath: 'mobile-perps-controller-close' };
322
+ }
323
+
324
+ const results = [];
325
+ for (const position of matching) {
326
+ results.push(await closePositionItem(input, position));
327
+ }
328
+
329
+ const after = await waitForPositionsAbsent(input, symbols, Number(input.node?.timeout_ms ?? 30000));
330
+ const stillOpen = after.filter((position) => symbols.includes(symbolForItem(position)));
331
+ if (stillOpen.length > 0) {
332
+ throw new Error(`Expected selected positions to close, but ${stillOpen.length} are still present: ${JSON.stringify(stillOpen.map(redactPosition))}`);
333
+ }
334
+ return { action: input.action, closed: true, matchingCount: matching.length, symbols, results, proofPath: 'mobile-perps-controller-close' };
335
+ }
336
+
337
+ export async function closeOrders(input) {
338
+ const before = await readOpenOrders(input);
339
+ const matching = selectedItems(input, before);
340
+ const symbols = uniqueSymbols(matching.map(symbolForItem));
341
+ if (symbols.length === 0) return { action: input.action, canceled: false, matchingCount: 0, symbols, result: null };
342
+ const orders = matching.map((order) => ({ orderId: orderIdForItem(order), symbol: symbolForItem(order) }));
343
+ const invalid = orders.find((order) => !order.orderId || !order.symbol);
344
+ if (invalid) {
345
+ throw new Error(`Cannot cancel selected Perps order without orderId and symbol: ${JSON.stringify(invalid)}`);
346
+ }
347
+ const result = await evalAsync(
348
+ input,
349
+ `(function(){
350
+ var controller = Engine && Engine.context && Engine.context.PerpsController;
351
+ var orders = ${JSON.stringify(orders)};
352
+ if (!controller || typeof controller.cancelOrder !== 'function') {
353
+ throw new Error('Engine.context.PerpsController.cancelOrder is unavailable; cannot cancel live Perps orders.');
354
+ }
355
+ return Promise.all(orders.map(function(order){
356
+ return controller.cancelOrder({ orderId: String(order.orderId), symbol: order.symbol }).then(function(r){
357
+ return {
358
+ orderId: String(order.orderId),
359
+ symbol: order.symbol,
360
+ success: !r || r.success !== false,
361
+ result: r
362
+ };
363
+ });
364
+ })).then(function(results){
365
+ var successCount = results.filter(function(entry){ return entry.success; }).length;
366
+ return JSON.stringify({
367
+ success: successCount === results.length,
368
+ successCount: successCount,
369
+ failureCount: results.length - successCount,
370
+ results: results
371
+ });
372
+ });
373
+ })()`,
374
+ );
375
+ const after = await waitForOrdersAbsent(input, symbols, Number(input.node?.timeout_ms ?? 30000));
376
+ const stillOpen = after.filter((order) => symbols.includes(symbolForItem(order)));
377
+ if (stillOpen.length > 0) throw new Error(`Expected selected Perps orders to cancel, but ${stillOpen.length} are still open.`);
378
+ return { action: input.action, canceled: true, matchingCount: matching.length, symbols, result, proofPath: 'mobile-perps-controller-cancel-order' };
379
+ }
380
+
381
+ export async function placeOrder(input) {
382
+ const symbol = marketSymbol(input);
383
+ const side = String(input.node?.side ?? 'long').toLowerCase();
384
+ const amount = String(input.node?.amount ?? input.node?.notional ?? '11');
385
+ const size = String(input.node?.size ?? '0.0001');
386
+ const leverage = Number(input.node?.leverage ?? 3);
387
+ const maxSlippageBps = Number(input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300);
388
+ const isBuy = orderSideIsBuy(side);
389
+ const currentPrice = await currentPriceForOrder(input, symbol);
390
+ const result = await evalAsync(input, `Engine.context.PerpsController.placeOrder({ symbol: ${JSON.stringify(symbol)}, isBuy: ${JSON.stringify(isBuy)}, orderType: 'market', size: ${JSON.stringify(size)}, usdAmount: ${JSON.stringify(amount)}, leverage: ${JSON.stringify(leverage)}, currentPrice: ${JSON.stringify(currentPrice)}, maxSlippageBps: ${JSON.stringify(maxSlippageBps)} }).then(function(r){return JSON.stringify(r)})`);
391
+ if (result?.success === false || result == null) {
392
+ throw new Error(`Failed to place ${symbol} ${side}: ${result?.error || JSON.stringify(result)}`);
393
+ }
394
+ const refresh = await evalAsync(
395
+ input,
396
+ 'globalThis.__AGENTIC__ && globalThis.__AGENTIC__.refreshPerpsStreams ? globalThis.__AGENTIC__.refreshPerpsStreams().then(function(r){return JSON.stringify(r)}) : Promise.resolve(JSON.stringify({ ok: false, reason: "refreshPerpsStreams unavailable" }))',
397
+ );
398
+ await waitForPositionPresent(input, symbol, Number(input.node?.timeout_ms ?? 30000));
399
+ return { action: input.action, market: symbol, side, amount, size, leverage, submitted: true, result, refresh, proofPath: 'mobile-perps-controller-place-order' };
400
+ }
401
+
402
+ export async function ensurePositions(input) {
403
+ const state = String(input.node?.state ?? input.node?.position ?? 'none').toLowerCase();
404
+ if (state === 'none' || state === 'closed' || state === 'absent') {
405
+ const close = await closePositions(input);
406
+ return { ...(await assertPositions(input, false)), close };
407
+ }
408
+ if (state === 'open' || state === 'present') {
409
+ const existing = await readPositions(input);
410
+ if (selectedItems(input, existing).length === 0) await placeOrder(input);
411
+ return assertPositions(input, true);
412
+ }
413
+ throw new Error(`metamask.perps.ensure_positions received unsupported state: ${state}`);
414
+ }
415
+
416
+ export async function ensureOrders(input) {
417
+ const state = String(input.node?.state ?? input.node?.orders ?? 'none').toLowerCase();
418
+ if (state === 'none' || state === 'closed' || state === 'absent') {
419
+ const close = await closeOrders(input);
420
+ return { ...(await assertOrders(input, false)), close };
421
+ }
422
+ if (state === 'open' || state === 'present') return assertOrders(input, true);
423
+ throw new Error(`metamask.perps.ensure_orders received unsupported state: ${state}`);
424
+ }
425
+
426
+
427
+ function paramsForState(input) {
428
+ const nested = input.node?.params && typeof input.node.params === 'object' ? input.node.params : {};
429
+ return { ...nested, ...input.node };
430
+ }
431
+
432
+ function profileDefaults(profile) {
433
+ const selected = String(profile ?? 'clean_market_testnet');
434
+ if (selected === 'clean_market_testnet') {
435
+ return {
436
+ provider: 'hyperliquid',
437
+ network: 'testnet',
438
+ page: 'market',
439
+ positions: { state: 'none', mode: 'matching' },
440
+ orders: { state: 'none', mode: 'matching' },
441
+ };
442
+ }
443
+ if (selected === 'open_position_testnet') {
444
+ return {
445
+ provider: 'hyperliquid',
446
+ network: 'testnet',
447
+ page: 'market',
448
+ positions: { state: 'open', mode: 'matching' },
449
+ orders: { state: 'none', mode: 'matching' },
450
+ };
451
+ }
452
+ if (selected === 'open_order_testnet') {
453
+ return {
454
+ provider: 'hyperliquid',
455
+ network: 'testnet',
456
+ page: 'market',
457
+ positions: false,
458
+ orders: { state: 'open', mode: 'matching' },
459
+ };
460
+ }
461
+ if (selected === 'provider_mainnet_readonly') {
462
+ return {
463
+ provider: 'hyperliquid',
464
+ network: 'mainnet',
465
+ page: 'home',
466
+ positions: false,
467
+ orders: false,
468
+ };
469
+ }
470
+ if (selected === 'clean_market_mainnet') {
471
+ return {
472
+ provider: 'hyperliquid',
473
+ network: 'mainnet',
474
+ page: 'market',
475
+ positions: { state: 'none', mode: 'matching' },
476
+ orders: { state: 'none', mode: 'matching' },
477
+ };
478
+ }
479
+ return {};
480
+ }
481
+
482
+ function mergeStateConfig(defaults, params) {
483
+ return {
484
+ ...defaults,
485
+ ...params,
486
+ positions: mergeNested(defaults.positions, params.positions),
487
+ orders: mergeNested(defaults.orders, params.orders),
488
+ };
489
+ }
490
+
491
+ function mergeNested(defaultValue, overrideValue) {
492
+ if (overrideValue === false) return false;
493
+ if (overrideValue === undefined) return defaultValue;
494
+ if (defaultValue && typeof defaultValue === 'object' && overrideValue && typeof overrideValue === 'object') {
495
+ return { ...defaultValue, ...overrideValue };
496
+ }
497
+ return overrideValue;
498
+ }
499
+
500
+ function childInput(input, node) {
501
+ const params = paramsForState(input);
502
+ return {
503
+ ...input,
504
+ node: {
505
+ ...params,
506
+ ...node,
507
+ market: node.market ?? node.symbol ?? params.market ?? params.symbol,
508
+ markets: node.markets ?? params.markets,
509
+ symbols: node.symbols ?? params.symbols,
510
+ side: node.side ?? params.side,
511
+ timeout_ms: node.timeout_ms ?? params.timeout_ms,
512
+ },
513
+ };
514
+ }
515
+
516
+ async function applyOrdersState(input, config) {
517
+ if (config === false) return { skipped: true };
518
+ const node = config && typeof config === 'object' ? config : { state: String(config ?? 'none') };
519
+ return ensureOrders(childInput(input, node));
520
+ }
521
+
522
+ async function applyPositionsState(input, config) {
523
+ if (config === false) return { skipped: true };
524
+ const node = config && typeof config === 'object' ? config : { state: String(config ?? 'none') };
525
+ return ensurePositions(childInput(input, node));
526
+ }
527
+
528
+ async function applyStateNavigation(input, config) {
529
+ if (!config.page && !config.market && !config.symbol) return { skipped: true };
530
+ const target = config.page ?? defaultNavigationTarget(config);
531
+ return navigatePerps(childInput(input, { target, market: config.market, symbol: config.symbol }));
532
+ }
533
+
534
+
535
+ async function readPerpsRuntimeState(input) {
536
+ return evalAsync(input, `
537
+ (function(){
538
+ var c=Engine.context.PerpsController;
539
+ var s=c.state || {};
540
+ return JSON.stringify({ activeProvider:s.activeProvider || null, isTestnet:!!s.isTestnet });
541
+ })()
542
+ `);
543
+ }
544
+
545
+ async function ensureProvider(input, config) {
546
+ if (!config.provider) return { skipped: true };
547
+ const expected = String(config.provider).toLowerCase();
548
+ const before = await readPerpsRuntimeState(input);
549
+ if (String(before.activeProvider || '').toLowerCase() === expected) {
550
+ return { requested: expected, activeProvider: before.activeProvider, changed: false };
551
+ }
552
+ const result = await evalAsync(
553
+ input,
554
+ `Engine.context.PerpsController.switchProvider(${JSON.stringify(expected)}).then(function(r){return JSON.stringify(r)})`,
555
+ );
556
+ const after = await readPerpsRuntimeState(input);
557
+ if (String(after.activeProvider || '').toLowerCase() !== expected) {
558
+ throw new Error(`Expected Perps provider ${expected}, got ${after.activeProvider || 'unknown'} after switchProvider.`);
559
+ }
560
+ return { requested: expected, activeProvider: after.activeProvider, changed: true, result };
561
+ }
562
+
563
+ async function ensureNetwork(input, config) {
564
+ if (!config.network) return { skipped: true };
565
+ const expectedTestnet = String(config.network).toLowerCase() === 'testnet';
566
+ const before = await readPerpsRuntimeState(input);
567
+ if (Boolean(before.isTestnet) === expectedTestnet) {
568
+ return { requested: config.network, isTestnet: before.isTestnet, changed: false };
569
+ }
570
+ const result = await evalAsync(input, 'Engine.context.PerpsController.toggleTestnet().then(function(r){return JSON.stringify(r)})');
571
+ const after = await waitForNetworkState(input, expectedTestnet, Number(input.node?.timeout_ms ?? 30000));
572
+ if (Boolean(after.isTestnet) !== expectedTestnet) {
573
+ throw new Error(`Expected Perps network ${config.network}, got isTestnet=${after.isTestnet}.`);
574
+ }
575
+ return { requested: config.network, isTestnet: after.isTestnet, changed: true, result };
576
+ }
577
+
578
+ async function waitForNetworkState(input, expectedTestnet, timeoutMs) {
579
+ const deadline = Date.now() + timeoutMs;
580
+ let last = await readPerpsRuntimeState(input);
581
+ while (Date.now() < deadline) {
582
+ last = await readPerpsRuntimeState(input);
583
+ if (Boolean(last.isTestnet) === expectedTestnet) return last;
584
+ await sleep(500);
585
+ }
586
+ return last;
587
+ }
588
+
589
+ async function assertReadyToTrade(input, config) {
590
+ if (config.readyToTrade === undefined || config.readyToTrade === false) return { skipped: true };
591
+ const ready = await evalAsync(
592
+ input,
593
+ `(function(){ var c=Engine.context.PerpsController; var id=c.state.activeProvider; var p=c.providers && c.providers.get ? c.providers.get(id) : c.getActiveProvider && c.getActiveProvider(); if(!p || typeof p.isReadyToTrade !== 'function') return Promise.resolve(JSON.stringify({ready:false,activeProvider:id || null,error:'provider unavailable'})); return p.isReadyToTrade().then(function(r){ return JSON.stringify({ready:!!(r && r.ready), activeProvider:id || null, authenticatedAddress:(r&&r.authenticatedAddress)||null}); }); })()`,
594
+ );
595
+ if (!ready.ready) throw new Error(`Perps provider is not ready to trade: ${JSON.stringify(ready)}`);
596
+ return ready;
597
+ }
598
+
599
+ async function assertBalance(input, config) {
600
+ if (!config.balance) return { skipped: true };
601
+ const balanceConfig = config.balance && typeof config.balance === 'object' ? config.balance : {};
602
+ const minWithdrawable = Number(balanceConfig.minWithdrawableUsd ?? balanceConfig.minUsd ?? 0);
603
+ const minSpendable = Number(balanceConfig.minSpendableUsd ?? 0);
604
+ const accountState = await evalAsync(
605
+ input,
606
+ 'Engine.context.PerpsController.getAccountState().then(function(r){return JSON.stringify(r)})',
607
+ );
608
+ const withdrawable = Number(accountState.withdrawableBalance ?? 0);
609
+ const spendable = Number(accountState.spendableBalance ?? accountState.withdrawableBalance ?? 0);
610
+ if (Number.isFinite(minWithdrawable) && withdrawable < minWithdrawable) {
611
+ throw new Error(`Perps withdrawable balance ${withdrawable} is below requested minimum ${minWithdrawable}.`);
612
+ }
613
+ if (Number.isFinite(minSpendable) && spendable < minSpendable) {
614
+ throw new Error(`Perps spendable balance ${spendable} is below requested minimum ${minSpendable}.`);
615
+ }
616
+ return { withdrawableBalance: withdrawable, spendableBalance: spendable, minWithdrawableUsd: minWithdrawable, minSpendableUsd: minSpendable };
617
+ }
618
+
619
+ async function applyTutorialState(input, config) {
620
+ const tutorialConfig = config.tutorial && typeof config.tutorial === 'object'
621
+ ? config.tutorial
622
+ : { completed: config.tutorialCompleted ?? config.skipTutorial };
623
+ if (tutorialConfig.completed !== true) return { skipped: true };
624
+ return evalAsync(
625
+ input,
626
+ `(function(){
627
+ var c=Engine.context.PerpsController;
628
+ if (!c || typeof c.markTutorialCompleted !== 'function') return JSON.stringify({ok:false, skipped:true, reason:'markTutorialCompleted unavailable'});
629
+ c.markTutorialCompleted();
630
+ return JSON.stringify({ok:true, completed:true, isTestnet:!!c.state.isTestnet, isFirstTimeUser:c.state.isFirstTimeUser});
631
+ })()`,
632
+ );
633
+ }
634
+
635
+
636
+
637
+ export async function startState(input) {
638
+ const params = paramsForState(input);
639
+ const config = mergeStateConfig(profileDefaults(params.profile), params);
640
+ const provider = await ensureProvider(input, config);
641
+ const network = await ensureNetwork(input, config);
642
+ const tutorial = await applyTutorialState(input, config);
643
+ const readyToTrade = await assertReadyToTrade(input, config);
644
+ const balance = await assertBalance(input, config);
645
+ const orders = await applyOrdersState(input, config.orders);
646
+ const positions = await applyPositionsState(input, config.positions);
647
+ const navigation = await applyStateNavigation(input, config);
648
+ return {
649
+ action: input.action,
650
+ profile: config.profile ?? params.profile ?? 'clean_market_testnet',
651
+ phase: 'start_state',
652
+ provider,
653
+ network,
654
+ tutorial,
655
+ readyToTrade,
656
+ balance,
657
+ market: config.market ?? config.symbol ?? null,
658
+ orders,
659
+ positions,
660
+ navigation,
661
+ proofPath: 'metamask-perps-start-state',
662
+ };
663
+ }
664
+
665
+ export async function teardownState(input) {
666
+ const params = paramsForState(input);
667
+ const defaults = {
668
+ page: 'home',
669
+ positions: params.market || params.symbol || params.markets || params.symbols ? { state: 'none', mode: 'matching' } : false,
670
+ orders: params.market || params.symbol || params.markets || params.symbols ? { state: 'none', mode: 'matching' } : false,
671
+ };
672
+ const config = mergeStateConfig(defaults, params);
673
+ const orders = await applyOrdersState(input, config.orders);
674
+ const positions = await applyPositionsState(input, config.positions);
675
+ const navigation = config.page === false ? { skipped: true } : await applyStateNavigation(input, config);
676
+ return {
677
+ action: input.action,
678
+ phase: 'teardown',
679
+ market: config.market ?? config.symbol ?? null,
680
+ orders,
681
+ positions,
682
+ navigation,
683
+ proofPath: 'metamask-perps-teardown-state',
684
+ };
685
+ }
686
+
687
+ const DIRECT_ACTIONS = new Map([
688
+ ['metamask.perps.read_positions', readPerpsPositions],
689
+ ['metamask.perps.read_orders', readOrders],
690
+ ['metamask.perps.close_positions', closePositions],
691
+ ['metamask.perps.close_orders', closeOrders],
692
+ ['metamask.perps.place_order', placeOrder],
693
+ ['metamask.perps.ensure_positions', ensurePositions],
694
+ ['metamask.perps.ensure_orders', ensureOrders],
695
+ ['metamask.perps.start_state', startState],
696
+ ['metamask.perps.teardown_state', teardownState],
697
+ ]);
698
+
699
+ function isDirectRun() {
700
+ return process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
701
+ }
702
+
703
+ if (isDirectRun()) {
704
+ runAdapter((input) => {
705
+ const handler = DIRECT_ACTIONS.get(input.action);
706
+ if (!handler) throw new Error(`No Perps domain dispatcher handler for ${input.action}.`);
707
+ return handler(input);
708
+ });
709
+ }