@deeeed/metamask-harness 0.17.5 → 0.19.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 (169) hide show
  1. package/CHANGELOG.md +1059 -0
  2. package/README.md +91 -140
  3. package/adapters/core/inject.sh +1 -6
  4. package/adapters/extension/console-tail.mjs +55 -24
  5. package/adapters/extension/ensure-browser.sh +6 -1
  6. package/adapters/extension/inject.mjs +7 -13
  7. package/adapters/extension/launch-browser.cjs +7 -1
  8. package/adapters/extension/launch-webpack.cjs +83 -0
  9. package/adapters/extension/launch.sh +8 -11
  10. package/adapters/extension/lib/chrome-args.cjs +8 -1
  11. package/adapters/extension/lib/macos-focus.cjs +32 -0
  12. package/adapters/extension/live.sh +12 -21
  13. package/adapters/extension/reattach.sh +1 -0
  14. package/adapters/extension/seed-fixture.sh +4 -12
  15. package/adapters/extension/sidepanel-toggle.sh +4 -1
  16. package/adapters/extension/stamp-runtime-title.cjs +58 -0
  17. package/adapters/extension/start-watch.sh +17 -4
  18. package/adapters/extension/stop-viewers.sh +1 -1
  19. package/adapters/extension/sync-webpack-dist.cjs +107 -0
  20. package/adapters/extension/verify.sh +2 -2
  21. package/adapters/manifest.json +53 -37
  22. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +16 -0
  23. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +1 -1
  24. package/adapters/mobile/bridge-runtime/lib/match-bridge-target.cjs +3 -1
  25. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +2 -3
  26. package/adapters/mobile/cleanup.sh +16 -176
  27. package/adapters/mobile/inject.sh +91 -671
  28. package/adapters/mobile/launch-console-forwarder.cjs +39 -0
  29. package/adapters/mobile/open-device.sh +45 -7
  30. package/adapters/mobile/start-console-forwarder.sh +70 -0
  31. package/adapters/mobile/start-metro.sh +0 -41
  32. package/adapters/mobile/verify.sh +34 -83
  33. package/adapters/mobile/wait-for-bridge.sh +22 -8
  34. package/adapters/shared/harness-source-fingerprint.mjs +14 -12
  35. package/adapters/shared/resolve-slot-ports-core.mjs +17 -6
  36. package/adapters/shared/resolve-slot-ports.sh +17 -5
  37. package/adapters/shared/update-check-worker.mjs +43 -0
  38. package/bin/mm-harness +21 -2
  39. package/dist/adapters/core/surface.js +4 -1
  40. package/dist/adapters/extension/console-capture.js +95 -0
  41. package/dist/adapters/extension/product-config.js +110 -0
  42. package/dist/adapters/extension/runtime-decision.js +20 -2
  43. package/dist/adapters/extension/surface.js +22 -3
  44. package/dist/adapters/harness-freshness.js +49 -0
  45. package/dist/adapters/mobile/perps-env.js +43 -3
  46. package/dist/adapters/mobile/prepare.js +39 -7
  47. package/dist/adapters/mobile/surface.js +5 -2
  48. package/dist/adapters/resolve-slot-ports.js +2 -2
  49. package/dist/adapters/slot-ports.js +13 -16
  50. package/dist/adapters.js +56 -16
  51. package/dist/checkout-lock.js +27 -2
  52. package/dist/cli-color.js +19 -0
  53. package/dist/cli-commands.js +1 -1
  54. package/dist/cli.js +7 -18
  55. package/dist/command-contract.js +462 -0
  56. package/dist/command-journal.js +263 -0
  57. package/dist/commands/call.js +252 -57
  58. package/dist/commands/check.js +10 -4
  59. package/dist/commands/completion-candidates.js +17 -22
  60. package/dist/commands/device-target.js +27 -12
  61. package/dist/commands/doctor.js +106 -25
  62. package/dist/commands/fixtures.js +142 -35
  63. package/dist/commands/last.js +60 -0
  64. package/dist/commands/launch/extension.js +40 -17
  65. package/dist/commands/launch/index.js +207 -63
  66. package/dist/commands/launch/mobile.js +2 -0
  67. package/dist/commands/list-executables.js +187 -29
  68. package/dist/commands/logs.js +8 -6
  69. package/dist/commands/manifest.js +286 -35
  70. package/dist/commands/parse-args.js +68 -4
  71. package/dist/commands/provision.js +10 -4
  72. package/dist/commands/run-engine.js +772 -264
  73. package/dist/commands/run-report.js +12 -3
  74. package/dist/commands/run.js +378 -85
  75. package/dist/commands/shared.js +76 -4
  76. package/dist/commands/status-probe.js +4 -1
  77. package/dist/commands/status.js +2 -1
  78. package/dist/commands/stop.js +7 -2
  79. package/dist/commands/update.js +16 -28
  80. package/dist/completions-cache.js +1 -1
  81. package/dist/doctor.js +57 -10
  82. package/dist/harness.js +47 -137
  83. package/dist/heal-bounds.js +7 -2
  84. package/dist/json-stream.js +57 -0
  85. package/dist/live-adapter-contract.js +138 -32
  86. package/dist/manifest.js +161 -1
  87. package/dist/mm-harness-cli.js +146 -73
  88. package/dist/paths.js +2 -5
  89. package/dist/recipe-security.js +178 -0
  90. package/dist/run-diagnostics.js +261 -0
  91. package/dist/run-recording.js +1 -1
  92. package/dist/runner.js +140 -9
  93. package/docs/CONTRIBUTING.md +136 -0
  94. package/docs/QA.md +184 -0
  95. package/docs/RECIPES.md +122 -0
  96. package/docs/SECURITY.md +88 -0
  97. package/library/README.md +7 -3
  98. package/library/actions/core/perps/_controller.mjs +43 -56
  99. package/library/actions/core/perps/assert_orders.mjs +6 -7
  100. package/library/actions/core/perps/assert_positions.mjs +6 -7
  101. package/library/actions/core/perps/close_orders.mjs +2 -0
  102. package/library/actions/core/perps/close_positions.mjs +2 -0
  103. package/library/actions/core/perps/ensure_orders.mjs +4 -2
  104. package/library/actions/core/perps/ensure_positions.mjs +4 -2
  105. package/library/actions/core/perps/place_order.mjs +7 -3
  106. package/library/actions/core/perps/read_account.mjs +2 -2
  107. package/library/actions/core/perps/read_orders.mjs +2 -1
  108. package/library/actions/core/perps/read_positions.mjs +2 -1
  109. package/library/actions/core/wallet/list_accounts.mjs +95 -0
  110. package/library/actions/extension/perps/assert_orders.mjs +2 -1
  111. package/library/actions/extension/perps/assert_positions.mjs +2 -1
  112. package/library/actions/extension/perps/perps.mjs +43 -14
  113. package/library/actions/extension/platform/cdp.mjs +1 -0
  114. package/library/actions/extension/wallet/list_accounts.mjs +41 -0
  115. package/library/actions/mobile/perps/assert_orders.mjs +2 -1
  116. package/library/actions/mobile/perps/assert_positions.mjs +2 -1
  117. package/library/actions/mobile/perps/perps.mjs +40 -12
  118. package/library/actions/mobile/wallet/list_accounts.mjs +37 -0
  119. package/library/library.json +1 -1
  120. package/library/manifests/core.action-manifest.json +1214 -396
  121. package/library/manifests/extension.action-manifest.json +1635 -728
  122. package/library/manifests/mobile.action-manifest.json +1740 -727
  123. package/library/recipes/app/lifecycle.android-smoke.mobile.recipe.json +63 -81
  124. package/library/recipes/perps/clean-market-testnet.core.recipe.json +44 -0
  125. package/library/recipes/perps/clean-market-testnet.recipe.json +49 -0
  126. package/library/recipes/perps/lifecycle.recipe.json +136 -180
  127. package/library/recipes/perps/order-lifecycle.core.recipe.json +71 -67
  128. package/library/recipes/perps/performance.background-resume.mobile.recipe.json +51 -67
  129. package/library/recipes/perps/performance.cold-start.mobile.recipe.json +51 -67
  130. package/library/recipes/perps/performance.mobile.recipe.json +37 -51
  131. package/library/recipes/perps/performance.warm-start.mobile.recipe.json +44 -59
  132. package/library/recipes/perps/read-markets.core.recipe.json +29 -31
  133. package/library/recipes/perps/smoke.core.recipe.json +29 -32
  134. package/library/recipes/perps/smoke.extension.recipe.json +41 -44
  135. package/library/recipes/perps/smoke.mobile.recipe.json +42 -44
  136. package/library/recipes/perps/trading-lifecycle.core.recipe.json +69 -65
  137. package/library/recipes/runner/action-validation.extension.recipe.json +312 -398
  138. package/library/recipes/runner/action-validation.mobile.recipe.json +316 -402
  139. package/library/recipes/runner/smoke.core.recipe.json +25 -0
  140. package/library/recipes/runner/smoke.extension.recipe.json +23 -24
  141. package/library/recipes/runner/smoke.mobile.recipe.json +23 -24
  142. package/library/recipes/wallet/smoke.extension.recipe.json +40 -0
  143. package/library/recipes/wallet/smoke.mobile.recipe.json +40 -0
  144. package/package.json +12 -5
  145. package/scripts/completions.sh +8 -11
  146. package/scripts/validate-human-outcomes.mjs +169 -0
  147. package/adapters/mobile/overlay/app/dev-tools/AgenticService/AgentStepHud.tsx.patch +0 -185
  148. package/adapters/mobile/overlay/app/dev-tools/AgenticService/AgenticService.ts.patch +0 -1662
  149. package/adapters/mobile/overlay/compat/README.md +0 -28
  150. package/adapters/mobile/overlay/compat/rn81-message-event-source.patch +0 -42
  151. package/adapters/shared/resolve-farmslot-ports-core.mjs +0 -3
  152. package/adapters/shared/resolve-farmslot-ports.mjs +0 -5
  153. package/adapters/shared/resolve-farmslot-ports.sh +0 -7
  154. package/dist/adapters/extension/harness-freshness.js +0 -39
  155. package/dist/commands/flows.js +0 -62
  156. package/docs/ADAPTER-SURFACE.md +0 -119
  157. package/docs/CHEATSHEET.md +0 -61
  158. package/docs/CLI-SPEC.md +0 -1098
  159. package/docs/CODE-MAP.md +0 -62
  160. package/docs/DEBUG-HANDOVER.md +0 -36
  161. package/docs/MENTAL-MODEL.md +0 -295
  162. package/docs/UX-PRINCIPLES.md +0 -64
  163. package/docs/architecture.md +0 -398
  164. package/docs/live-adapter-contract.md +0 -188
  165. package/docs/package-boundaries.md +0 -47
  166. package/docs/perps-flow-catalog.md +0 -235
  167. package/docs/recipe-libraries.md +0 -301
  168. package/docs/runtime-file-conventions.md +0 -36
  169. package/library/flows/perps.flows.json +0 -64
