@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,727 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+
5
+ import { mnemonicToAccount, privateKeyToAccount } from 'viem/accounts';
6
+
7
+ // Importing a runner .ts source signals the live-adapter contract to execute
8
+ // this adapter under the bundled tsx (see commandFor/importsSourceTypescript in
9
+ // src/live-adapter-contract.ts). The core adapter dynamic-imports the perps
10
+ // controller TypeScript at runtime, so it MUST run under tsx, not plain node.
11
+ import { walletFixturePath } from '../../../../runner/src/paths.ts';
12
+
13
+ // Shared headless instantiation for the MetaMask `core` adapter.
14
+ //
15
+ // Slice 1: read-only. We instantiate @metamask/perps-controller against a
16
+ // resolved MetaMask/core checkout (context.projectRoot) and drive its standalone
17
+ // read path, which talks to HyperLiquid testnet over HTTP — no CDP, no bridge,
18
+ // no UI, no signer. The controller's standalone read methods
19
+ // (getPositions/getOpenOrders/getAccountState with { standalone: true,
20
+ // userAddress }) create their own InfoClient and never touch the messenger, so
21
+ // reads need ZERO external action handlers. Signing/account-resolution through
22
+ // the messenger is Slice 2.
23
+
24
+ function controllerEntry(projectRoot) {
25
+ return pathToFileURL(path.join(projectRoot, 'packages/perps-controller/src/index.ts')).href;
26
+ }
27
+
28
+ function messengerEntry(projectRoot) {
29
+ return pathToFileURL(path.join(projectRoot, 'packages/messenger/src/index.ts')).href;
30
+ }
31
+
32
+ // Faithful no-op implementation of PerpsPlatformDependencies. The read path only
33
+ // touches debugLogger/logger; the rest exist so the controller constructor and
34
+ // its services can be created without throwing. Mirrors the shape declared in
35
+ // packages/perps-controller/src/types/index.ts (PerpsPlatformDependencies).
36
+ //
37
+ // debugLogger/logger route to stderr so the LAST controller operation before a
38
+ // hang/exit is visible in the live-adapter run log. The write path lazily opens
39
+ // a persistent auto-reconnecting HyperLiquid WebSocket (HyperLiquidClientService
40
+ // .initialize → wsTransport.ready()); these logs proved the order returns over
41
+ // HTTP while that open socket kept the event loop alive — see disconnectAndExit.
42
+ function buildInfrastructure(stubbed) {
43
+ const noop = () => undefined;
44
+ return {
45
+ logger: {
46
+ error: (error, meta) =>
47
+ process.stderr.write(
48
+ `[core/perps][controller.error] ${error?.message ?? error}${
49
+ meta ? ` ${JSON.stringify(meta)}` : ''
50
+ }\n`,
51
+ ),
52
+ },
53
+ debugLogger: {
54
+ log: (message, meta) =>
55
+ process.stderr.write(
56
+ `[core/perps][controller] ${message}${meta ? ` ${JSON.stringify(meta)}` : ''}\n`,
57
+ ),
58
+ },
59
+ metrics: { trackEvent: noop, isEnabled: () => false, trackPerpsEvent: noop },
60
+ performance: { now: () => Date.now() },
61
+ tracer: { trace: noop, endTrace: noop, setMeasurement: noop, addBreadcrumb: noop },
62
+ streamManager: { pauseChannel: noop, resumeChannel: noop, clearAllChannels: noop },
63
+ featureFlags: {
64
+ validateVersionGated: () => {
65
+ stubbed.add('featureFlags.validateVersionGated');
66
+ return undefined;
67
+ },
68
+ },
69
+ marketDataFormatters: {
70
+ formatVolume: (value) => `$${value}`,
71
+ formatPerpsFiat: (value) => `$${value}`,
72
+ formatPercentage: (value) => `${value}%`,
73
+ priceRangesUniversal: [],
74
+ },
75
+ cacheInvalidator: { invalidate: noop, invalidateAll: noop },
76
+ diskCache: {
77
+ getItem: async () => null,
78
+ getItemSync: () => null,
79
+ setItem: async () => undefined,
80
+ removeItem: async () => undefined,
81
+ },
82
+ rewards: { getPerpsDiscountForAccount: async () => null },
83
+ };
84
+ }
85
+
86
+ // --- Account resolution from wallet-fixture.json ---
87
+ //
88
+ // The core adapter resolves the signing account from the same wallet-fixture.json
89
+ // that mobile/extension adapters use (recipeRuntimePath/wallet-fixture.json).
90
+ // The fixture has the standard { accounts: [{ type, value, name }] } shape from
91
+ // wallet-fixture.json.sample. The recipe node selects an account by name via
92
+ // `account_name` (default "dev1"). The viem account derived from the fixture
93
+ // entry is the authoritative source for both the address AND the signing key —
94
+ // no separate MM_TEST_ACCOUNT_ADDRESS env var needed.
95
+ //
96
+ // Env-var fallback: if no fixture is present (e.g. direct CLI invocation),
97
+ // MM_TEST_ACCOUNT_SRP / MM_TEST_ACCOUNT_PRIVATE_KEY + MM_TEST_ACCOUNT_ADDRESS
98
+ // are still accepted for backward compatibility.
99
+
100
+ /**
101
+ * Load wallet-fixture.json and return the named account entry.
102
+ * Returns null if the fixture file does not exist.
103
+ *
104
+ * @param projectRoot - Absolute path to the project root.
105
+ * @param accountName - The `name` field to match in fixture.accounts.
106
+ */
107
+ async function loadFixtureAccount(projectRoot, accountName) {
108
+ const fixturePath = walletFixturePath(projectRoot);
109
+ let raw;
110
+ try {
111
+ raw = await readFile(fixturePath, 'utf8');
112
+ } catch (error) {
113
+ if (error?.code === 'ENOENT') return null;
114
+ throw error;
115
+ }
116
+ const fixture = JSON.parse(raw);
117
+ if (!Array.isArray(fixture.accounts) || fixture.accounts.length === 0) {
118
+ throw new Error(`wallet-fixture.json at ${fixturePath} has no accounts array.`);
119
+ }
120
+ const entry = fixture.accounts.find((a) => a?.name === accountName);
121
+ if (!entry) {
122
+ const names = fixture.accounts.map((a) => a?.name).filter(Boolean).join(', ');
123
+ throw new Error(
124
+ `wallet-fixture.json has no account named "${accountName}". Available: ${names}.`,
125
+ );
126
+ }
127
+ if (typeof entry.value !== 'string' || entry.value.trim().length === 0) {
128
+ throw new Error(`wallet-fixture.json account "${accountName}" has no value.`);
129
+ }
130
+ if (entry.type !== 'mnemonic' && entry.type !== 'privateKey') {
131
+ throw new Error(
132
+ `wallet-fixture.json account "${accountName}" type must be mnemonic or privateKey, got "${entry.type}".`,
133
+ );
134
+ }
135
+ return entry;
136
+ }
137
+
138
+ /**
139
+ * Derive a viem account from a wallet-fixture account entry.
140
+ * Mnemonics use BIP-44 account index 0 (MetaMask default derivation).
141
+ * Private keys are accepted with or without a 0x prefix.
142
+ */
143
+ function viemAccountFromFixtureEntry(entry) {
144
+ if (entry.type === 'mnemonic') {
145
+ return mnemonicToAccount(entry.value.trim(), { addressIndex: 0 });
146
+ }
147
+ // privateKey
148
+ const raw = entry.value.trim();
149
+ const normalized = raw.startsWith('0x') ? raw : `0x${raw}`;
150
+ if (!/^0x[0-9a-fA-F]{64}$/u.test(normalized)) {
151
+ throw new Error(
152
+ `wallet-fixture.json privateKey account "${entry.name}" is not a 32-byte hex key.`,
153
+ );
154
+ }
155
+ return privateKeyToAccount(normalized);
156
+ }
157
+
158
+ // Env-var fallback constants (used only when wallet-fixture.json is absent).
159
+ const SIGNER_PRIVATE_KEY_ENV = 'MM_TEST_ACCOUNT_PRIVATE_KEY';
160
+ const SIGNER_MNEMONIC_ENV = 'MM_TEST_ACCOUNT_SRP';
161
+
162
+ function signerFromEnv() {
163
+ const pk = process.env[SIGNER_PRIVATE_KEY_ENV]?.trim();
164
+ if (pk && pk.length > 0) {
165
+ const normalized = pk.startsWith('0x') ? pk : `0x${pk}`;
166
+ if (!/^0x[0-9a-fA-F]{64}$/u.test(normalized)) {
167
+ throw new Error(`${SIGNER_PRIVATE_KEY_ENV} is not a 32-byte hex private key.`);
168
+ }
169
+ return privateKeyToAccount(normalized);
170
+ }
171
+ const mnemonic = process.env[SIGNER_MNEMONIC_ENV]?.trim();
172
+ if (mnemonic && mnemonic.split(/\s+/u).length >= 12) {
173
+ return mnemonicToAccount(mnemonic, { addressIndex: 0 });
174
+ }
175
+ return null;
176
+ }
177
+
178
+ /**
179
+ * Resolve the account name to use for signing.
180
+ * Precedence: node.account_name → node.account (if not an address) → "dev1".
181
+ */
182
+ function resolveAccountName(input) {
183
+ const explicit = input.node?.account_name;
184
+ if (typeof explicit === 'string' && explicit.trim().length > 0) return explicit.trim();
185
+ // node.account can be either a name ("dev1") or an address ("0x...").
186
+ // If it looks like an address, ignore it here — the address will be derived
187
+ // from the fixture signer instead.
188
+ const nodeAccount = input.node?.account;
189
+ if (
190
+ typeof nodeAccount === 'string' &&
191
+ nodeAccount.trim().length > 0 &&
192
+ !/^0x[0-9a-fA-F]{40}$/u.test(nodeAccount.trim())
193
+ ) {
194
+ return nodeAccount.trim();
195
+ }
196
+ return 'dev1';
197
+ }
198
+
199
+ /**
200
+ * Resolve the viem signer and EVM address for writes.
201
+ * Primary: wallet-fixture.json account selected by name.
202
+ * Fallback: MM_TEST_ACCOUNT_PRIVATE_KEY / MM_TEST_ACCOUNT_SRP env vars
203
+ * (requires MM_TEST_ACCOUNT_ADDRESS for address verification).
204
+ *
205
+ * @param input - Adapter input (context.projectRoot, node.account_name).
206
+ * @returns { account: ViemAccount, address: string }
207
+ */
208
+ async function resolveSignerFromFixture(input) {
209
+ const projectRoot = input.context?.projectRoot;
210
+ const accountName = resolveAccountName(input);
211
+
212
+ // Primary: fixture
213
+ if (projectRoot) {
214
+ const entry = await loadFixtureAccount(projectRoot, accountName);
215
+ if (entry) {
216
+ const account = viemAccountFromFixtureEntry(entry);
217
+ return { account, address: account.address };
218
+ }
219
+ }
220
+
221
+ // Fallback: env vars (no fixture present — direct CLI use)
222
+ const account = signerFromEnv();
223
+ if (!account) {
224
+ throw new Error(
225
+ `core perps writes require a wallet-fixture.json with account "${accountName}", ` +
226
+ `or env vars ${SIGNER_PRIVATE_KEY_ENV} / ${SIGNER_MNEMONIC_ENV} + MM_TEST_ACCOUNT_ADDRESS.`,
227
+ );
228
+ }
229
+ const envAddress = String(process.env.MM_TEST_ACCOUNT_ADDRESS ?? '').trim();
230
+ if (!/^0x[0-9a-fA-F]{40}$/u.test(envAddress)) {
231
+ throw new Error(
232
+ `Env-var fallback requires MM_TEST_ACCOUNT_ADDRESS (a 0x EVM address) to verify the signer.`,
233
+ );
234
+ }
235
+ if (account.address.toLowerCase() !== envAddress.toLowerCase()) {
236
+ throw new Error(
237
+ `Env-var signer derives ${account.address} but MM_TEST_ACCOUNT_ADDRESS is ${envAddress}; signatures would be invalid.`,
238
+ );
239
+ }
240
+ return { account, address: envAddress };
241
+ }
242
+
243
+ /**
244
+ * Resolve the account address for reads (no signing required).
245
+ * Primary: wallet-fixture.json account selected by name (address derived from key).
246
+ * Fallback: node.account / node.address / MM_TEST_ACCOUNT_ADDRESS env var.
247
+ */
248
+ async function requireAccountAddress(input) {
249
+ const projectRoot = input.context?.projectRoot;
250
+ const accountName = resolveAccountName(input);
251
+
252
+ if (projectRoot) {
253
+ // No try/catch: loadFixtureAccount returns null when no fixture is present
254
+ // (the only recoverable case) and THROWS on a malformed / missing-named /
255
+ // empty / bad-type fixture. Those must fail loudly — swallowing them would
256
+ // let a read silently run against a different address than the writes use.
257
+ const entry = await loadFixtureAccount(projectRoot, accountName);
258
+ if (entry) {
259
+ const account = viemAccountFromFixtureEntry(entry);
260
+ return account.address;
261
+ }
262
+ }
263
+
264
+ // Fallback: explicit address from node or env
265
+ const fromNode = input.node?.account ?? input.node?.address ?? input.node?.userAddress;
266
+ const address = String(fromNode ?? process.env.MM_TEST_ACCOUNT_ADDRESS ?? '').trim();
267
+ if (!/^0x[0-9a-fA-F]{40}$/u.test(address)) {
268
+ throw new Error(
269
+ `core perps reads require a wallet-fixture.json with account "${accountName}", ` +
270
+ `or a 0x EVM address via node.account / MM_TEST_ACCOUNT_ADDRESS.`,
271
+ );
272
+ }
273
+ return address;
274
+ }
275
+
276
+ let cached = null;
277
+
278
+ /**
279
+ * Resolve the requested network. Default testnet; mainnet only when a node
280
+ * explicitly sets network: "mainnet". Mainnet reads are safe; mainnet mutations
281
+ * use REAL funds and must be explicitly requested — mirrors the extension/mobile
282
+ * "mainnet is read-only unless explicitly requested" contract.
283
+ */
284
+ export function resolveNetwork(input) {
285
+ const raw = String(input.node?.network ?? 'testnet').toLowerCase();
286
+ if (raw !== 'testnet' && raw !== 'mainnet') {
287
+ throw new Error(
288
+ `core perps network must be "testnet" or "mainnet", got "${input.node?.network}".`,
289
+ );
290
+ }
291
+ return raw;
292
+ }
293
+
294
+ /**
295
+ * Instantiate the PerpsController headlessly against the resolved core checkout.
296
+ * Returns the controller plus the resolved read account address. Cached per
297
+ * process so repeated reads in one adapter invocation reuse one controller.
298
+ */
299
+ export async function getCoreController(input) {
300
+ const projectRoot = input.context?.projectRoot;
301
+ if (!projectRoot) throw new Error('core adapter requires context.projectRoot.');
302
+ const accountAddress = await requireAccountAddress(input);
303
+ const network = resolveNetwork(input);
304
+
305
+ if (cached && cached.projectRoot === projectRoot && cached.network === network) {
306
+ return { ...cached, accountAddress };
307
+ }
308
+
309
+ const [{ PerpsController }, { Messenger, MOCK_ANY_NAMESPACE }] = await Promise.all([
310
+ import(controllerEntry(projectRoot)),
311
+ import(messengerEntry(projectRoot)),
312
+ ]);
313
+
314
+ const stubbed = new Set();
315
+ const infrastructure = buildInfrastructure(stubbed);
316
+
317
+ // Root + child messenger pair, mirroring the real app wiring (and the core
318
+ // repo's own test harness in
319
+ // packages/perps-controller/tests/defer-eligibility.test.ts): a permissive
320
+ // root messenger (MOCK_ANY_NAMESPACE) owns the external action handlers
321
+ // (Accounts/Keyring/...), and the PerpsController-namespaced child receives
322
+ // them via rootMessenger.delegate(). Slice 1 reads use the standalone path and
323
+ // need no external handlers; Slice 2 writes register the signer handlers on
324
+ // the root and delegate them into this child (see getCoreControllerWithSigner).
325
+ const rootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE });
326
+ const messenger = new Messenger({
327
+ namespace: 'PerpsController',
328
+ parent: rootMessenger,
329
+ });
330
+
331
+ // Default testnet. Mainnet only on explicit node.network: "mainnet" — mainnet
332
+ // reads are safe; mainnet mutations use real funds (gated in getCoreControllerWithSigner).
333
+ const isTestnet = network === 'testnet';
334
+ const controller = new PerpsController({
335
+ messenger,
336
+ state: { isTestnet },
337
+ infrastructure,
338
+ });
339
+
340
+ if (controller.state.isTestnet !== isTestnet) {
341
+ throw new Error(`core perps controller did not initialize in ${network} mode.`);
342
+ }
343
+
344
+ const stubbedHandlers = Array.from(stubbed);
345
+ if (stubbedHandlers.length > 0) {
346
+ // Surface any stubbed dependency the read path leaned on, per the brief.
347
+ process.stderr.write(
348
+ `[core/perps] stubbed platform dependencies used during read: ${stubbedHandlers.join(', ')}\n`,
349
+ );
350
+ }
351
+
352
+ // If a controller from a different network/checkout is cached in this process,
353
+ // disconnect it (close its HL WebSocket) before replacing — a superseded
354
+ // controller would otherwise leak its socket and keep the event loop alive.
355
+ if (cached?.controller && typeof cached.controller.disconnect === 'function') {
356
+ try {
357
+ await cached.controller.disconnect();
358
+ } catch (error) {
359
+ process.stderr.write(
360
+ `[core/perps] failed to disconnect superseded controller: ${fmtError(error)}\n`,
361
+ );
362
+ }
363
+ }
364
+ cached = { controller, messenger, rootMessenger, projectRoot, network };
365
+ return { ...cached, accountAddress };
366
+ }
367
+
368
+ // --- Slice 2: default viem signer + write path ---
369
+ //
370
+ // WRITES go through the full provider path, not the standalone read path. The
371
+ // HyperLiquidProvider lazily builds the ExchangeClient by calling
372
+ // HyperLiquidWalletService.createWalletAdapter() (packages/perps-controller/
373
+ // src/services/HyperLiquidWalletService.ts), which in turn drives THREE external
374
+ // messenger actions that the real wallet resolves to KeyringController /
375
+ // AccountsController:
376
+ // 1. AccountsController:getSelectedAccount — resolve the signing EVM account
377
+ // 2. KeyringController:getState — assert the keyring is unlocked
378
+ // 3. KeyringController:signTypedMessage — sign the EIP-712 typed data the
379
+ // HL SDK constructs
380
+ // Headless, we register those three handlers on a permissive root messenger and
381
+ // delegate them into the PerpsController messenger, backed by a viem account
382
+ // resolved from wallet-fixture.json (see resolveSignerFromFixture). The HL SDK
383
+ // builds the typed data — we sign EXACTLY the {domain, types, primaryType,
384
+ // message} it passes; we never hand-roll the payload.
385
+
386
+ // The EXACT external actions the HyperLiquid write path drives through the
387
+ // messenger (HyperLiquidWalletService.createWalletAdapter / isKeyringUnlocked /
388
+ // getSelectedEvmAccountFromMessenger). These get registered on the root and
389
+ // delegated into the PerpsController child messenger.
390
+ const SIGNER_ACTIONS = [
391
+ 'AccountsController:getSelectedAccount',
392
+ 'KeyringController:getState',
393
+ 'KeyringController:signTypedMessage',
394
+ ];
395
+
396
+ /**
397
+ * Register the signer-backed external handlers on the root messenger and
398
+ * delegate them into the PerpsController child, mirroring the real app's
399
+ * rootMessenger.delegate(...) wiring. Idempotent per root messenger.
400
+ *
401
+ * @param rootMessenger - The permissive (MOCK_ANY_NAMESPACE) root messenger.
402
+ * @param childMessenger - The PerpsController-namespaced messenger.
403
+ * @param account - The viem signer account.
404
+ * @param address - The selected EVM account address (0x).
405
+ */
406
+ function registerSignerHandlers(rootMessenger, childMessenger, account, address) {
407
+ if (rootMessenger.__coreSignerRegistered) return;
408
+
409
+ // AccountsController:getSelectedAccount — getSelectedEvmAccountFromMessenger()
410
+ // calls this first and uses it when the returned object looks like an account
411
+ // with an EVM `type`. Minimal InternalAccount shape: address + EVM type, plus
412
+ // metadata.keyring.type so isSelectedHardwareWallet() sees a software (non-
413
+ // hardware) keyring and allows user signing.
414
+ rootMessenger.registerActionHandler(
415
+ 'AccountsController:getSelectedAccount',
416
+ () => ({
417
+ id: 'core-headless-signer',
418
+ address,
419
+ type: 'eip155:eoa',
420
+ metadata: { keyring: { type: 'HD Key Tree' } },
421
+ }),
422
+ );
423
+
424
+ // KeyringController:getState — isKeyringUnlocked() reads `.isUnlocked`. The
425
+ // headless keyring is always unlocked (we hold the private key).
426
+ rootMessenger.registerActionHandler('KeyringController:getState', () => ({
427
+ isUnlocked: true,
428
+ }));
429
+
430
+ // KeyringController:signTypedMessage — the heart of Slice 2. The wallet
431
+ // adapter calls this with ({ from, data: typedData }, version='V4'). `data`
432
+ // is the EXACT { domain, types, primaryType, message } the HL SDK built. We
433
+ // sign it verbatim with viem and return the hex signature the SDK expects.
434
+ rootMessenger.registerActionHandler(
435
+ 'KeyringController:signTypedMessage',
436
+ async (msgParams) => {
437
+ const typedData =
438
+ typeof msgParams?.data === 'string'
439
+ ? JSON.parse(msgParams.data)
440
+ : msgParams?.data;
441
+ if (!typedData || typeof typedData !== 'object') {
442
+ throw new Error(
443
+ 'KeyringController:signTypedMessage received no typed data to sign.',
444
+ );
445
+ }
446
+ const { domain, types, primaryType, message } = typedData;
447
+ // Strip the EIP712Domain entry if present: viem derives the domain types
448
+ // from `domain` itself and rejects a duplicate EIP712Domain in `types`.
449
+ const signableTypes = { ...types };
450
+ delete signableTypes.EIP712Domain;
451
+ return account.signTypedData({
452
+ domain,
453
+ types: signableTypes,
454
+ primaryType,
455
+ message,
456
+ });
457
+ },
458
+ );
459
+
460
+ // Deliver the handlers to the PerpsController messenger so its internal
461
+ // this.messenger.call('AccountsController:getSelectedAccount' | 'Keyring...')
462
+ // resolves them.
463
+ rootMessenger.delegate({ actions: SIGNER_ACTIONS, messenger: childMessenger });
464
+
465
+ rootMessenger.__coreSignerRegistered = true;
466
+ }
467
+
468
+ /**
469
+ * Instantiate the PerpsController with the default viem signer wired in and the
470
+ * active HyperLiquid testnet provider initialized, ready for writes
471
+ * (placeOrder/closePosition). Builds on getCoreController (Slice 1 setup) and
472
+ * additionally:
473
+ * - registers the account + signTypedData messenger handlers, and
474
+ * - calls controller.init() so getActiveProvider() (which placeOrder requires)
475
+ * returns the active HyperLiquidProvider. The provider lazily initializes its
476
+ * ExchangeClient(wallet) on the first write, awaiting that path internally.
477
+ *
478
+ * @param input - The adapter input (context.projectRoot, account, env).
479
+ * @returns { controller, projectRoot, network, accountAddress, signerAddress }.
480
+ */
481
+ export async function getCoreControllerWithSigner(input) {
482
+ const base = await getCoreController(input);
483
+ const { controller, messenger: childMessenger, rootMessenger, accountAddress } = base;
484
+ if (!rootMessenger || !childMessenger) {
485
+ throw new Error('core perps controller exposes no messenger pair for signing.');
486
+ }
487
+
488
+ // Default testnet. A mutation on mainnet uses REAL funds, so it is gated TWICE:
489
+ // the recipe node must explicitly set network: "mainnet" (resolveNetwork), AND a
490
+ // human must opt in via CORE_PERPS_ALLOW_MAINNET_WRITES=1 — so a copy-pasted recipe
491
+ // alone cannot move real money on a dev/CI box.
492
+ if (base.network === 'mainnet') {
493
+ if (process.env.CORE_PERPS_ALLOW_MAINNET_WRITES !== '1') {
494
+ throw new Error(
495
+ 'core perps refuses a MAINNET write: set CORE_PERPS_ALLOW_MAINNET_WRITES=1 to confirm signing with REAL funds.',
496
+ );
497
+ }
498
+ process.stderr.write(
499
+ '[core/perps] WARNING: signing a MAINNET perps action with REAL funds (network=mainnet + CORE_PERPS_ALLOW_MAINNET_WRITES=1)\n',
500
+ );
501
+ }
502
+
503
+ const { account, address: signerAddress } = await resolveSignerFromFixture(input);
504
+ registerSignerHandlers(rootMessenger, childMessenger, account, signerAddress);
505
+
506
+ // Bring up the active provider. placeOrder/closePosition call
507
+ // getActiveProvider(), which throws CLIENT_NOT_INITIALIZED until init()
508
+ // assigns the active HyperLiquidProvider. init() is idempotent (promise
509
+ // cached), so repeated write adapters in one run reuse the same provider.
510
+ await controller.init();
511
+
512
+ const wantTestnet = base.network === 'testnet';
513
+ if (controller.state.isTestnet !== wantTestnet) {
514
+ throw new Error(`core perps controller is not in ${base.network} mode; refusing to sign.`);
515
+ }
516
+
517
+ return { ...base, signerAddress };
518
+ }
519
+
520
+ /**
521
+ * Resolve the current numeric market price for a symbol via the controller's
522
+ * standalone market-data path (same HTTP read used by Slice 1 — no signing, no
523
+ * provider init required). Used to convert a USD notional into a coin size for
524
+ * placeOrder, mirroring the extension: size = (usdAmount * leverage) / price.
525
+ *
526
+ * @param controller - The instantiated PerpsController.
527
+ * @param symbol - Normalized market symbol (e.g. 'BTC').
528
+ * @returns The current price as a positive number.
529
+ */
530
+ export async function currentMarketPrice(controller, symbol) {
531
+ const markets = await controller.getMarketDataWithPrices({ standalone: true });
532
+ const target = normalizeMarketSymbol(symbol);
533
+ const market = (Array.isArray(markets) ? markets : []).find(
534
+ (item) => normalizeMarketSymbol(item?.symbol ?? '') === target,
535
+ );
536
+ if (!market) {
537
+ throw new Error(`No market data found for ${target} on testnet.`);
538
+ }
539
+ // PerpsMarketData.price is a formatted string like '$103,245.00'.
540
+ const numeric = Number(String(market.price ?? '').replace(/[$,\s]/gu, ''));
541
+ if (!Number.isFinite(numeric) || numeric <= 0) {
542
+ throw new Error(
543
+ `Unable to parse a positive current price for ${target} (got ${JSON.stringify(market.price)}).`,
544
+ );
545
+ }
546
+ return numeric;
547
+ }
548
+
549
+ // --- Selection vocabulary (mirrors the extension perps adapter contract) ---
550
+
551
+ export function normalizeMarketSymbol(rawSymbol) {
552
+ const raw = String(rawSymbol);
553
+ if (raw.includes(':')) {
554
+ const [source, ...symbolParts] = raw.split(':');
555
+ return `${source.toLowerCase()}:${symbolParts.join(':').toUpperCase()}`;
556
+ }
557
+ return raw.toUpperCase();
558
+ }
559
+
560
+ export function symbolForItem(item) {
561
+ return normalizeMarketSymbol(item?.symbol ?? item?.coin ?? '');
562
+ }
563
+
564
+ // Normalize an item's side to the long/short vocabulary the selector uses.
565
+ // Positions report side as long/short already; OPEN ORDERS report it as buy/sell
566
+ // (a resting BUY = long direction, a resting SELL = short). Mapping both onto
567
+ // long/short lets the shared `side` selector filter positions and orders alike —
568
+ // without this, requesting side:"long" silently drops every order (whose side is
569
+ // "buy"), which is the bug this normalization fixes.
570
+ function normalizeSide(rawSide) {
571
+ const side = String(rawSide ?? '').toLowerCase();
572
+ if (side === 'buy' || side === 'b') return 'long';
573
+ if (side === 'sell' || side === 'a' || side === 's') return 'short';
574
+ return side;
575
+ }
576
+
577
+ function sideForItem(item) {
578
+ return normalizeSide(item?.side ?? item?.direction ?? '');
579
+ }
580
+
581
+ function uniqueSymbols(symbols) {
582
+ return Array.from(new Set(symbols.filter(Boolean)));
583
+ }
584
+
585
+ export function configuredSymbols(input, items) {
586
+ const selector =
587
+ input.node?.selector && typeof input.node.selector === 'object' ? input.node.selector : {};
588
+ const mode = String(input.node?.mode ?? selector.mode ?? 'matching').toLowerCase();
589
+ if (mode === 'all') return uniqueSymbols(items.map(symbolForItem).filter(Boolean));
590
+ const explicit = input.node?.markets ?? input.node?.symbols ?? selector.markets ?? selector.symbols;
591
+ if (Array.isArray(explicit) && explicit.length > 0) {
592
+ return uniqueSymbols(explicit.map(normalizeMarketSymbol));
593
+ }
594
+ if (typeof explicit === 'string' && explicit.length > 0) {
595
+ return uniqueSymbols(
596
+ explicit
597
+ .split(',')
598
+ .map((part) => normalizeMarketSymbol(part.trim()))
599
+ .filter(Boolean),
600
+ );
601
+ }
602
+ const single = input.node?.market ?? input.node?.symbol;
603
+ return single ? [normalizeMarketSymbol(single)] : [];
604
+ }
605
+
606
+ /**
607
+ * Filter live items to the recipe-selected subset using the same market/side
608
+ * vocabulary as the extension perps adapter. With no selector and no
609
+ * mode=all, returns every item (read defaults to showing all live state).
610
+ */
611
+ export function selectedItems(input, items) {
612
+ const requested = configuredSymbols(input, items);
613
+ const selector =
614
+ input.node?.selector && typeof input.node.selector === 'object' ? input.node.selector : {};
615
+ const requestedSide = input.node?.side ?? selector.side;
616
+ const side = requestedSide ? normalizeSide(requestedSide) : undefined;
617
+ const symbols = requested.length > 0 ? new Set(requested) : null;
618
+ return items.filter((item) => {
619
+ if (symbols && !symbols.has(symbolForItem(item))) return false;
620
+ if (side && sideForItem(item) && sideForItem(item) !== side) return false;
621
+ return true;
622
+ });
623
+ }
624
+
625
+ export function redactPosition(position) {
626
+ return {
627
+ coin: position.coin ?? position.symbol ?? null,
628
+ size: position.size ?? position.szi ?? null,
629
+ side: position.side ?? null,
630
+ entryPrice: position.entryPrice ?? position.entryPx ?? null,
631
+ };
632
+ }
633
+
634
+ export function redactOrder(order) {
635
+ return {
636
+ coin: order.coin ?? order.symbol ?? null,
637
+ side: order.side ?? null,
638
+ size: order.size ?? order.sz ?? order.szi ?? null,
639
+ price: order.price ?? order.limitPx ?? order.px ?? null,
640
+ type: order.orderType ?? order.type ?? null,
641
+ };
642
+ }
643
+
644
+ // --- Adapter IO (mirrors the live-adapter contract in src/live-adapter-contract.ts) ---
645
+
646
+ async function loadInput() {
647
+ const inputPath = process.argv[2] || process.env.METAMASK_RECIPE_ADAPTER_INPUT;
648
+ if (!inputPath) throw new Error('Missing live adapter input path.');
649
+ return JSON.parse(await readFile(inputPath, 'utf8'));
650
+ }
651
+
652
+ async function writeOutput(input, output) {
653
+ await writeFile(input.outputPath, `${JSON.stringify(output, null, 2)}\n`);
654
+ }
655
+
656
+ // Format an error for stderr without truncating stack traces.
657
+ const fmtError = (e) => e?.stack ?? e?.message ?? String(e);
658
+
659
+ /**
660
+ * Tear the cached controller down so the process can drain and exit naturally.
661
+ *
662
+ * ROOT CAUSE this fixes: the WRITE path (placeOrder/closePosition) runs
663
+ * controller.init() → first write → HyperLiquidProvider.#ensureClientsInitialized
664
+ * → HyperLiquidClientService.initialize(), which creates a persistent,
665
+ * auto-reconnecting WebSocketTransport (reconnect config) and awaits
666
+ * wsTransport.ready(). Orders themselves go over the HTTP ExchangeClient and
667
+ * RETURN normally, but that open WebSocket keeps the Node event loop alive, so
668
+ * the adapter process never exits. The runner only resolves on the child's
669
+ * `close` event (src/live-adapter-contract.ts runProcess), so it waits out
670
+ * live_adapter_timeout_ms, SIGTERMs the child, and reports `fail` even though
671
+ * the trade filled and the output was computed. READ adapters use the
672
+ * standalone HTTP-only InfoClient (utils/standaloneInfoClient) — no WebSocket —
673
+ * so they never hang.
674
+ *
675
+ * controller.disconnect() closes the WebSocket transport and clears
676
+ * subscriptions (PerpsController.disconnect → HyperLiquidClientService teardown
677
+ * → wsTransport.close()). Once the socket is closed the event loop drains and
678
+ * Node exits naturally. We await it best-effort: a teardown failure must not
679
+ * mask a successful trade whose output is already written, but per the
680
+ * no-swallowed-exceptions rule we log the reason to stderr, then set
681
+ * process.exitCode and fall through — the process will drain and exit on its own.
682
+ *
683
+ * @param exitCode - Exit code to set before returning (0 = success).
684
+ */
685
+ async function disconnectController(exitCode) {
686
+ process.exitCode = exitCode;
687
+ const controller = cached?.controller;
688
+ if (controller && typeof controller.disconnect === 'function') {
689
+ try {
690
+ await controller.disconnect();
691
+ } catch (error) {
692
+ // Best-effort teardown: output already written; surface, don't swallow.
693
+ process.stderr.write(
694
+ `[core/perps] controller.disconnect() failed during teardown: ${fmtError(error)}\n`,
695
+ );
696
+ }
697
+ }
698
+ }
699
+
700
+ export async function runAdapter(callback) {
701
+ const input = await loadInput();
702
+ let exitCode = 0;
703
+ try {
704
+ await writeOutput(input, await callback(input));
705
+ } catch (error) {
706
+ exitCode = 1;
707
+ // Surface the failure; still tear down so a half-open WebSocket from a
708
+ // failed write doesn't leave the process wedged open.
709
+ process.stderr.write(`[core/perps] adapter failed: ${fmtError(error)}\n`);
710
+ } finally {
711
+ await disconnectController(exitCode);
712
+ }
713
+ }
714
+
715
+ /**
716
+ * True when the given module is the process entry point (tsx <file> <input>).
717
+ * Write adapters import each other (ensure → place/close → assert), so each
718
+ * top-level runAdapter() must be gated on this to avoid firing on import.
719
+ *
720
+ * @param importMetaUrl - The importing module's import.meta.url.
721
+ * @returns Whether this module was invoked directly by the harness.
722
+ */
723
+ export function isDirectRun(importMetaUrl) {
724
+ const entry = process.argv[1];
725
+ if (!entry) return false;
726
+ return importMetaUrl === pathToFileURL(entry).href;
727
+ }