@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,53 @@
1
+ import {
2
+ getCoreController,
3
+ isDirectRun,
4
+ redactOrder,
5
+ runAdapter,
6
+ selectedItems,
7
+ } from './_controller.mjs';
8
+
9
+ // core Assert selected live Perps open orders are present or absent. Pure read
10
+ // over the controller's standalone getOpenOrders path (no signer / provider init
11
+ // needed) — throws on mismatch so the recipe fails loudly. Mirrors
12
+ // assert_positions.mjs.
13
+
14
+ export function expectedOpen(input) {
15
+ const state = String(input.node?.state ?? input.node?.order ?? 'open').toLowerCase();
16
+ if (state === 'open' || state === 'present') return true;
17
+ if (state === 'none' || state === 'closed' || state === 'absent') return false;
18
+ throw new Error(`metamask.perps.assert_orders received unsupported state: ${state}`);
19
+ }
20
+
21
+ export async function assertOrders(input, expectOpen = expectedOpen(input)) {
22
+ const { controller, accountAddress, network } = await getCoreController(input);
23
+ const orders = await controller.getOpenOrders({
24
+ standalone: true,
25
+ userAddress: accountAddress,
26
+ });
27
+ const matching = selectedItems(input, orders);
28
+ const hasOrder = matching.length > 0;
29
+
30
+ if (expectOpen && !hasOrder) {
31
+ throw new Error(
32
+ `Expected selected Perps open order(s), but none matched ${JSON.stringify(input.node)}.`,
33
+ );
34
+ }
35
+ if (!expectOpen && hasOrder) {
36
+ throw new Error(
37
+ `Expected no selected Perps open orders, but ${matching.length} matched ${JSON.stringify(input.node)}.`,
38
+ );
39
+ }
40
+
41
+ return {
42
+ action: input.action,
43
+ source: 'perps-controller-standalone',
44
+ network,
45
+ account: accountAddress,
46
+ expectedOpen: expectOpen,
47
+ matchingCount: matching.length,
48
+ orders: matching.map(redactOrder),
49
+ proofPath: 'perps-controller-getOpenOrders',
50
+ };
51
+ }
52
+
53
+ if (isDirectRun(import.meta.url)) runAdapter((input) => assertOrders(input));
@@ -0,0 +1,52 @@
1
+ import {
2
+ getCoreController,
3
+ isDirectRun,
4
+ redactPosition,
5
+ runAdapter,
6
+ selectedItems,
7
+ } from './_controller.mjs';
8
+
9
+ // core Assert selected live Perps positions are present or absent. Pure read
10
+ // over the controller's standalone path (no signer / provider init needed) —
11
+ // throws on mismatch so the recipe fails loudly.
12
+
13
+ export function expectedOpen(input) {
14
+ const state = String(input.node?.state ?? input.node?.position ?? 'open').toLowerCase();
15
+ if (state === 'open' || state === 'present') return true;
16
+ if (state === 'none' || state === 'closed' || state === 'absent') return false;
17
+ throw new Error(`metamask.perps.assert_positions received unsupported state: ${state}`);
18
+ }
19
+
20
+ export async function assertPositions(input, expectOpen = expectedOpen(input)) {
21
+ const { controller, accountAddress, network } = await getCoreController(input);
22
+ const positions = await controller.getPositions({
23
+ standalone: true,
24
+ userAddress: accountAddress,
25
+ });
26
+ const matching = selectedItems(input, positions);
27
+ const hasPosition = matching.length > 0;
28
+
29
+ if (expectOpen && !hasPosition) {
30
+ throw new Error(
31
+ `Expected selected Perps position(s), but none matched ${JSON.stringify(input.node)}.`,
32
+ );
33
+ }
34
+ if (!expectOpen && hasPosition) {
35
+ throw new Error(
36
+ `Expected no selected Perps positions, but ${matching.length} matched ${JSON.stringify(input.node)}.`,
37
+ );
38
+ }
39
+
40
+ return {
41
+ action: input.action,
42
+ source: 'perps-controller-standalone',
43
+ network,
44
+ account: accountAddress,
45
+ expectedOpen: expectOpen,
46
+ matchingCount: matching.length,
47
+ positions: matching.map(redactPosition),
48
+ proofPath: 'perps-controller-getPositions',
49
+ };
50
+ }
51
+
52
+ if (isDirectRun(import.meta.url)) runAdapter((input) => assertPositions(input));
@@ -0,0 +1,97 @@
1
+ import {
2
+ getCoreControllerWithSigner,
3
+ isDirectRun,
4
+ redactOrder,
5
+ runAdapter,
6
+ selectedItems,
7
+ symbolForItem,
8
+ } from './_controller.mjs';
9
+
10
+ // core Cancel selected live Perps open orders on HyperLiquid testnet by driving
11
+ // the headless PerpsController.cancelOrders() through the full signing/provider
12
+ // path (Slice 2). Mirrors close_positions.mjs: read open orders → cancel the
13
+ // selected subset via the controller → verify the selected orders are gone.
14
+ //
15
+ // cancelOrders() resolves the concrete orders to cancel via the ACTIVE provider's
16
+ // getOpenOrders() (PerpsController wires getOpenOrders: () => this.getOpenOrders()
17
+ // into the cancel context) and then batch-cancels by symbol. We pass the selected
18
+ // symbols; with mode=all (no explicit market) we cancel every open order.
19
+
20
+ function sleep(ms) {
21
+ return new Promise((resolve) => setTimeout(resolve, ms));
22
+ }
23
+
24
+ async function waitForOrdersAbsent(controller, accountAddress, symbols, timeoutMs) {
25
+ const deadline = Date.now() + timeoutMs;
26
+ let orders = [];
27
+ for (;;) {
28
+ orders = await controller.getOpenOrders({
29
+ standalone: true,
30
+ userAddress: accountAddress,
31
+ });
32
+ const remaining = orders.filter((order) => symbols.includes(symbolForItem(order)));
33
+ if (remaining.length === 0) return orders;
34
+ if (Date.now() >= deadline) return orders;
35
+ await sleep(1000);
36
+ }
37
+ }
38
+
39
+ export async function closeOrders(input) {
40
+ const { controller, accountAddress, network } = await getCoreControllerWithSigner(input);
41
+ const timeoutMs = Number(input.node?.timeout_ms ?? 30000);
42
+
43
+ const orders = await controller.getOpenOrders({
44
+ standalone: true,
45
+ userAddress: accountAddress,
46
+ });
47
+ const matching = selectedItems(input, orders);
48
+ const symbols = Array.from(new Set(matching.map(symbolForItem)));
49
+
50
+ if (symbols.length === 0) {
51
+ return {
52
+ action: input.action,
53
+ source: 'perps-controller-cancelOrders',
54
+ network,
55
+ account: accountAddress,
56
+ canceled: false,
57
+ matchingCount: 0,
58
+ symbols,
59
+ reason: 'no matching open orders',
60
+ proofPath: 'perps-controller-cancelOrders',
61
+ };
62
+ }
63
+
64
+ // Cancel by the selected symbols. cancelOrders() filters the active provider's
65
+ // open orders to these symbols and batch-cancels them on the exchange.
66
+ const result = await controller.cancelOrders({ symbols });
67
+ if (!result || result.success !== true) {
68
+ throw new Error(
69
+ `core cancelOrders failed for ${JSON.stringify(symbols)}: ${JSON.stringify(result)}`,
70
+ );
71
+ }
72
+
73
+ const after = await waitForOrdersAbsent(controller, accountAddress, symbols, timeoutMs);
74
+ const stillOpen = after.filter((order) => symbols.includes(symbolForItem(order)));
75
+ if (stillOpen.length > 0) {
76
+ throw new Error(
77
+ `Expected selected orders to cancel, but ${stillOpen.length} remain: ${JSON.stringify(result)}`,
78
+ );
79
+ }
80
+
81
+ return {
82
+ action: input.action,
83
+ source: 'perps-controller-cancelOrders',
84
+ network,
85
+ account: accountAddress,
86
+ canceled: true,
87
+ matchingCount: matching.length,
88
+ successCount: result.successCount,
89
+ failureCount: result.failureCount,
90
+ symbols,
91
+ results: result.results,
92
+ orders: stillOpen.map(redactOrder),
93
+ proofPath: 'perps-controller-cancelOrders',
94
+ };
95
+ }
96
+
97
+ if (isDirectRun(import.meta.url)) runAdapter(closeOrders);
@@ -0,0 +1,118 @@
1
+ import {
2
+ currentMarketPrice,
3
+ getCoreControllerWithSigner,
4
+ isDirectRun,
5
+ redactPosition,
6
+ runAdapter,
7
+ selectedItems,
8
+ symbolForItem,
9
+ } from './_controller.mjs';
10
+
11
+ // core Close selected live Perps positions on HyperLiquid testnet by driving the
12
+ // headless PerpsController.closePosition() per matching position (full close)
13
+ // through the full signing/provider path (Slice 2). Mirrors the extension
14
+ // adapter: read → close each matching position with a fresh price → verify the
15
+ // selected positions are gone.
16
+
17
+ function sleep(ms) {
18
+ return new Promise((resolve) => setTimeout(resolve, ms));
19
+ }
20
+
21
+ async function waitForPositionsAbsent(controller, accountAddress, symbols, timeoutMs) {
22
+ const deadline = Date.now() + timeoutMs;
23
+ let positions = [];
24
+ for (;;) {
25
+ positions = await controller.getPositions({
26
+ standalone: true,
27
+ userAddress: accountAddress,
28
+ });
29
+ const remaining = positions.filter((position) =>
30
+ symbols.includes(symbolForItem(position)),
31
+ );
32
+ if (remaining.length === 0) return positions;
33
+ if (Date.now() >= deadline) return positions;
34
+ await sleep(1000);
35
+ }
36
+ }
37
+
38
+ export async function closePositions(input) {
39
+ const { controller, accountAddress, network } = await getCoreControllerWithSigner(input);
40
+ const timeoutMs = Number(input.node?.timeout_ms ?? 30000);
41
+ const maxSlippageBps = Number(
42
+ input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300,
43
+ );
44
+
45
+ const positions = await controller.getPositions({
46
+ standalone: true,
47
+ userAddress: accountAddress,
48
+ });
49
+ const matching = selectedItems(input, positions);
50
+ const symbols = Array.from(new Set(matching.map(symbolForItem)));
51
+
52
+ if (symbols.length === 0) {
53
+ return {
54
+ action: input.action,
55
+ source: 'perps-controller-closePosition',
56
+ network,
57
+ account: accountAddress,
58
+ closed: false,
59
+ matchingCount: 0,
60
+ symbols,
61
+ reason: 'no matching open positions',
62
+ proofPath: 'perps-controller-closePosition',
63
+ };
64
+ }
65
+
66
+ const results = [];
67
+ let successCount = 0;
68
+ for (const position of matching) {
69
+ const symbol = symbolForItem(position);
70
+ const currentPrice = await currentMarketPrice(controller, symbol);
71
+ // Full close: omit size so the provider closes 100% of the position. Pass
72
+ // live position + fresh price so the provider skips a REST refetch and the
73
+ // slippage guard has a current anchor.
74
+ const result = await controller.closePosition({
75
+ symbol,
76
+ orderType: 'market',
77
+ currentPrice,
78
+ priceAtCalculation: currentPrice,
79
+ maxSlippageBps,
80
+ position,
81
+ });
82
+ const success = result?.success === true;
83
+ results.push({ symbol, success, currentPrice, result });
84
+ if (success) successCount += 1;
85
+ }
86
+
87
+ // A wholesale close failure must surface even if a read race shows the
88
+ // positions absent — mirrors close_orders, which throws on success !== true.
89
+ if (matching.length > 0 && successCount === 0) {
90
+ throw new Error(
91
+ `All ${matching.length} closePosition call(s) reported failure (successCount=0): ${JSON.stringify(results)}`,
92
+ );
93
+ }
94
+
95
+ const after = await waitForPositionsAbsent(controller, accountAddress, symbols, timeoutMs);
96
+ const stillOpen = after.filter((position) => symbols.includes(symbolForItem(position)));
97
+ if (stillOpen.length > 0) {
98
+ throw new Error(
99
+ `Expected selected positions to close, but ${stillOpen.length} remain: ${JSON.stringify(results)}`,
100
+ );
101
+ }
102
+
103
+ return {
104
+ action: input.action,
105
+ source: 'perps-controller-closePosition',
106
+ network,
107
+ account: accountAddress,
108
+ closed: true,
109
+ matchingCount: matching.length,
110
+ successCount,
111
+ symbols,
112
+ results,
113
+ positions: stillOpen.map(redactPosition),
114
+ proofPath: 'perps-controller-closePosition',
115
+ };
116
+ }
117
+
118
+ if (isDirectRun(import.meta.url)) runAdapter(closePositions);
@@ -0,0 +1,40 @@
1
+ import { getCoreController, isDirectRun, runAdapter, selectedItems } from './_controller.mjs';
2
+ import { assertOrders } from './assert_orders.mjs';
3
+ import { closeOrders } from './close_orders.mjs';
4
+ import { placeOrder } from './place_order.mjs';
5
+
6
+ // core Higher-level wrapper that reads selected open orders, converges them to the
7
+ // requested `state` (open/none) by canceling or placing, then asserts the final
8
+ // order state. Mirrors ensure_positions.mjs.
9
+
10
+ export async function ensureOrders(input) {
11
+ const state = String(input.node?.state ?? input.node?.order ?? 'none').toLowerCase();
12
+
13
+ if (state === 'none' || state === 'closed' || state === 'absent') {
14
+ const close = await closeOrders(input);
15
+ const assertion = await assertOrders(input, false);
16
+ return { ...assertion, ensured: 'none', close };
17
+ }
18
+
19
+ if (state === 'open' || state === 'present') {
20
+ // Only place a resting order when no matching open order already exists
21
+ // (idempotent ensure). Placing to reach state=open requires a limit order
22
+ // (orderType=limit + price/offset_pct) so it rests on the book instead of
23
+ // filling — see place_order.mjs.
24
+ const { controller, accountAddress } = await getCoreController(input);
25
+ const orders = await controller.getOpenOrders({
26
+ standalone: true,
27
+ userAddress: accountAddress,
28
+ });
29
+ let order = null;
30
+ if (selectedItems(input, orders).length === 0) {
31
+ order = await placeOrder({ ...input, node: { ...input.node, order_type: 'limit' } });
32
+ }
33
+ const assertion = await assertOrders(input, true);
34
+ return { ...assertion, ensured: 'open', order };
35
+ }
36
+
37
+ throw new Error(`metamask.perps.ensure_orders received unsupported state: ${state}`);
38
+ }
39
+
40
+ if (isDirectRun(import.meta.url)) runAdapter(ensureOrders);
@@ -0,0 +1,37 @@
1
+ import { getCoreController, isDirectRun, runAdapter, selectedItems } from './_controller.mjs';
2
+ import { assertPositions } from './assert_positions.mjs';
3
+ import { closePositions } from './close_positions.mjs';
4
+ import { placeOrder } from './place_order.mjs';
5
+
6
+ // core Higher-level wrapper that reads selected positions, converges them to the
7
+ // requested `state` (open/none) by placing or closing, then asserts the final
8
+ // state. Mirrors the extension ensure_positions semantics.
9
+
10
+ export async function ensurePositions(input) {
11
+ const state = String(input.node?.state ?? input.node?.position ?? 'none').toLowerCase();
12
+
13
+ if (state === 'none' || state === 'closed' || state === 'absent') {
14
+ const close = await closePositions(input);
15
+ const assertion = await assertPositions(input, false);
16
+ return { ...assertion, ensured: 'none', close };
17
+ }
18
+
19
+ if (state === 'open' || state === 'present') {
20
+ // Only place when no matching position already exists (idempotent ensure).
21
+ const { controller, accountAddress } = await getCoreController(input);
22
+ const positions = await controller.getPositions({
23
+ standalone: true,
24
+ userAddress: accountAddress,
25
+ });
26
+ let order = null;
27
+ if (selectedItems(input, positions).length === 0) {
28
+ order = await placeOrder(input);
29
+ }
30
+ const assertion = await assertPositions(input, true);
31
+ return { ...assertion, ensured: 'open', order };
32
+ }
33
+
34
+ throw new Error(`metamask.perps.ensure_positions received unsupported state: ${state}`);
35
+ }
36
+
37
+ if (isDirectRun(import.meta.url)) runAdapter(ensurePositions);
@@ -0,0 +1,201 @@
1
+ import {
2
+ configuredSymbols,
3
+ currentMarketPrice,
4
+ getCoreControllerWithSigner,
5
+ isDirectRun,
6
+ redactOrder,
7
+ redactPosition,
8
+ runAdapter,
9
+ selectedItems,
10
+ } from './_controller.mjs';
11
+
12
+ // core Place a real Perps order on HyperLiquid testnet by driving the headless
13
+ // PerpsController.placeOrder() through the full signing/provider path (Slice 2).
14
+ // Mirrors the extension adapter's input vocabulary and notional→size math:
15
+ // size = (usdAmount * leverage) / currentPrice
16
+ // The HyperLiquidProvider recalculates size from usdAmount internally; we still
17
+ // pass a derived size for the controller's own validation, plus currentPrice/
18
+ // priceAtCalculation so the provider's slippage guard has a fresh anchor.
19
+ //
20
+ // LIMIT orders (orderType=limit) place a RESTING order on the book instead of
21
+ // filling: the provider sends tif=Gtc and uses the provided `price` verbatim
22
+ // (calculateOrderPriceAndSize / buildOrdersArray in core's orderCalculations.ts).
23
+ // The limit price is derived from the live mid by `offset_pct` (e.g. -30 places a
24
+ // BUY 30% below mid that will NOT fill), or supplied absolutely via `limit_price`.
25
+ // For a limit order we size from the LIMIT price (not mid) and omit usdAmount so
26
+ // the controller uses our explicit size verbatim — that guarantees the
27
+ // on-exchange notional (size * limitPrice) clears HyperLiquid's ~$10 minimum even
28
+ // though the limit sits far from mid. After placing we verify the resting open
29
+ // order exists (not a filled position).
30
+
31
+ function resolveSymbol(input) {
32
+ const symbols = configuredSymbols(input, []);
33
+ if (symbols.length !== 1) {
34
+ throw new Error(
35
+ `metamask.perps.place_order requires exactly one market; got ${JSON.stringify(symbols)}.`,
36
+ );
37
+ }
38
+ return symbols[0];
39
+ }
40
+
41
+ function resolveOrderType(input) {
42
+ const raw = String(
43
+ input.node?.order_type ?? input.node?.orderType ?? 'market',
44
+ ).toLowerCase();
45
+ if (raw !== 'market' && raw !== 'limit') {
46
+ throw new Error(`metamask.perps.place_order received unsupported order_type: ${raw}.`);
47
+ }
48
+ return raw;
49
+ }
50
+
51
+ /**
52
+ * Resolve the resting limit price for a limit order.
53
+ * Precedence: explicit absolute `limit_price` → `offset_pct` from mid.
54
+ * A BUY uses a negative offset (below mid) to rest; a SELL a positive offset.
55
+ * Defaults to a -30% (buy) / +30% (sell) offset so the order will not fill.
56
+ *
57
+ * @param input - Adapter input (node.limit_price / node.offset_pct).
58
+ * @param isBuy - Order direction.
59
+ * @param mid - Current mid price.
60
+ */
61
+ function resolveLimitPrice(input, isBuy, mid) {
62
+ const absolute = input.node?.limit_price ?? input.node?.limitPrice ?? input.node?.price;
63
+ if (absolute !== undefined && absolute !== null && String(absolute).length > 0) {
64
+ const numeric = Number(absolute);
65
+ if (!Number.isFinite(numeric) || numeric <= 0) {
66
+ throw new Error(`metamask.perps.place_order received invalid limit_price: ${absolute}.`);
67
+ }
68
+ return numeric;
69
+ }
70
+ const rawOffset = input.node?.offset_pct ?? input.node?.offsetPct;
71
+ // Default far-from-mid offset so the resting order does not fill.
72
+ const offsetPct = rawOffset === undefined || rawOffset === null ? (isBuy ? -30 : 30) : Number(rawOffset);
73
+ if (!Number.isFinite(offsetPct)) {
74
+ throw new Error(`metamask.perps.place_order received invalid offset_pct: ${rawOffset}.`);
75
+ }
76
+ const price = mid * (1 + offsetPct / 100);
77
+ if (!Number.isFinite(price) || price <= 0) {
78
+ throw new Error(`metamask.perps.place_order computed a non-positive limit price (${price}).`);
79
+ }
80
+ return price;
81
+ }
82
+
83
+ export async function placeOrder(input) {
84
+ const { controller, accountAddress, network } = await getCoreControllerWithSigner(input);
85
+ const symbol = resolveSymbol(input);
86
+ const side = String(input.node?.side ?? 'long').toLowerCase();
87
+ const isBuy = side !== 'short';
88
+ const orderType = resolveOrderType(input);
89
+ const usdAmount = String(input.node?.amount ?? input.node?.notional ?? '11');
90
+ const leverage = Number(input.node?.leverage ?? 3);
91
+ const maxSlippageBps = Number(
92
+ input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300,
93
+ );
94
+
95
+ const usdNumeric = Number(usdAmount);
96
+ if (!Number.isFinite(usdNumeric) || usdNumeric <= 0) {
97
+ throw new Error(`metamask.perps.place_order received invalid notional: ${usdAmount}.`);
98
+ }
99
+ if (!Number.isFinite(leverage) || leverage <= 0) {
100
+ throw new Error(`metamask.perps.place_order received invalid leverage: ${leverage}.`);
101
+ }
102
+
103
+ const currentPrice = await currentMarketPrice(controller, symbol);
104
+
105
+ let orderParams;
106
+ let limitPrice = null;
107
+ if (orderType === 'limit') {
108
+ limitPrice = resolveLimitPrice(input, isBuy, currentPrice);
109
+ // Size from the LIMIT price so size * limitPrice clears HL's ~$10 minimum;
110
+ // omit usdAmount so the controller does NOT recompute size from mid.
111
+ const size = ((usdNumeric * leverage) / limitPrice).toString();
112
+ orderParams = {
113
+ symbol,
114
+ isBuy,
115
+ size,
116
+ orderType: 'limit',
117
+ price: String(limitPrice),
118
+ timeInForce: 'GTC',
119
+ leverage,
120
+ currentPrice,
121
+ priceAtCalculation: currentPrice,
122
+ maxSlippageBps,
123
+ };
124
+ } else {
125
+ const size = ((usdNumeric * leverage) / currentPrice).toString();
126
+ orderParams = {
127
+ symbol,
128
+ isBuy,
129
+ size,
130
+ orderType: 'market',
131
+ leverage,
132
+ usdAmount,
133
+ currentPrice,
134
+ priceAtCalculation: currentPrice,
135
+ maxSlippageBps,
136
+ };
137
+ }
138
+
139
+ const result = await controller.placeOrder(orderParams);
140
+ if (!result || result.success !== true) {
141
+ throw new Error(
142
+ `core placeOrder failed for ${symbol} (${orderType}): ${result?.error ?? 'unknown error'} (currentPrice=${currentPrice}${limitPrice ? `, limitPrice=${limitPrice}` : ''}).`,
143
+ );
144
+ }
145
+
146
+ // For limit orders, confirm a RESTING open order exists (not a filled position);
147
+ // for market orders, confirm the position opened. Both read the real
148
+ // provider/exchange state (not the submit ack) before reporting success.
149
+ let openOrders = [];
150
+ let positions = [];
151
+ if (orderType === 'limit') {
152
+ openOrders = await controller.getOpenOrders({
153
+ standalone: true,
154
+ userAddress: accountAddress,
155
+ });
156
+ } else {
157
+ positions = await controller.getPositions({
158
+ standalone: true,
159
+ userAddress: accountAddress,
160
+ });
161
+ }
162
+ const matchingOrders = selectedItems(input, openOrders);
163
+ const matchingPositions = selectedItems(input, positions);
164
+
165
+ if (orderType === 'limit' && matchingOrders.length === 0) {
166
+ throw new Error(
167
+ `core placeOrder (limit) for ${symbol} reported success but no resting open order is visible (orderId=${result.orderId ?? 'null'}, limitPrice=${limitPrice}).`,
168
+ );
169
+ }
170
+ if (orderType === 'market' && matchingPositions.length === 0) {
171
+ throw new Error(
172
+ `core placeOrder (market) for ${symbol} reported success but no matching position is visible (orderId=${result.orderId ?? 'null'}).`,
173
+ );
174
+ }
175
+
176
+ return {
177
+ action: input.action,
178
+ source: 'perps-controller-placeOrder',
179
+ network,
180
+ account: accountAddress,
181
+ market: symbol,
182
+ side,
183
+ orderType,
184
+ notional: usdAmount,
185
+ leverage,
186
+ size: orderParams.size,
187
+ currentPrice,
188
+ limitPrice,
189
+ submitted: true,
190
+ orderId: result.orderId ?? null,
191
+ filledSize: result.filledSize ?? null,
192
+ averagePrice: result.averagePrice ?? null,
193
+ matchingCount: orderType === 'limit' ? matchingOrders.length : matchingPositions.length,
194
+ orders: matchingOrders.map(redactOrder),
195
+ positions: matchingPositions.map(redactPosition),
196
+ order: result,
197
+ proofPath: 'perps-controller-placeOrder',
198
+ };
199
+ }
200
+
201
+ if (isDirectRun(import.meta.url)) runAdapter(placeOrder);
@@ -0,0 +1,30 @@
1
+ import { getCoreController, runAdapter } from './_controller.mjs';
2
+
3
+ function redactAccount(account) {
4
+ return {
5
+ totalBalance: account?.totalBalance ?? null,
6
+ spendableBalance: account?.spendableBalance ?? null,
7
+ withdrawableBalance: account?.withdrawableBalance ?? null,
8
+ marginUsed: account?.marginUsed ?? null,
9
+ unrealizedPnl: account?.unrealizedPnl ?? null,
10
+ returnOnEquity: account?.returnOnEquity ?? null,
11
+ };
12
+ }
13
+
14
+ export async function readAccount(input) {
15
+ const { controller, accountAddress, network } = await getCoreController(input);
16
+ const account = await controller.getAccountState({
17
+ standalone: true,
18
+ userAddress: accountAddress,
19
+ });
20
+ return {
21
+ action: input.action,
22
+ source: 'perps-controller-standalone',
23
+ network,
24
+ account: accountAddress,
25
+ accountState: redactAccount(account),
26
+ proofPath: 'perps-controller-getAccountState',
27
+ };
28
+ }
29
+
30
+ runAdapter(readAccount);
@@ -0,0 +1,27 @@
1
+ import {
2
+ getCoreController,
3
+ redactOrder,
4
+ runAdapter,
5
+ selectedItems,
6
+ } from './_controller.mjs';
7
+
8
+ export async function readOrders(input) {
9
+ const { controller, accountAddress, network } = await getCoreController(input);
10
+ const orders = await controller.getOpenOrders({
11
+ standalone: true,
12
+ userAddress: accountAddress,
13
+ });
14
+ const matching = selectedItems(input, orders);
15
+ return {
16
+ action: input.action,
17
+ source: 'perps-controller-standalone',
18
+ network,
19
+ account: accountAddress,
20
+ count: orders.length,
21
+ matchingCount: matching.length,
22
+ orders: matching.map(redactOrder),
23
+ proofPath: 'perps-controller-getOpenOrders',
24
+ };
25
+ }
26
+
27
+ runAdapter(readOrders);
@@ -0,0 +1,27 @@
1
+ import {
2
+ getCoreController,
3
+ redactPosition,
4
+ runAdapter,
5
+ selectedItems,
6
+ } from './_controller.mjs';
7
+
8
+ export async function readPositions(input) {
9
+ const { controller, accountAddress, network } = await getCoreController(input);
10
+ const positions = await controller.getPositions({
11
+ standalone: true,
12
+ userAddress: accountAddress,
13
+ });
14
+ const matching = selectedItems(input, positions);
15
+ return {
16
+ action: input.action,
17
+ source: 'perps-controller-standalone',
18
+ network,
19
+ account: accountAddress,
20
+ count: positions.length,
21
+ matchingCount: matching.length,
22
+ positions: matching.map(redactPosition),
23
+ proofPath: 'perps-controller-getPositions',
24
+ };
25
+ }
26
+
27
+ runAdapter(readPositions);