@@ -90,12 +90,7 @@ function buildInfrastructure(stubbed) {
90
90
  // The fixture has the standard { accounts: [{ type, value, name }] } shape from
91
91
  // wallet-fixture.json.sample. The recipe node selects an account by name via
92
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.
93
+ // entry is the authoritative source for both the address and signing key.
99
94
 
100
95
  /**
101
96
  * Load wallet-fixture.json and return the named account entry.
@@ -155,26 +150,6 @@ function viemAccountFromFixtureEntry(entry) {
155
150
  return privateKeyToAccount(normalized);
156
151
  }
157
152
 
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
153
  /**
179
154
  * Resolve the account name to use for signing.
180
155
  * Precedence: node.account_name → node.account (if not an address) → "dev1".
@@ -198,9 +173,7 @@ function resolveAccountName(input) {
198
173
 
199
174
  /**
200
175
  * 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).
176
+ * The wallet fixture account selected by name is the only signing source.
204
177
  *
205
178
  * @param input - Adapter input (context.projectRoot, node.account_name).
206
179
  * @returns { account: ViemAccount, address: string }
@@ -209,7 +182,6 @@ async function resolveSignerFromFixture(input) {
209
182
  const projectRoot = input.context?.projectRoot;
210
183
  const accountName = resolveAccountName(input);
211
184
 
212
- // Primary: fixture
213
185
  if (projectRoot) {
214
186
  const entry = await loadFixtureAccount(projectRoot, accountName);
215
187
  if (entry) {
@@ -218,32 +190,15 @@ async function resolveSignerFromFixture(input) {
218
190
  }
219
191
  }
220
192
 
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 };
193
+ throw new Error(
194
+ `core perps writes require wallet-fixture.json account "${accountName}". ` +
195
+ `Next: run mm-harness fixtures set`,
196
+ );
241
197
  }
242
198
 
243
199
  /**
244
200
  * 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.
201
+ * Prefer the wallet fixture account; explicit node addresses support read-only calls.
247
202
  */
248
203
  async function requireAccountAddress(input) {
249
204
  const projectRoot = input.context?.projectRoot;
@@ -261,13 +216,13 @@ async function requireAccountAddress(input) {
261
216
  }
262
217
  }
263
218
 
264
- // Fallback: explicit address from node or env
219
+ // Read-only calls may name an address directly.
265
220
  const fromNode = input.node?.account ?? input.node?.address ?? input.node?.userAddress;
266
- const address = String(fromNode ?? process.env.MM_TEST_ACCOUNT_ADDRESS ?? '').trim();
221
+ const address = String(fromNode ?? '').trim();
267
222
  if (!/^0x[0-9a-fA-F]{40}$/u.test(address)) {
268
223
  throw new Error(
269
224
  `core perps reads require a wallet-fixture.json with account "${accountName}", ` +
270
- `or a 0x EVM address via node.account / MM_TEST_ACCOUNT_ADDRESS.`,
225
+ `or a 0x EVM address via node.account.`,
271
226
  );
272
227
  }
273
228
  return address;
@@ -291,6 +246,14 @@ export function resolveNetwork(input) {
291
246
  return raw;
292
247
  }
293
248
 
249
+ export function requireRuntimeExport(moduleNamespace, exportName, sourceLabel) {
250
+ const value = moduleNamespace?.[exportName] ?? moduleNamespace?.default?.[exportName];
251
+ if (value === undefined) {
252
+ throw new Error(`${sourceLabel} does not export ${exportName}.`);
253
+ }
254
+ return value;
255
+ }
256
+
294
257
  /**
295
258
  * Instantiate the PerpsController headlessly against the resolved core checkout.
296
259
  * Returns the controller plus the resolved read account address. Cached per
@@ -306,10 +269,25 @@ export async function getCoreController(input) {
306
269
  return { ...cached, accountAddress };
307
270
  }
308
271
 
309
- const [{ PerpsController }, { Messenger, MOCK_ANY_NAMESPACE }] = await Promise.all([
272
+ const [controllerModule, messengerModule] = await Promise.all([
310
273
  import(controllerEntry(projectRoot)),
311
274
  import(messengerEntry(projectRoot)),
312
275
  ]);
276
+ const PerpsController = requireRuntimeExport(
277
+ controllerModule,
278
+ 'PerpsController',
279
+ 'Core perps-controller entrypoint',
280
+ );
281
+ const Messenger = requireRuntimeExport(
282
+ messengerModule,
283
+ 'Messenger',
284
+ 'Core messenger entrypoint',
285
+ );
286
+ const MOCK_ANY_NAMESPACE = requireRuntimeExport(
287
+ messengerModule,
288
+ 'MOCK_ANY_NAMESPACE',
289
+ 'Core messenger entrypoint',
290
+ );
313
291
 
314
292
  const stubbed = new Set();
315
293
  const infrastructure = buildInfrastructure(stubbed);
@@ -603,6 +581,15 @@ export function configuredSymbols(input, items) {
603
581
  return single ? [normalizeMarketSymbol(single)] : [];
604
582
  }
605
583
 
584
+ export function requireExplicitSelection(input) {
585
+ const selector =
586
+ input.node?.selector && typeof input.node.selector === 'object' ? input.node.selector : {};
587
+ const mode = String(input.node?.mode ?? selector.mode ?? '').toLowerCase();
588
+ if (configuredSymbols(input, []).length === 0 && mode !== 'all') {
589
+ throw new Error(`${input.action} requires market=<symbol> or mode=all.`);
590
+ }
591
+ }
592
+
606
593
  /**
607
594
  * Filter live items to the recipe-selected subset using the same market/side
608
595
  * vocabulary as the extension perps adapter. With no selector and no
@@ -2,6 +2,7 @@ import {
2
2
  getCoreController,
3
3
  isDirectRun,
4
4
  redactOrder,
5
+ requireExplicitSelection,
5
6
  runAdapter,
6
7
  selectedItems,
7
8
  } from './_controller.mjs';
@@ -12,13 +13,15 @@ import {
12
13
  // assert_positions.mjs.
13
14
 
14
15
  export function expectedOpen(input) {
15
- const state = String(input.node?.state ?? input.node?.order ?? 'open').toLowerCase();
16
+ if (input.node?.state == null) throw new Error('metamask.perps.assert_orders requires state=open or state=none.');
17
+ const state = String(input.node.state).toLowerCase();
16
18
  if (state === 'open' || state === 'present') return true;
17
19
  if (state === 'none' || state === 'closed' || state === 'absent') return false;
18
20
  throw new Error(`metamask.perps.assert_orders received unsupported state: ${state}`);
19
21
  }
20
22
 
21
23
  export async function assertOrders(input, expectOpen = expectedOpen(input)) {
24
+ requireExplicitSelection(input);
22
25
  const { controller, accountAddress, network } = await getCoreController(input);
23
26
  const orders = await controller.getOpenOrders({
24
27
  standalone: true,
@@ -28,14 +31,10 @@ export async function assertOrders(input, expectOpen = expectedOpen(input)) {
28
31
  const hasOrder = matching.length > 0;
29
32
 
30
33
  if (expectOpen && !hasOrder) {
31
- throw new Error(
32
- `Expected selected Perps open order(s), but none matched ${JSON.stringify(input.node)}.`,
33
- );
34
+ throw new Error('Expected at least one matching open Perps order, but found none.');
34
35
  }
35
36
  if (!expectOpen && hasOrder) {
36
- throw new Error(
37
- `Expected no selected Perps open orders, but ${matching.length} matched ${JSON.stringify(input.node)}.`,
38
- );
37
+ throw new Error(`Expected no matching open Perps orders, but found ${matching.length}.`);
39
38
  }
40
39
 
41
40
  return {
@@ -2,6 +2,7 @@ import {
2
2
  getCoreController,
3
3
  isDirectRun,
4
4
  redactPosition,
5
+ requireExplicitSelection,
5
6
  runAdapter,
6
7
  selectedItems,
7
8
  } from './_controller.mjs';
@@ -11,13 +12,15 @@ import {
11
12
  // throws on mismatch so the recipe fails loudly.
12
13
 
13
14
  export function expectedOpen(input) {
14
- const state = String(input.node?.state ?? input.node?.position ?? 'open').toLowerCase();
15
+ if (input.node?.state == null) throw new Error('metamask.perps.assert_positions requires state=open or state=none.');
16
+ const state = String(input.node.state).toLowerCase();
15
17
  if (state === 'open' || state === 'present') return true;
16
18
  if (state === 'none' || state === 'closed' || state === 'absent') return false;
17
19
  throw new Error(`metamask.perps.assert_positions received unsupported state: ${state}`);
18
20
  }
19
21
 
20
22
  export async function assertPositions(input, expectOpen = expectedOpen(input)) {
23
+ requireExplicitSelection(input);
21
24
  const { controller, accountAddress, network } = await getCoreController(input);
22
25
  const positions = await controller.getPositions({
23
26
  standalone: true,
@@ -27,14 +30,10 @@ export async function assertPositions(input, expectOpen = expectedOpen(input)) {
27
30
  const hasPosition = matching.length > 0;
28
31
 
29
32
  if (expectOpen && !hasPosition) {
30
- throw new Error(
31
- `Expected selected Perps position(s), but none matched ${JSON.stringify(input.node)}.`,
32
- );
33
+ throw new Error('Expected at least one matching open Perps position, but found none.');
33
34
  }
34
35
  if (!expectOpen && hasPosition) {
35
- throw new Error(
36
- `Expected no selected Perps positions, but ${matching.length} matched ${JSON.stringify(input.node)}.`,
37
- );
36
+ throw new Error(`Expected no matching open Perps positions, but found ${matching.length}.`);
38
37
  }
39
38
 
40
39
  return {
@@ -2,6 +2,7 @@ import {
2
2
  getCoreControllerWithSigner,
3
3
  isDirectRun,
4
4
  redactOrder,
5
+ requireExplicitSelection,
5
6
  runAdapter,
6
7
  selectedItems,
7
8
  symbolForItem,
@@ -37,6 +38,7 @@ async function waitForOrdersAbsent(controller, accountAddress, symbols, timeoutM
37
38
  }
38
39
 
39
40
  export async function closeOrders(input) {
41
+ requireExplicitSelection(input);
40
42
  const { controller, accountAddress, network } = await getCoreControllerWithSigner(input);
41
43
  const timeoutMs = Number(input.node?.timeout_ms ?? 30000);
42
44
 
@@ -3,6 +3,7 @@ import {
3
3
  getCoreControllerWithSigner,
4
4
  isDirectRun,
5
5
  redactPosition,
6
+ requireExplicitSelection,
6
7
  runAdapter,
7
8
  selectedItems,
8
9
  symbolForItem,
@@ -36,6 +37,7 @@ async function waitForPositionsAbsent(controller, accountAddress, symbols, timeo
36
37
  }
37
38
 
38
39
  export async function closePositions(input) {
40
+ requireExplicitSelection(input);
39
41
  const { controller, accountAddress, network } = await getCoreControllerWithSigner(input);
40
42
  const timeoutMs = Number(input.node?.timeout_ms ?? 30000);
41
43
  const maxSlippageBps = Number(
@@ -1,4 +1,4 @@
1
- import { getCoreController, isDirectRun, runAdapter, selectedItems } from './_controller.mjs';
1
+ import { getCoreController, isDirectRun, requireExplicitSelection, runAdapter, selectedItems } from './_controller.mjs';
2
2
  import { assertOrders } from './assert_orders.mjs';
3
3
  import { closeOrders } from './close_orders.mjs';
4
4
  import { placeOrder } from './place_order.mjs';
@@ -8,7 +8,9 @@ import { placeOrder } from './place_order.mjs';
8
8
  // order state. Mirrors ensure_positions.mjs.
9
9
 
10
10
  export async function ensureOrders(input) {
11
- const state = String(input.node?.state ?? input.node?.order ?? 'none').toLowerCase();
11
+ if (input.node?.state == null) throw new Error('metamask.perps.ensure_orders requires state=open or state=none.');
12
+ requireExplicitSelection(input);
13
+ const state = String(input.node.state).toLowerCase();
12
14
 
13
15
  if (state === 'none' || state === 'closed' || state === 'absent') {
14
16
  const close = await closeOrders(input);
@@ -1,4 +1,4 @@
1
- import { getCoreController, isDirectRun, runAdapter, selectedItems } from './_controller.mjs';
1
+ import { getCoreController, isDirectRun, requireExplicitSelection, runAdapter, selectedItems } from './_controller.mjs';
2
2
  import { assertPositions } from './assert_positions.mjs';
3
3
  import { closePositions } from './close_positions.mjs';
4
4
  import { placeOrder } from './place_order.mjs';
@@ -8,7 +8,9 @@ import { placeOrder } from './place_order.mjs';
8
8
  // state. Mirrors the extension ensure_positions semantics.
9
9
 
10
10
  export async function ensurePositions(input) {
11
- const state = String(input.node?.state ?? input.node?.position ?? 'none').toLowerCase();
11
+ if (input.node?.state == null) throw new Error('metamask.perps.ensure_positions requires state=open or state=none.');
12
+ requireExplicitSelection(input);
13
+ const state = String(input.node.state).toLowerCase();
12
14
 
13
15
  if (state === 'none' || state === 'closed' || state === 'absent') {
14
16
  const close = await closePositions(input);
@@ -81,12 +81,16 @@ function resolveLimitPrice(input, isBuy, mid) {
81
81
  }
82
82
 
83
83
  export async function placeOrder(input) {
84
- const { controller, accountAddress, network } = await getCoreControllerWithSigner(input);
85
84
  const symbol = resolveSymbol(input);
86
- const side = String(input.node?.side ?? 'long').toLowerCase();
85
+ if (input.node?.side == null) throw new Error('metamask.perps.place_order requires side=long or side=short.');
86
+ if (input.node?.amount == null && input.node?.notional == null) {
87
+ throw new Error('metamask.perps.place_order requires amount=<usd> or notional=<usd>.');
88
+ }
89
+ const { controller, accountAddress, network } = await getCoreControllerWithSigner(input);
90
+ const side = String(input.node.side).toLowerCase();
87
91
  const isBuy = side !== 'short';
88
92
  const orderType = resolveOrderType(input);
89
- const usdAmount = String(input.node?.amount ?? input.node?.notional ?? '11');
93
+ const usdAmount = String(input.node.amount ?? input.node.notional);
90
94
  const leverage = Number(input.node?.leverage ?? 3);
91
95
  const maxSlippageBps = Number(
92
96
  input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300,
@@ -1,4 +1,4 @@
1
- import { getCoreController, runAdapter } from './_controller.mjs';
1
+ import { getCoreController, isDirectRun, runAdapter } from './_controller.mjs';
2
2
 
3
3
  function redactAccount(account) {
4
4
  return {
@@ -27,4 +27,4 @@ export async function readAccount(input) {
27
27
  };
28
28
  }
29
29
 
30
- runAdapter(readAccount);
30
+ if (isDirectRun(import.meta.url)) runAdapter(readAccount);
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  getCoreController,
3
+ isDirectRun,
3
4
  redactOrder,
4
5
  runAdapter,
5
6
  selectedItems,
@@ -24,4 +25,4 @@ export async function readOrders(input) {
24
25
  };
25
26
  }
26
27
 
27
- runAdapter(readOrders);
28
+ if (isDirectRun(import.meta.url)) runAdapter(readOrders);
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  getCoreController,
3
+ isDirectRun,
3
4
  redactPosition,
4
5
  runAdapter,
5
6
  selectedItems,
@@ -24,4 +25,4 @@ export async function readPositions(input) {
24
25
  };
25
26
  }
26
27
 
27
- runAdapter(readPositions);
28
+ if (isDirectRun(import.meta.url)) runAdapter(readPositions);
@@ -0,0 +1,95 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { pathToFileURL } from 'node:url';
3
+ import { mnemonicToAccount, privateKeyToAccount } from 'viem/accounts';
4
+
5
+ import { walletFixturePath } from '../../harness-exports.mjs';
6
+
7
+ function fixtureAccountName(base, index, count) {
8
+ if (count === 1) return base || 'account1';
9
+ const match = /^(.*?)(\d+)$/u.exec(base || 'account1');
10
+ return match
11
+ ? `${match[1]}${Number(match[2]) + index}`
12
+ : `${base || 'account'}${index + 1}`;
13
+ }
14
+
15
+ function mnemonicCount(entry, fixturePath, index) {
16
+ const count = Number(entry.count ?? entry.numberOfAccounts ?? 1);
17
+ if (!Number.isInteger(count) || count < 1 || count > 100) {
18
+ throw new Error(`${fixturePath} accounts[${index}] mnemonic count must be an integer from 1 through 100.`);
19
+ }
20
+ return count;
21
+ }
22
+
23
+ function deriveFixtureAccounts(fixture, fixturePath) {
24
+ if (!Array.isArray(fixture.accounts) || fixture.accounts.length === 0) {
25
+ throw new Error(`wallet fixture at ${fixturePath} has no accounts array.`);
26
+ }
27
+ return fixture.accounts.flatMap((entry, fixtureIndex) => {
28
+ if (!entry || typeof entry !== 'object' || typeof entry.value !== 'string' || !entry.value.trim()) {
29
+ throw new Error(`${fixturePath} accounts[${fixtureIndex}] must contain a non-empty value.`);
30
+ }
31
+ if (entry.type === 'mnemonic') {
32
+ const count = mnemonicCount(entry, fixturePath, fixtureIndex);
33
+ return Array.from({ length: count }, (_, addressIndex) => {
34
+ const account = mnemonicToAccount(entry.value.trim(), { addressIndex });
35
+ return {
36
+ id: `fixture:${fixtureAccountName(entry.name, addressIndex, count)}`,
37
+ address: account.address,
38
+ name: fixtureAccountName(entry.name, addressIndex, count),
39
+ type: 'eip155:eoa',
40
+ };
41
+ });
42
+ }
43
+ if (entry.type === 'privateKey') {
44
+ const raw = entry.value.trim();
45
+ const normalized = raw.startsWith('0x') ? raw : `0x${raw}`;
46
+ if (!/^0x[0-9a-fA-F]{64}$/u.test(normalized)) {
47
+ throw new Error(`${fixturePath} accounts[${fixtureIndex}] privateKey is not a 32-byte hex key.`);
48
+ }
49
+ const account = privateKeyToAccount(normalized);
50
+ const name = entry.name || `account${fixtureIndex + 1}`;
51
+ return [{ id: `fixture:${name}`, address: account.address, name, type: 'eip155:eoa' }];
52
+ }
53
+ throw new Error(`${fixturePath} accounts[${fixtureIndex}].type must be mnemonic or privateKey.`);
54
+ });
55
+ }
56
+
57
+ async function listAccounts(input) {
58
+ const scope = input.node?.scope == null ? 'named' : String(input.node.scope);
59
+ if (scope !== 'selected' && scope !== 'named' && scope !== 'evm' && scope !== 'all') {
60
+ throw new Error('metamask.wallet.list_accounts scope must be selected, named, evm, or all.');
61
+ }
62
+ const fixturePath = walletFixturePath(input.context.projectRoot);
63
+ const fixture = JSON.parse(await readFile(fixturePath, 'utf8'));
64
+ const selectedName = String(input.node?.account_name ?? input.node?.account ?? 'dev1');
65
+ const accounts = deriveFixtureAccounts(fixture, fixturePath)
66
+ .map((account) => ({
67
+ ...account,
68
+ selected: account.name === selectedName,
69
+ }))
70
+ .filter((account) => {
71
+ if (scope === 'selected') return account.selected;
72
+ if (scope === 'all') return true;
73
+ if (scope === 'evm') return /^0x[0-9a-f]{40}$/iu.test(account.address);
74
+ return account.selected || Boolean(account.name.trim());
75
+ });
76
+ return {
77
+ action: input.action,
78
+ source: 'core-headless-wallet-fixture',
79
+ scope,
80
+ accounts,
81
+ count: accounts.length,
82
+ redacted: true,
83
+ selectionSource: 'explicit-controller-account-input',
84
+ proofPath: 'core-wallet-fixture-accounts',
85
+ };
86
+ }
87
+
88
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
89
+ const inputPath = process.argv[2] || process.env.METAMASK_RECIPE_ADAPTER_INPUT;
90
+ if (!inputPath) throw new Error('Adapter input path is required.');
91
+ const input = JSON.parse(await readFile(inputPath, 'utf8'));
92
+ const result = await listAccounts(input);
93
+ if (input.outputPath) await writeFile(input.outputPath, `${JSON.stringify(result, null, 2)}\n`);
94
+ else process.stdout.write(`${JSON.stringify(result)}\n`);
95
+ }
@@ -2,7 +2,8 @@ import { runAdapter } from '../platform/cdp.mjs';
2
2
  import { assertOrders } from './perps.mjs';
3
3
 
4
4
  function expectedOpen(input) {
5
- const state = String(input.node?.state ?? input.node?.orders ?? 'open').toLowerCase();
5
+ if (input.node?.state == null) throw new Error('metamask.perps.assert_orders requires state=open or state=none.');
6
+ const state = String(input.node.state).toLowerCase();
6
7
  if (state === 'open' || state === 'present') return true;
7
8
  if (state === 'none' || state === 'closed' || state === 'absent') return false;
8
9
  throw new Error(`metamask.perps.assert_orders received unsupported state: ${state}`);
@@ -2,7 +2,8 @@ import { runAdapter } from '../platform/cdp.mjs';
2
2
  import { assertPositions } from './perps.mjs';
3
3
 
4
4
  function expectedOpen(input) {
5
- const state = String(input.node?.state ?? input.node?.position ?? 'open').toLowerCase();
5
+ if (input.node?.state == null) throw new Error('metamask.perps.assert_positions requires state=open or state=none.');
6
+ const state = String(input.node.state).toLowerCase();
6
7
  if (state === 'open' || state === 'present') return true;
7
8
  if (state === 'none' || state === 'closed' || state === 'absent') return false;
8
9
  throw new Error(`metamask.perps.assert_positions received unsupported state: ${state}`);
@@ -24,20 +24,39 @@ function configuredSymbols(input, items) {
24
24
  const explicit = input.node?.markets ?? input.node?.symbols ?? selector.markets ?? selector.symbols;
25
25
  if (Array.isArray(explicit) && explicit.length > 0) return uniqueSymbols(explicit.map(normalizeMarketSymbol));
26
26
  if (typeof explicit === 'string' && explicit.length > 0) return uniqueSymbols(explicit.split(',').map((part) => normalizeMarketSymbol(part.trim())).filter(Boolean));
27
- return [marketSymbol(input)];
27
+ const single = input.node?.market ?? input.node?.symbol ?? selector.market ?? selector.symbol;
28
+ return single ? [normalizeMarketSymbol(single)] : [];
28
29
  }
29
30
 
30
- function selectedItems(input, items) {
31
- const symbols = new Set(configuredSymbols(input, items));
31
+ export function selectedItems(input, items) {
32
+ const requested = configuredSymbols(input, items);
33
+ const symbols = requested.length > 0 ? new Set(requested) : null;
32
34
  const selector = input.node?.selector && typeof input.node.selector === 'object' ? input.node.selector : {};
33
35
  const side = input.node?.side ?? selector.side;
34
36
  return items.filter((item) => {
35
- if (!symbols.has(symbolForItem(item))) return false;
37
+ if (symbols && !symbols.has(symbolForItem(item))) return false;
36
38
  if (side && sideForItem(item) && sideForItem(item) !== String(side).toLowerCase()) return false;
37
39
  return true;
38
40
  });
39
41
  }
40
42
 
43
+ function requireExplicitSelection(input) {
44
+ const node = input.node ?? {};
45
+ const selector = node.selector && typeof node.selector === 'object' ? node.selector : {};
46
+ const values = [node.market, node.symbol, node.markets, node.symbols, selector.market, selector.symbol, selector.markets, selector.symbols];
47
+ const selected = values.some((value) => Array.isArray(value) ? value.length > 0 : value != null && String(value).trim() !== '');
48
+ if (!selected && node.mode !== 'all' && selector.mode !== 'all') {
49
+ throw new Error(`${input.action} requires market=<symbol> or mode=all.`);
50
+ }
51
+ }
52
+
53
+ function requirePlaceOrderInputs(input) {
54
+ const node = input.node ?? {};
55
+ if (node.market == null && node.symbol == null) throw new Error(`${input.action} requires market=<symbol>.`);
56
+ if (node.side == null) throw new Error(`${input.action} requires side=long or side=short.`);
57
+ if (node.amount == null && node.notional == null) throw new Error(`${input.action} requires amount=<usd> or notional=<usd>.`);
58
+ }
59
+
41
60
  export function positionSelectionState(input, positions) {
42
61
  return { hasMatching: selectedItems(input, positions).length > 0 };
43
62
  }
@@ -85,33 +104,37 @@ export async function readOrders(input) {
85
104
  }
86
105
 
87
106
  export async function assertPositions(input, expectedOpen) {
107
+ requireExplicitSelection(input);
88
108
  return withExtensionPage(input, async (page) => {
89
109
  const timeoutMs = Number(input.node?.timeout_ms ?? 0);
90
- if (expectedOpen && timeoutMs > 0) {
91
- await waitForPositionPresent(page, marketSymbol(input), timeoutMs);
110
+ const requested = configuredSymbols(input, []);
111
+ if (expectedOpen && timeoutMs > 0 && requested.length === 1) {
112
+ await waitForPositionPresent(page, requested[0], timeoutMs);
92
113
  }
93
114
  const state = await page.readPositions();
94
115
  if (!state.available) throw new Error('stateHooks.submitRequestToBackground is unavailable; cannot assert live Perps positions.');
95
116
  const matching = selectedItems(input, state.positions);
96
117
  const hasPosition = matching.length > 0;
97
- if (expectedOpen && !hasPosition) throw new Error(`Expected selected Perps position(s), but none matched ${JSON.stringify(input.node)}.`);
98
- if (!expectedOpen && hasPosition) throw new Error(`Expected no selected Perps positions, but ${matching.length} matched ${JSON.stringify(input.node)}.`);
118
+ if (expectedOpen && !hasPosition) throw new Error('Expected at least one matching open Perps position, but found none.');
119
+ if (!expectedOpen && hasPosition) throw new Error(`Expected no matching open Perps positions, but found ${matching.length}.`);
99
120
  return { action: input.action, expectedOpen, matchingCount: matching.length, positions: matching.map(redactPosition), source: state.source ?? 'perps-stream-manager-cache' };
100
121
  });
101
122
  }
102
123
 
103
124
  export async function assertOrders(input, expectedOpen) {
125
+ requireExplicitSelection(input);
104
126
  return withExtensionPage(input, async (page) => {
105
127
  const orders = await readOpenOrders(page);
106
128
  const matching = selectedItems(input, orders);
107
129
  const hasOrders = matching.length > 0;
108
- if (expectedOpen && !hasOrders) throw new Error(`Expected selected Perps order(s), but none matched ${JSON.stringify(input.node)}.`);
109
- if (!expectedOpen && hasOrders) throw new Error(`Expected no selected Perps orders, but ${matching.length} matched ${JSON.stringify(input.node)}.`);
130
+ if (expectedOpen && !hasOrders) throw new Error('Expected at least one matching open Perps order, but found none.');
131
+ if (!expectedOpen && hasOrders) throw new Error(`Expected no matching open Perps orders, but found ${matching.length}.`);
110
132
  return { action: input.action, expectedOpen, matchingCount: matching.length, orders: matching.map(redactOrder), source: 'perps-stream-manager-cache' };
111
133
  });
112
134
  }
113
135
 
114
136
  export async function closePositions(input) {
137
+ requireExplicitSelection(input);
115
138
  return withExtensionPage(input, async (page) => {
116
139
  const timeoutMs = Number(input.node?.timeout_ms ?? 30000);
117
140
  const state = await page.readPositions();
@@ -147,6 +170,7 @@ export async function closePositions(input) {
147
170
  }
148
171
 
149
172
  export async function closeOrders(input) {
173
+ requireExplicitSelection(input);
150
174
  return withExtensionPage(input, async (page) => {
151
175
  const timeoutMs = Number(input.node?.timeout_ms ?? 30000);
152
176
  const before = await readOpenOrders(page);
@@ -370,9 +394,10 @@ async function waitForPositionPresent(page, symbol, timeoutMs) {
370
394
  }
371
395
 
372
396
  export async function placeOrder(input) {
397
+ requirePlaceOrderInputs(input);
373
398
  const symbol = marketSymbol(input);
374
- const side = String(input.node?.side ?? 'long').toLowerCase();
375
- const amount = String(input.node?.amount ?? input.node?.notional ?? '11');
399
+ const side = String(input.node.side).toLowerCase();
400
+ const amount = String(input.node.amount ?? input.node.notional);
376
401
  const leverage = Number(input.node?.leverage ?? 3);
377
402
  return withExtensionPage(input, async (page) => {
378
403
  await page.navigateHash(`#/perps/market/${encodeURIComponent(symbol)}`);
@@ -427,7 +452,9 @@ export async function placeOrder(input) {
427
452
  }
428
453
 
429
454
  export async function ensurePositions(input) {
430
- const state = String(input.node?.state ?? input.node?.position ?? 'none').toLowerCase();
455
+ if (input.node?.state == null) throw new Error('metamask.perps.ensure_positions requires state=open or state=none.');
456
+ requireExplicitSelection(input);
457
+ const state = String(input.node.state).toLowerCase();
431
458
  if (state === 'none' || state === 'closed' || state === 'absent') {
432
459
  const close = await closePositions(input);
433
460
  return { ...(await assertPositions(input, false)), close };
@@ -445,7 +472,9 @@ export async function ensurePositions(input) {
445
472
  }
446
473
 
447
474
  export async function ensureOrders(input) {
448
- const state = String(input.node?.state ?? input.node?.orders ?? 'none').toLowerCase();
475
+ if (input.node?.state == null) throw new Error('metamask.perps.ensure_orders requires state=open or state=none.');
476
+ requireExplicitSelection(input);
477
+ const state = String(input.node.state).toLowerCase();
449
478
  if (state === 'none' || state === 'closed' || state === 'absent') {
450
479
  const close = await closeOrders(input);
451
480
  return { ...(await assertOrders(input, false)), close };
@@ -397,6 +397,7 @@ async function launchExistingDistRuntime(input, port) {
397
397
  try {
398
398
  child = spawn(chrome, [
399
399
  `--user-data-dir=${profileDir}`,
400
+ ...(process.platform === 'darwin' ? ['--use-mock-keychain'] : []),
400
401
  '--remote-debugging-address=127.0.0.1',
401
402
  `--remote-debugging-port=${port}`,
402
403
  '--no-first-run',