@deeeed/metamask-harness 0.79.0 → 0.80.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 (210) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +1 -1
  3. package/adapters/extension/ensure-browser.sh +2 -2
  4. package/adapters/extension/inject.mjs +0 -1
  5. package/adapters/extension/launch-browser.cjs +39 -613
  6. package/adapters/extension/readiness.mjs +2 -5
  7. package/adapters/extension/reattach.sh +2 -2
  8. package/adapters/extension/stamp-runtime-title.cjs +1 -1
  9. package/adapters/manifest.json +2 -34
  10. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +57 -1358
  11. package/adapters/mobile/bridge-runtime/teaching.cjs +20 -0
  12. package/adapters/mobile/inject.sh +16 -0
  13. package/adapters/mobile/launch-console-forwarder.cjs +1 -2
  14. package/adapters/mobile/open-device.sh +2 -2
  15. package/adapters/mobile/reload-app.mjs +4 -4
  16. package/adapters/mobile/reset-app-data.sh +1 -1
  17. package/adapters/mobile/start-console-forwarder.sh +3 -2
  18. package/adapters/mobile/start-metro.sh +17 -12
  19. package/adapters/mobile/wait-for-bridge.cjs +2 -2
  20. package/adapters/shared/harness-source-fingerprint.mjs +13 -8
  21. package/adapters/shared/open-debug.mjs +2 -2
  22. package/adapters/shared/sync-wallet-fixture.sh +8 -5
  23. package/adapters/terminal/cleanup.mjs +2 -1
  24. package/adapters/terminal/inject.mjs +2 -1
  25. package/bin/mm-harness +9 -28
  26. package/dist/adapters/checkout-files.js +16 -0
  27. package/dist/adapters/console-capture.js +14 -1
  28. package/dist/adapters/core/actions.js +4 -3
  29. package/dist/adapters/core/overlay.js +1 -1
  30. package/dist/adapters/core/readiness.js +28 -1
  31. package/dist/adapters/core/run.js +2 -2
  32. package/dist/adapters/core/surface.js +16 -1
  33. package/dist/adapters/extension/browser-pid.js +56 -0
  34. package/dist/adapters/extension/console-capture.js +1 -1
  35. package/dist/adapters/extension/doctor-checks.js +1 -1
  36. package/dist/adapters/extension/ensure-ready.js +5 -4
  37. package/dist/adapters/extension/extension-id.js +1 -1
  38. package/dist/adapters/extension/feature-flags.js +1 -1
  39. package/dist/adapters/extension/fixtures.js +1 -2
  40. package/dist/adapters/extension/launch.js +4 -5
  41. package/dist/adapters/extension/launched-browser.js +1 -2
  42. package/dist/adapters/extension/network-observer.js +1 -1
  43. package/dist/adapters/extension/overlay.js +1 -1
  44. package/dist/adapters/extension/product-config.js +1 -1
  45. package/dist/adapters/extension/readiness.js +1 -1
  46. package/dist/adapters/extension/reload.js +1 -1
  47. package/dist/adapters/extension/run.js +24 -10
  48. package/dist/adapters/extension/runtime-decision.js +2 -1
  49. package/dist/adapters/extension/runtime.js +9 -5
  50. package/dist/adapters/extension/surface.js +15 -2
  51. package/dist/adapters/extension/verify.js +2 -2
  52. package/dist/adapters/harness-freshness.js +2 -1
  53. package/dist/adapters/mobile/android-gfxinfo-performance.js +1 -3
  54. package/dist/adapters/mobile/app-identity.js +10 -0
  55. package/dist/adapters/mobile/doctor-checks.js +1 -1
  56. package/dist/adapters/mobile/fixtures.js +1 -1
  57. package/dist/adapters/mobile/launch.js +27 -28
  58. package/dist/adapters/mobile/lifecycle.js +1 -1
  59. package/dist/adapters/mobile/metro-env.js +9 -59
  60. package/dist/adapters/mobile/performance-observer.js +1 -1
  61. package/dist/adapters/mobile/prepare-target.js +1 -1
  62. package/dist/adapters/mobile/prepare.js +3 -4
  63. package/dist/adapters/mobile/provision.js +1 -1
  64. package/dist/adapters/mobile/readiness.js +1 -1
  65. package/dist/adapters/mobile/recording-target.js +15 -0
  66. package/dist/adapters/mobile/release-artifact-state.js +1 -1
  67. package/dist/adapters/mobile/reload.js +1 -1
  68. package/dist/adapters/mobile/run.js +8 -7
  69. package/dist/adapters/mobile/runtime-decision.js +2 -4
  70. package/dist/adapters/mobile/source-freshness.js +22 -126
  71. package/dist/adapters/mobile/surface.js +26 -3
  72. package/dist/adapters/mobile/ui.js +16 -5
  73. package/dist/adapters/mobile/verify.js +2 -3
  74. package/dist/adapters/performance/cdp-trace.js +1 -1
  75. package/dist/adapters/performance/device-platform.js +1 -1
  76. package/dist/adapters/slot-ports.js +1 -1
  77. package/dist/adapters/surface.js +1 -0
  78. package/dist/adapters/terminal/console-capture.js +1 -1
  79. package/dist/adapters/terminal/launch.js +1 -1
  80. package/dist/adapters/terminal/run.js +25 -2
  81. package/dist/adapters/terminal/surface.js +7 -1
  82. package/dist/adapters.js +8 -4
  83. package/dist/advisor-policy.js +1 -1
  84. package/dist/checkout-view.js +1 -2
  85. package/dist/cli.js +28 -15
  86. package/dist/command-contract.js +1 -26
  87. package/dist/commands/call.js +10 -24
  88. package/dist/commands/check.js +3 -4
  89. package/dist/commands/checklist.js +1 -1
  90. package/dist/commands/completion-candidates.js +2 -10
  91. package/dist/commands/config.js +2 -2
  92. package/dist/commands/core-readiness.js +12 -7
  93. package/dist/commands/debug.js +3 -3
  94. package/dist/commands/device-target.js +5 -5
  95. package/dist/commands/doctor.js +2 -14
  96. package/dist/commands/domain.js +2 -2
  97. package/dist/commands/ensure-ready.js +1 -7
  98. package/dist/commands/fixtures.js +5 -6
  99. package/dist/commands/help.js +3 -4
  100. package/dist/commands/launch/device.js +10 -17
  101. package/dist/commands/launch/extension.js +2 -8
  102. package/dist/commands/launch/mobile.js +1 -1
  103. package/dist/commands/list-executables.js +1 -3
  104. package/dist/commands/logs.js +3 -3
  105. package/dist/commands/manifest.js +1 -12
  106. package/dist/commands/mobile-device-view.js +4 -2
  107. package/dist/commands/overlay.js +110 -0
  108. package/dist/commands/pin.js +1 -1
  109. package/dist/commands/pr-body.js +2 -2
  110. package/dist/commands/prepare.js +4 -9
  111. package/dist/commands/provision.js +3 -12
  112. package/dist/commands/recipe-advice.js +1 -2
  113. package/dist/commands/recipe-quality.js +2 -3
  114. package/dist/commands/resolve-extension.js +1 -7
  115. package/dist/commands/review.js +4 -4
  116. package/dist/commands/run-engine.js +12 -51
  117. package/dist/commands/run.js +4 -25
  118. package/dist/commands/runtime-decision.js +1 -6
  119. package/dist/commands/runtime-health.js +1 -7
  120. package/dist/commands/runtime-launch.js +1 -13
  121. package/dist/commands/self-test.js +2 -6
  122. package/dist/commands/status-watch.js +1 -2
  123. package/dist/commands/status.js +2 -5
  124. package/dist/commands/task-init.js +2 -3
  125. package/dist/commands/tutorial.js +2 -2
  126. package/dist/commands/update.js +1 -1
  127. package/dist/completions-cache.js +1 -1
  128. package/dist/console-classifier.js +19 -0
  129. package/dist/doctor.js +2 -3
  130. package/dist/funding-execution-context.js +15 -1
  131. package/dist/host.js +32 -0
  132. package/dist/index.js +1 -6
  133. package/dist/live-tsx.js +49 -0
  134. package/dist/mm-harness-cli.js +2 -4
  135. package/dist/network-observation.js +4 -4
  136. package/dist/paths.js +34 -64
  137. package/dist/performance-observation.js +2 -2
  138. package/dist/progress.js +1 -1
  139. package/dist/recipe-assistance-context.js +1 -3
  140. package/dist/recipe-security.js +2 -59
  141. package/dist/review/knowledge.js +2 -2
  142. package/dist/runner.js +5 -3
  143. package/dist/runtime-context.js +1 -1
  144. package/dist/task-view.js +2 -3
  145. package/docs/CONTRIBUTING.md +1 -1
  146. package/docs/cli-contract.md +2 -2
  147. package/docs/feature-guard.md +90 -0
  148. package/library/README.md +3 -1
  149. package/library/actions/core/perps/_controller.mjs +153 -130
  150. package/library/actions/core/perps/quote_fees.mjs +58 -0
  151. package/library/actions/core/wallet/_fixture-accounts.mjs +69 -0
  152. package/library/actions/core/wallet/list_accounts.mjs +14 -57
  153. package/library/actions/extension/feature_flags/state.mjs +1 -1
  154. package/library/actions/extension/platform/cdp.mjs +2 -8
  155. package/library/actions/harness-exports.mjs +1 -6
  156. package/library/actions/mobile/app/network-control.mjs +6 -8
  157. package/library/actions/mobile/perps/prepare_local_snapshot_endpoint.mjs +1 -1
  158. package/library/actions/mobile/platform/bridge.mjs +8 -8
  159. package/library/actions/mobile/platform/native-session-name.mjs +21 -1
  160. package/library/actions/mobile/platform/native-session.mjs +3 -4
  161. package/library/actions/mobile/platform/observe-ui.mjs +12 -6
  162. package/library/actions/mobile/ui/native-navigation.mjs +4 -3
  163. package/library/actions/mobile/wallet/native-ui.mjs +3 -2
  164. package/library/actions/shared/analytics/_adapter.mjs +1 -1
  165. package/library/actions/shared/console-findings.mjs +1 -1
  166. package/library/actions/terminal/platform/runtime.mjs +2 -1
  167. package/library/manifests/core.action-manifest.json +83 -13
  168. package/package.json +8 -9
  169. package/adapters/extension/lib/slot-title.cjs +0 -292
  170. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +0 -472
  171. package/adapters/mobile/bridge-runtime/lib/bridge-errors.cjs +0 -125
  172. package/adapters/mobile/bridge-runtime/lib/cdp-broker.cjs +0 -1055
  173. package/adapters/mobile/bridge-runtime/lib/cdp-eval.cjs +0 -110
  174. package/adapters/mobile/bridge-runtime/lib/config.cjs +0 -61
  175. package/adapters/mobile/bridge-runtime/lib/console-format.cjs +0 -53
  176. package/adapters/mobile/bridge-runtime/lib/devtools-proxy.cjs +0 -177
  177. package/adapters/mobile/bridge-runtime/lib/issue-capture.cjs +0 -446
  178. package/adapters/mobile/bridge-runtime/lib/match-bridge-target.cjs +0 -84
  179. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +0 -403
  180. package/adapters/mobile/bridge-runtime/lib/ws-client.cjs +0 -116
  181. package/adapters/mobile/coalesce-metro-log.cjs +0 -24
  182. package/adapters/mobile/launch-metro.cjs +0 -77
  183. package/adapters/mobile/metro-config.cjs +0 -98
  184. package/adapters/mobile/metro-log-generation.cjs +0 -106
  185. package/dist/adapters/mobile/frame-metrics.js +0 -118
  186. package/dist/adapters/mobile/video-recorder.js +0 -289
  187. package/dist/artifact-files.js +0 -92
  188. package/dist/checkout-lock.js +0 -149
  189. package/dist/cli-color.js +0 -98
  190. package/dist/command-journal.js +0 -308
  191. package/dist/commands/last.js +0 -60
  192. package/dist/commands/launch/index.js +0 -109
  193. package/dist/commands/parse-args.js +0 -279
  194. package/dist/commands/reload.js +0 -41
  195. package/dist/commands/run-report.js +0 -241
  196. package/dist/commands/shared.js +0 -323
  197. package/dist/commands/stop.js +0 -60
  198. package/dist/devices.js +0 -100
  199. package/dist/execution-provenance.js +0 -332
  200. package/dist/harness.js +0 -451
  201. package/dist/heal-bounds.js +0 -137
  202. package/dist/json-stream.js +0 -60
  203. package/dist/leaf-invoke.js +0 -19
  204. package/dist/library-provenance.js +0 -33
  205. package/dist/live-adapter-contract.js +0 -380
  206. package/dist/recording-target.js +0 -120
  207. package/dist/run-diagnostics.js +0 -364
  208. package/dist/run-recording.js +0 -481
  209. package/dist/runtime-proof.js +0 -90
  210. package/library/actions/mobile/platform/tool-paths.mjs +0 -123
package/dist/task-view.js CHANGED
@@ -22,9 +22,8 @@ import {
22
22
  subtaskPaths,
23
23
  WORKER_SIGNAL_FILE
24
24
  } from "@farmslot/protocol";
25
- import { readCommandJournal } from "./command-journal.js";
26
- import { resolveRuntimeContextPath } from "./harness.js";
27
- import { PREPARE_PROGRESS_ARTIFACT, recipeRuntimeDir } from "./paths.js";
25
+ import { readCommandJournal, recipeRuntimeDir, resolveRuntimeContextPath } from "@farmslot/recipe-cli/harness";
26
+ import { PREPARE_PROGRESS_ARTIFACT } from "./paths.js";
28
27
  import { parseLibraryPathEnv } from "./review/knowledge.js";
29
28
  const CHECKLIST_LABEL_LIMIT = 70;
30
29
  const TASK_SILENT_AFTER_MS = 10 * 60 * 1e3;
@@ -180,7 +180,7 @@ test. Update `CHANGELOG.md` under `Unreleased` for user-visible behavior.
180
180
 
181
181
  When upgrading the protocol or runtime, align their transitive consumers before
182
182
  pinning them here. Farmslot's `workspace:*` dependencies become exact versions
183
- when packed, so `agent-runtime` and `expo-recipe` can retain older copies until
183
+ when packed, so `agent-runtime` and `adapter-rn` can retain older copies until
184
184
  republished. Verify the complete install with
185
185
  `bash tests/contract/packaging-dist-install.test.sh` before releasing the harness.
186
186
 
@@ -109,11 +109,11 @@ Every family checks its own golden directory: a golden no case writes fails the
109
109
  Each case runs under a safety cap, `GOLDENS_CASE_TIMEOUT` (default 600 s). A case that hits it reports `TIMEOUT <case>` and fails without being compared or written, so `--update` can never accept a truncated capture.
110
110
 
111
111
  Each family is a normal contract test (`tests/contract/goldens-<family>.test.sh`). `gd_init` (`tests/contract/goldens/lib.sh`) builds a sandbox on `ct_init`, and every case runs under `env -i`:
112
- - `gd_init` first unsets every inherited `MM_HARNESS_*`, `RECIPE_*`, `METAMASK_*` and `FARMSLOT_*` variable, plus device and port pins (`IOS_SIMULATOR`, `ADB_SERIAL`, `WATCHER_PORT`, `METRO_PORT`, `CDP_PORT`, `IDB_PATH`, `ANDROID_HOME`, …). A developer's `MM_HARNESS_BIN` or `MM_HARNESS_IDB_PATH` export therefore cannot redirect a case;
112
+ - `gd_init` first unsets every inherited `MM_HARNESS_*`, `RECIPE_*`, `METAMASK_*` and `FARMSLOT_*` variable, plus device and port pins (`IOS_SIMULATOR`, `ADB_SERIAL`, `WATCHER_PORT`, `METRO_PORT`, `CDP_PORT`, `IDB_PATH`, `ANDROID_HOME`, …). A developer's `MM_HARNESS_BIN` or `RECIPE_RN_IDB_PATH` export therefore cannot redirect a case;
113
113
  - the environment is an allowlist: sandbox `HOME`, caches, config, `FARMSLOT_HOME`, `TZ=UTC`, no update probe, and only the variables the family sets itself;
114
114
  - every interpreter and tool the goldens launch is resolved to its real binary before `HOME` moves into the sandbox: `node` (`process.execPath`), `git` (`git --exec-path`), `bash` (`$BASH`) and `timeout`. The goldens need no Python. A tool that resolves to a script, the shape of an asdf/mise/volta/pyenv shim, fails the family up front with a message naming it. Each is checked to run with a sandboxed `HOME`;
115
115
  - `PATH` is pinned to the case stubs, the sandbox stubs, those real binaries, and `/usr/bin:/bin:/usr/sbin:/sbin`. Host tools (`idb`, `capture-helper`, a developer's Chrome) cannot change a golden;
116
- - device-tool discovery is pinned: `MM_HARNESS_IDB_PATH` and `MM_HARNESS_ADB_PATH` point at sandbox stubs, which discovery checks before any absolute Homebrew or pipx path;
116
+ - device-tool discovery is pinned: `RECIPE_RN_IDB_PATH` and `RECIPE_RN_ADB_PATH` point at sandbox stubs, which discovery checks before any absolute Homebrew or pipx path;
117
117
  - localhost probes cannot reach a real server. `gd_reserve_ports` runs a Node port holder (`tests/contract/goldens/port-holder.mjs`) that holds ephemeral ports on `127.0.0.1` and `::1` and resets every connection it accepts, so Metro `/status` and CDP probes never get a response and nothing real can take the port mid-run. Each family exports the farm's slot port variables (`RECIPE_CDP_PORT`, `WATCHER_PORT`, `METRO_PORT`) set to those ports, so commands without an explicit port use them too. Goldens show them as `<PORT:NAME>`, rewritten only in port positions (`*port` keys, `:<port>`, `port <n>`/`PORT=<n>`, or a string that is exactly the port), so an unrelated equal number such as a 60000 ms timeout is never rewritten;
118
118
  - `tmux`, `xcrun` and `adb` are stubbed (`ct_init`, plus one booted simulator and one Android device for Mobile);
119
119
  - forbidden stubs record any call to `open`, `osascript`, `lsappinfo`, `screencapture`, `yarn`, `npx`, `npm`, `corepack`, `gh` and Chrome/Chromium, and the family fails if one is called. A browser stub answers only `--version`, and `npm` only `root -g` (install-source detection), with a sandbox path;
@@ -0,0 +1,90 @@
1
+ # Feature-loss guard
2
+
3
+ While the platform phases move mm-harness code into Farmslot packages, nothing the frozen 0.76.0 release could do may get lost. Unit tests don't prove that. The guard has three parts, all run against real builds:
4
+
5
+ | Part | Script | Proves | When |
6
+ |---|---|---|---|
7
+ | Inventory | `scripts/feature-inventory.mjs --check` | every 0.76.0 capability still exists, or is mapped in `tests/feature-inventory/changes.json` | every PR review, every release |
8
+ | Live differential | `scripts/differential.mjs` | the same commands on the same real slots behave the same on 0.76.0 and on the PR build | every migration PR (report attached to the PR) |
9
+ | Release smoke | `scripts/release-smoke.mjs` | the fixed real recipes pass on the canary Mac, recorded, and match the previous release | every release |
10
+
11
+ The baseline is `@deeeed/metamask-harness@0.76.0` (da656404, before phase 2). The repo declares no dependency on it: `scripts/feature-guard/builds.mjs` installs it on first use, on each host, into `~/.cache/mm-harness-baseline/0.76.0` with npm's `--before` set just after 0.76.0's publish time, so every host resolves the dependency tree that release shipped with (the differential report prints each host's tree digest); it is never upgraded. The candidate is `npm pack` of the checkout, installed in `~/.cache/mm-harness-builds/<sha>`, so both sides are installed packages in sibling directories. Commands run as argv arrays, locally without a shell and remotely through one fixed ssh launcher with quoted arguments.
12
+
13
+ ## 1. Inventory
14
+
15
+ ```bash
16
+ node scripts/feature-inventory.mjs --check # this checkout, packed and installed, vs tests/feature-inventory/baseline-0.76.0.json
17
+ node scripts/feature-inventory.mjs --check --bin <installed mm-harness> # a published build
18
+ ```
19
+
20
+ The inventory asks the build itself, so it stays valid while code moves between packages:
21
+
22
+ - **CLI**: a public command refuses `<cmd> --feature-inventory-probe` as an unknown option, a removed one is refused as an unknown command. A flag exists when `<cmd> <flags…> --feature-inventory-canary --help` is refused only at the trailing canary (a lost flag is refused first; the canary proves the validator walked every flag before it, and `--help` keeps an accepted probe from running anything). An option value exists when `<cmd> <flag>=<value> --feature-inventory-canary --help` is refused only at the canary. Positional choices come from the "Valid values" an invalid positional is refused with. Candidates to probe are the union of the build's own command contract and the baseline's grammar, so moving `command-contract.ts` cannot hide a lost flag.
23
+ - **Oracle check**: every probe reads presence from a refusal, so the run first proves the refusals are seen (a bogus command, flag and option value must each be refused, `--help` must succeed), and any probe result that is neither an acceptance nor a known refusal stops the run. A build that does not start, or a CLI whose wording changed (the contract moving into `@farmslot/recipe-cli`), fails loudly; update the patterns in `feature-inventory.mjs` in that PR.
24
+ - **Hidden commands** (`self-test`, `ensure-ready`, `runtime-*`, `resolve-extension`, `completion-candidates`, `completions`) bypass the preflight validator. They are probed with a flag that has no value, which their own parsers reject before doing any work; a different result stops the run. If that parse ever changed, the probe would reach the command, inside the sandbox.
25
+ - **Per adapter** (the `--adapter` choices): action ids, their schema fields and enum values, and observers (`actions --raw --json`); call short names (`call --list --json`); bundled recipes and their parameters (`run --list --json`); doctor check ids (`doctor --json`) against an empty target, plus a real checkout per `--checkout <adapter>=<dir>`.
26
+ - **Environment variables**: a source scan of the package and every `@farmslot` package it depends on (a direct JS read, a name passed to a helper that reads env by parameter or listed next to an `env[<variable>]` access, or a shell expansion the script never assigns), the env inputs `adapters/manifest.json` documents, and every unset variable a Node process looked up while the probes ran (including a read-only `help review` per adapter) (`scripts/feature-guard/env-trace.mjs`, which sees names built at runtime). This is a floor: a name that is still present somewhere does not prove the same code path reads it; the differential covers behaviour.
27
+ - **Package surface**: bins, exports, the package entry's export names, `files` entries that exist in the package, `adapters/manifest.json` features, every shipped script and module path under `adapters/` and `library/`, and the export subpaths and bins of the `@farmslot` dependencies (where moved code lands).
28
+
29
+ Probes run in `scripts/feature-guard/sandbox.mjs`: a sandbox HOME and config, no inherited recipe libraries, inert device and process listings, and stubs that fail the inventory if a probe reaches a browser, launcher, package manager or `gh`.
30
+
31
+ `--check` fails on a baseline key that is missing from the build unless `changes.json` covers it. Doctor contexts the run did not record (no `--checkout`) are reported as skipped. The baseline file is regenerated only when the probe logic changes, and always from the frozen 0.76.0 bin:
32
+
33
+ ```bash
34
+ node scripts/feature-inventory.mjs --bin ~/.cache/mm-harness-baseline/0.76.0/node_modules/.bin/mm-harness \
35
+ --out tests/feature-inventory/baseline-0.76.0.json
36
+ ```
37
+
38
+ ### `changes.json`: consolidation is welcome
39
+
40
+ The guard protects capabilities, not names. Merging or simplifying actions, commands or flags is fine when the capability survives. Each entry has an `id`, a `reason` and a `pr`:
41
+
42
+ - `consolidated`: `from` (old keys, `*` allowed) → `to` (keys that must exist in the build), the `argumentMapping`, and a `differential` the differential's consolidated lane runs:
43
+ - `{ "kind": "cli", "where": "sandbox" | "slot", "adapter": …, "old": { "argv", "env" }, "new": { "argv", "env" } }`: the old invocation on 0.76.0 vs the mapped one on the PR build; equivalent normalized outcome passes.
44
+ - `{ "kind": "env", …same…, "names": [[old, new], …] }`: as `cli`, and the baseline must read each old variable and the build the new one (env trace; leave them unset).
45
+ - `{ "kind": "module", "old": [paths or specifiers], "new": [specifiers], "renamed": { old: new }, "names": [old names] }`: each new home exports every name the old module did (or, with `names`, the listed names when only part of the old module moved).
46
+ - `{ "kind": "script", "old": { "script", "args" }, "new": { "script", "args" } }`: a moved CLI script, run on both sides.
47
+ - `dropped`: `keys` proven dead, with `evidence` (no caller in farms, skills, recipe libraries, docs).
48
+
49
+ No shims: the PR that consolidates renames every caller (farm hooks, skills, recipes, docs) in the same change.
50
+
51
+ ## 2. Live differential
52
+
53
+ ```bash
54
+ node scripts/differential.mjs \
55
+ --slot core=~/dev/metamask/core-6 \
56
+ --slot terminal=~/dev/metamask/va-mmcx-terminal-1 --slot-var terminal:cdpPort=9541 --slot-var terminal:appPort=9341 \
57
+ --slot-env terminal:TERMINAL_EXTENSION_CHECKOUT=<extension checkout with a dist> \
58
+ --slot-env terminal:RECIPE_WALLET_FIXTURE=<farm wallet fixture> \
59
+ --slot-env terminal:RECIPE_LIBRARY_PATH=perps=<dir>:terminal=<dir> \
60
+ --slot extension=macpro.local:/Users/deeeed/dev/metamask/metamask-extension-1 \
61
+ --slot-var extension:cdpPort=7661 --slot-var extension:watcherPort=9011 --slot-var extension:slot=macpro-mme-1 \
62
+ --slot mobile=macpro.local:/Users/deeeed/dev/metamask/metamask-mobile-5 \
63
+ --slot-var mobile:watcherPort=8065 --slot-var mobile:device=mm-5
64
+ ```
65
+
66
+ Lanes (`--lanes`, default all):
67
+
68
+ - `help`: `--help`, `--version` and `<cmd> --help` for every baseline command, sandboxed.
69
+ - `slots`: per `--slot`, discovery (`actions --raw`, `call --list`, `run --list`, `doctor --json`) and the adapter's cases in `tests/differential/matrix.json`: the farm hook argv the goldens freeze, run on the real slot, plus real runs and calls (`live`), including a probe that requires the live transport (`cdp.target`), not only checkout metadata. The prelude gives both sides the same start; the whole baseline sequence runs before the candidate's. A slot on another host runs over ssh, with both builds installed there.
70
+ - `consolidated`: the `changes.json` differentials.
71
+ - `goldens`: the contract golden families run against each build from a copy of `tests/`, compared golden by golden.
72
+
73
+ Captures are normalized (paths, versions, pids, timestamps, durations, epoch numbers, encrypted vaults) and compared per facet: exit code, stdout (JSON paths or text), stderr when stdout is empty, artifact files and tree. `volatile` paths in the matrix (product console warnings that arrive on the product's own timing) are masked but their keys still compared. Every remaining difference must match an entry in `tests/differential/intended.json` (`case` glob, optional `facet`, `paths` or `text`, `reason`, `pr`). The report lands in `evidence/differential/<sha>/report.md`, with `results.json` and every raw capture beside it; attach the report to the PR. Verdicts: PASS (exit 0), FAIL (exit 1: an unexplained difference or a lane error), INCOMPLETE (exit 3: a case was skipped, e.g. a consolidated proof without its `--slot`).
74
+
75
+ What the normaliser deliberately masks, and so cannot catch: any semver under a key ending in `version` other than the harness's own, numbers under keys ending in `Ms`/`Seconds`/`duration`/`elapsed` (so a changed timeout value), epoch-millisecond numbers, and the `volatile` paths (a new product warning during a live run). Each varies run to run on real slots; the goldens freeze the static values.
76
+
77
+ Machine rules: on macwork, headless only and no focus stealing (`TERMINAL_HEADLESS=1` is in the matrix). Headful runs and the Extension wallet popup fail while macwork's screen is locked (`Page.captureScreenshot` timeout). Mobile and the Extension run on free macpro or mini slots, never mini-mm-3. The differential stops and cleans the slot runtime it used; reinstall the farm's overlay afterwards if the slot is handed back to the farm.
78
+
79
+ ## 3. Release smoke
80
+
81
+ ```bash
82
+ node scripts/release-smoke.mjs --version 0.79.0 <the same --slot flags> # a published release -> ~/dev/metamask/mm-harness-smoke/0.79.0
83
+ node scripts/release-smoke.mjs <the same --slot flags> # this checkout, packed -> .../0.79.0+<package digest>, compared with 0.79.0
84
+ ```
85
+
86
+ `tests/release-smoke/*.json` is the fixed set: Mobile Perps smoke, Extension Perps smoke, Terminal wallet connect (`terminal.perps.ensure-session`), and a core `call`. All are read-only; the canary slots must hold the farm's testnet wallet fixture (the Terminal enforces testnet itself). Each runs its prelude (install or fixture sync, launch, wallet fixture), the recipe with `--record-video=full-run` (the Terminal adapter has no video; its trace is the record), then its after steps (stop). Runs are kept per build on the host in `~/dev/metamask/mm-harness-smoke/<version>` (published) , `<version>+<package digest>` (a packed checkout; commits that only touch repo tooling are the same build) or `<version>+local` (`--bin`), with the full artifact directory (summary, trace, video) per smoke; an `--only` run updates its smokes and keeps the others recorded for the same packed or published build (a `--bin` build keeps nothing: it can change in place). The run compares with the published run at the highest version not above its own. Verdicts: FAIL (exit 1) on a non-pass status, a missing trace or expected video, or a regression (status, or the node/action sequence of a passing run); INCOMPLETE (exit 3) when a smoke has not run for this build or has nothing to compare with (`--no-compare` marks the first recorded run).
87
+
88
+ ## CI
89
+
90
+ Not wired yet. The inventory `--check` needs no slot and can run in CI on the packed build; the differential and the smoke set need real slots and stay in the review and release flow. The workflow change is proposed separately.
package/library/README.md CHANGED
@@ -10,7 +10,9 @@
10
10
  That is the engine contract (the recipe engine spawns/imports them with the
11
11
  input env set) — a bare `import` outside the engine throws
12
12
  "Missing live adapter input path", which is expected, not a bug.
13
- - Actions load harness helpers ONLY through `actions/harness-exports.mjs` (the
13
+ - Actions import the generic harness helpers (runtime paths, recording
14
+ snapshots) directly from `@farmslot/recipe-cli/harness`, and load the
15
+ MetaMask helpers ONLY through `actions/harness-exports.mjs` (the
14
16
  dist-preferring bridge) — never deep-import `src/` (published installs have no
15
17
  src/, and the entrypoint would close an import cycle through adapters.ts).
16
18
  - `recipes/` — runnable recipes (`mm-harness run <name-or-file>`).
@@ -5,9 +5,8 @@ import { readFile, writeFile } from 'node:fs/promises';
5
5
  import path from 'node:path';
6
6
  import { pathToFileURL } from 'node:url';
7
7
 
8
- import { mnemonicToAccount, privateKeyToAccount } from 'viem/accounts';
9
-
10
8
  import { walletFixturePath } from '../../harness-exports.mjs';
9
+ import { readFixtureAccounts } from '../wallet/_fixture-accounts.mjs';
11
10
 
12
11
  function controllerEntry(projectRoot) {
13
12
  return pathToFileURL(path.join(projectRoot, 'packages/perps-controller/src/index.ts')).href;
@@ -27,7 +26,7 @@ function messengerEntry(projectRoot) {
27
26
  // a persistent auto-reconnecting HyperLiquid WebSocket (HyperLiquidClientService
28
27
  // .initialize → wsTransport.ready()); these logs proved the order returns over
29
28
  // HTTP while that open socket kept the event loop alive — see disconnectAndExit.
30
- function buildInfrastructure(stubbed, input) {
29
+ function buildInfrastructure(stubbed, input, rewardsSource) {
31
30
  const noop = () => undefined;
32
31
  const terminalApi = resolveTerminalApi(input);
33
32
  return {
@@ -69,153 +68,169 @@ function buildInfrastructure(stubbed, input) {
69
68
  removeItem: async () => undefined,
70
69
  },
71
70
  ...(terminalApi ? { terminalApi } : {}),
72
- rewards: { getPerpsDiscountForAccount: async () => null },
71
+ rewards: {
72
+ getPerpsDiscountForAccount: (caipAccountId, baseFeeBips) =>
73
+ rewardsDiscount(rewardsSource.messenger, caipAccountId, baseFeeBips),
74
+ },
73
75
  };
74
76
  }
75
77
 
76
- // --- Account resolution from wallet-fixture.json ---
78
+ // --- Rewards ---
77
79
  //
78
- // The core adapter resolves the signing account from the same wallet-fixture.json
79
- // that mobile/extension adapters use (recipeRuntimePath/wallet-fixture.json).
80
- // The fixture has the standard { accounts: [{ type, value, name }] } shape from
81
- // wallet-fixture.json.sample. The recipe node selects an account by name via
82
- // `account_name` (default "dev1"). The viem account derived from the fixture
83
- // entry is the authoritative source for both the address and signing key.
80
+ // The clients answer the rewards dependency through the messenger's
81
+ // RewardsController:getPerpsDiscountForAccount (extension perps-controller-init,
82
+ // mobile mobileInfrastructure). Core has no RewardsController, so the handler
83
+ // exists when the node supplies rewards_discount_bips, or when a driver
84
+ // registers one on rootMessenger. Without a handler the answer is null: the
85
+ // controller's "no discount known yet".
86
+ //
87
+ // The controller asks for the discount with the account's CAIP id, built from
88
+ // the wallet's selected network (NetworkController:getState and
89
+ // getNetworkClientById). A supplied discount therefore comes with the network a
90
+ // fresh client wallet starts on, Ethereum mainnet; without one the headless
91
+ // controller keeps having no NetworkController, as before.
84
92
 
85
- /**
86
- * Load wallet-fixture.json and return the named account entry.
87
- * Returns null if the fixture file does not exist.
88
- *
89
- * @param projectRoot - Absolute path to the project root.
90
- * @param accountName - The `name` field to match in fixture.accounts.
91
- */
92
- async function loadFixtureAccount(projectRoot, accountName) {
93
- const fixturePath = walletFixturePath(projectRoot);
94
- let raw;
93
+ const REWARDS_DISCOUNT_ACTION = 'RewardsController:getPerpsDiscountForAccount';
94
+
95
+ async function rewardsDiscount(rootMessenger, caipAccountId, baseFeeBips) {
96
+ if (!rootMessenger) return null;
95
97
  try {
96
- raw = await readFile(fixturePath, 'utf8');
98
+ return await rootMessenger.call(REWARDS_DISCOUNT_ACTION, caipAccountId, baseFeeBips);
97
99
  } catch (error) {
98
- if (error?.code === 'ENOENT') return null;
100
+ if (String(error?.message ?? '').includes(`${REWARDS_DISCOUNT_ACTION} has not been registered`)) return null;
99
101
  throw error;
100
102
  }
101
- const fixture = JSON.parse(raw);
102
- if (!Array.isArray(fixture.accounts) || fixture.accounts.length === 0) {
103
- throw new Error(`wallet-fixture.json at ${fixturePath} has no accounts array.`);
104
- }
105
- const entry = fixture.accounts.find((a) => a?.name === accountName);
106
- if (!entry) {
107
- const names = fixture.accounts.map((a) => a?.name).filter(Boolean).join(', ');
108
- throw new Error(
109
- `wallet-fixture.json has no account named "${accountName}". Available: ${names}.`,
110
- );
111
- }
112
- if (typeof entry.value !== 'string' || entry.value.trim().length === 0) {
113
- throw new Error(`wallet-fixture.json account "${accountName}" has no value.`);
114
- }
115
- if (entry.type !== 'mnemonic' && entry.type !== 'privateKey') {
116
- throw new Error(
117
- `wallet-fixture.json account "${accountName}" type must be mnemonic or privateKey, got "${entry.type}".`,
118
- );
119
- }
120
- return entry;
121
103
  }
122
104
 
123
105
  /**
124
- * Derive a viem account from a wallet-fixture account entry.
125
- * Mnemonics use BIP-44 account index 0 (MetaMask default derivation).
126
- * Private keys are accepted with or without a 0x prefix.
106
+ * The rewards discount a node supplies, in basis points (0-10000), or undefined.
107
+ *
108
+ * @param input - Adapter input (node.rewards_discount_bips).
127
109
  */
128
- function viemAccountFromFixtureEntry(entry) {
129
- if (entry.type === 'mnemonic') {
130
- return mnemonicToAccount(entry.value.trim(), { addressIndex: 0 });
131
- }
132
- // privateKey
133
- const raw = entry.value.trim();
134
- const normalized = raw.startsWith('0x') ? raw : `0x${raw}`;
135
- if (!/^0x[0-9a-fA-F]{64}$/u.test(normalized)) {
136
- throw new Error(
137
- `wallet-fixture.json privateKey account "${entry.name}" is not a 32-byte hex key.`,
138
- );
110
+ export function requestedRewardsDiscountBips(input) {
111
+ const raw = optionalParam(input.node, 'rewards_discount_bips', 'rewardsDiscountBips');
112
+ if (raw === undefined) return undefined;
113
+ const bips = Number(raw);
114
+ if (!Number.isInteger(bips) || bips < 0 || bips > 10000) {
115
+ throw new Error(`rewards_discount_bips must be an integer from 0 through 10000; got ${raw}.`);
139
116
  }
140
- return privateKeyToAccount(normalized);
117
+ return bips;
141
118
  }
142
119
 
143
- /**
144
- * Resolve the account name to use for signing.
145
- * Precedence: node.account_name → node.account (if not an address) → "dev1".
146
- */
147
- function resolveAccountName(input) {
148
- const explicit = input.node?.account_name;
149
- if (typeof explicit === 'string' && explicit.trim().length > 0) return explicit.trim();
150
- // node.account can be either a name ("dev1") or an address ("0x...").
151
- // If it looks like an address, ignore it here — the address will be derived
152
- // from the fixture signer instead.
153
- const nodeAccount = input.node?.account;
154
- if (
155
- typeof nodeAccount === 'string' &&
156
- nodeAccount.trim().length > 0 &&
157
- !/^0x[0-9a-fA-F]{40}$/u.test(nodeAccount.trim())
158
- ) {
159
- return nodeAccount.trim();
160
- }
161
- return 'dev1';
120
+ const REWARDS_NETWORK_ACTIONS = ['NetworkController:getState', 'NetworkController:getNetworkClientById'];
121
+
122
+ function registerRewardsHandler(rootMessenger, childMessenger, discountBips) {
123
+ if (discountBips === undefined) return;
124
+ rootMessenger.registerActionHandler(REWARDS_DISCOUNT_ACTION, async () => discountBips);
125
+ rootMessenger.registerActionHandler('NetworkController:getState', () => ({
126
+ selectedNetworkClientId: 'mainnet',
127
+ }));
128
+ rootMessenger.registerActionHandler('NetworkController:getNetworkClientById', (networkClientId) => {
129
+ if (networkClientId !== 'mainnet') throw new Error(`No network client with id ${networkClientId}.`);
130
+ return { configuration: { chainId: '0x1' } };
131
+ });
132
+ rootMessenger.delegate({ actions: REWARDS_NETWORK_ACTIONS, messenger: childMessenger });
133
+ }
134
+
135
+ // --- Account selection from wallet-fixture.json ---
136
+ //
137
+ // The core adapter reads the same wallet-fixture.json the mobile/extension
138
+ // adapters use (recipeRuntimePath/wallet-fixture.json), expanded the way
139
+ // list_accounts shows it (dev1..devN from a mnemonic with count N). A node
140
+ // selects an account by name (`account_name`, or a non-address `account`) or by
141
+ // 0x address (`account`, `address`, `userAddress`); dev1 when it names none.
142
+ // An unknown name is refused, and an address is never swapped for another
143
+ // account: one outside the fixture is read as given, and cannot sign.
144
+
145
+ const ADDRESS_PATTERN = /^0x[0-9a-fA-F]{40}$/u;
146
+
147
+ function trimmedString(value) {
148
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
162
149
  }
163
150
 
164
151
  /**
165
- * Resolve the viem signer and EVM address for writes.
166
- * The wallet fixture account selected by name is the only signing source.
152
+ * Resolve the account a node selects.
167
153
  *
168
- * @param input - Adapter input (context.projectRoot, node.account_name).
169
- * @returns { account: ViemAccount, address: string }
154
+ * @param input - Adapter input (context.projectRoot, node account fields).
155
+ * @returns { name, address, signer } — name and signer are null for an address outside the fixture.
170
156
  */
171
- async function resolveSignerFromFixture(input) {
157
+ export async function selectAccount(input) {
158
+ const node = input.node ?? {};
172
159
  const projectRoot = input.context?.projectRoot;
173
- const accountName = resolveAccountName(input);
160
+ const accountField = trimmedString(node.account);
161
+ const names = [trimmedString(node.account_name), accountField && !ADDRESS_PATTERN.test(accountField) ? accountField : undefined]
162
+ .filter((value) => value !== undefined);
163
+ const addresses = [node.account, node.address, node.userAddress]
164
+ .map(trimmedString)
165
+ .filter((value) => value !== undefined && ADDRESS_PATTERN.test(value));
166
+ if (new Set(names).size > 1) {
167
+ throw new Error(`core perps account selectors disagree: ${[...new Set(names)].join(' vs ')}; select one account.`);
168
+ }
169
+ if (new Set(addresses.map((value) => value.toLowerCase())).size > 1) {
170
+ throw new Error(`core perps account selectors disagree: ${addresses.join(' vs ')}; select one account.`);
171
+ }
172
+ const [name] = names;
173
+ const [address] = addresses;
174
+ const loaded = projectRoot ? await readFixtureAccounts(projectRoot) : null;
174
175
 
175
- if (projectRoot) {
176
- const entry = await loadFixtureAccount(projectRoot, accountName);
177
- if (entry) {
178
- const account = viemAccountFromFixtureEntry(entry);
179
- return { account, address: account.address };
180
- }
176
+ if (!loaded) {
177
+ if (!name && address) return { name: null, address, signer: null };
178
+ throw new Error(
179
+ `core perps account "${name ?? 'dev1'}" needs a wallet fixture at ` +
180
+ `${projectRoot ? walletFixturePath(projectRoot) : 'the checkout'}; ` +
181
+ 'a read-only action can name a 0x address via account= instead.\n' +
182
+ 'Next: run mm-harness fixtures set',
183
+ );
181
184
  }
182
185
 
183
- throw new Error(
184
- `core perps writes require wallet-fixture.json account "${accountName}". ` +
185
- `Next: run mm-harness fixtures set`,
186
- );
186
+ const available = loaded.accounts.map((account) => account.name).join(', ');
187
+ const byName = (wanted) => loaded.accounts.find((account) => account.name === wanted);
188
+ if (name) {
189
+ const matches = loaded.accounts.filter((account) => account.name === name);
190
+ if (matches.length > 1) {
191
+ throw new Error(`wallet fixture has ${matches.length} accounts named "${name}"; rename one in ${loaded.fixturePath}.`);
192
+ }
193
+ const [match] = matches;
194
+ if (!match) {
195
+ throw new Error(`wallet fixture has no account named "${name}". Available: ${available}.`);
196
+ }
197
+ if (address && match.address.toLowerCase() !== address.toLowerCase()) {
198
+ throw new Error(`fixture account "${name}" is ${match.address}, not ${address}; select one of them.`);
199
+ }
200
+ return match;
201
+ }
202
+ if (address) {
203
+ return loaded.accounts.find((account) => account.address.toLowerCase() === address.toLowerCase()) ??
204
+ { name: null, address, signer: null };
205
+ }
206
+ const fallback = byName('dev1');
207
+ if (!fallback) {
208
+ throw new Error(`wallet fixture has no account named "dev1" (the default). Available: ${available}; pass account=<name>.`);
209
+ }
210
+ return fallback;
187
211
  }
188
212
 
189
213
  /**
190
- * Resolve the account address for reads (no signing required).
191
- * Prefer the wallet fixture account; explicit node addresses support read-only calls.
214
+ * Resolve the viem signer and EVM address for writes: a fixture account,
215
+ * selected by name or by its address.
216
+ *
217
+ * @param input - Adapter input (context.projectRoot, node account fields).
218
+ * @returns { account: ViemAccount, address: string }
192
219
  */
193
- async function requireAccountAddress(input) {
194
- const projectRoot = input.context?.projectRoot;
195
- const accountName = resolveAccountName(input);
196
-
197
- if (projectRoot) {
198
- // No try/catch: loadFixtureAccount returns null when no fixture is present
199
- // (the only recoverable case) and THROWS on a malformed / missing-named /
200
- // empty / bad-type fixture. Those must fail loudly — swallowing them would
201
- // let a read silently run against a different address than the writes use.
202
- const entry = await loadFixtureAccount(projectRoot, accountName);
203
- if (entry) {
204
- const account = viemAccountFromFixtureEntry(entry);
205
- return account.address;
206
- }
207
- }
208
-
209
- // Read-only calls may name an address directly.
210
- const fromNode = input.node?.account ?? input.node?.address ?? input.node?.userAddress;
211
- const address = String(fromNode ?? '').trim();
212
- if (!/^0x[0-9a-fA-F]{40}$/u.test(address)) {
220
+ async function resolveSignerFromFixture(input) {
221
+ const selected = await selectAccount(input);
222
+ if (!selected.signer) {
213
223
  throw new Error(
214
- `core perps reads require a wallet-fixture.json with account "${accountName}", ` +
215
- `or a 0x EVM address via node.account.`,
224
+ `core perps writes sign with a wallet-fixture.json account; ${selected.address} is not one of them.\n` +
225
+ 'Next: pass account=<fixture account name>, or an address listed by metamask.wallet.list_accounts',
216
226
  );
217
227
  }
218
- return address;
228
+ return { account: selected.signer, address: selected.address };
229
+ }
230
+
231
+ /** The address a read acts on (no signing required). */
232
+ async function requireAccountAddress(input) {
233
+ return (await selectAccount(input)).address;
219
234
  }
220
235
 
221
236
  let cached = null;
@@ -275,13 +290,15 @@ export async function getCoreController(input) {
275
290
  const network = resolveNetwork(input);
276
291
  const terminalApi = resolveTerminalApi(input);
277
292
  const terminalApiKey = JSON.stringify(terminalApi ?? null);
293
+ const rewardsDiscountBips = requestedRewardsDiscountBips(input);
278
294
 
279
295
  if (
280
296
  cached &&
281
297
  cached.projectRoot === projectRoot &&
282
298
  cached.network === network &&
283
299
  cached.accountAddress.toLowerCase() === accountAddress.toLowerCase() &&
284
- cached.terminalApiKey === terminalApiKey
300
+ cached.terminalApiKey === terminalApiKey &&
301
+ cached.rewardsDiscountBips === rewardsDiscountBips
285
302
  ) {
286
303
  return { ...cached, accountAddress };
287
304
  }
@@ -307,7 +324,8 @@ export async function getCoreController(input) {
307
324
  );
308
325
 
309
326
  const stubbed = new Set();
310
- const infrastructure = buildInfrastructure(stubbed, input);
327
+ const rewardsSource = {};
328
+ const infrastructure = buildInfrastructure(stubbed, input, rewardsSource);
311
329
 
312
330
  // Root + child messenger pair, mirroring the real app wiring (and the core
313
331
  // repo's own test harness in
@@ -323,6 +341,8 @@ export async function getCoreController(input) {
323
341
  parent: rootMessenger,
324
342
  });
325
343
  registerSelectedAccountHandler(rootMessenger, messenger, accountAddress);
344
+ registerRewardsHandler(rootMessenger, messenger, rewardsDiscountBips);
345
+ rewardsSource.messenger = rootMessenger;
326
346
 
327
347
  // Default testnet. Mainnet only on explicit node.network: "mainnet" — mainnet
328
348
  // reads are safe; mainnet mutations use real funds (gated in getCoreControllerWithSigner).
@@ -365,6 +385,7 @@ export async function getCoreController(input) {
365
385
  network,
366
386
  accountAddress,
367
387
  terminalApiKey,
388
+ rewardsDiscountBips,
368
389
  };
369
390
  return { ...cached, accountAddress };
370
391
  }
@@ -450,6 +471,9 @@ function registerSignerHandlers(rootMessenger, childMessenger, account) {
450
471
  'KeyringController:signTypedMessage received no typed data to sign.',
451
472
  );
452
473
  }
474
+ if (msgParams?.from && String(msgParams.from).toLowerCase() !== account.address.toLowerCase()) {
475
+ throw new Error(`KeyringController:signTypedMessage asked to sign for ${msgParams.from}; the selected account is ${account.address}.`);
476
+ }
453
477
  const { domain, types, primaryType, message } = typedData;
454
478
  // Strip the EIP712Domain entry if present: viem derives the domain types
455
479
  // from `domain` itself and rejects a duplicate EIP712Domain in `types`.
@@ -486,16 +510,16 @@ function registerSignerHandlers(rootMessenger, childMessenger, account) {
486
510
  * @returns { controller, projectRoot, network, accountAddress, signerAddress }.
487
511
  */
488
512
  export async function getCoreControllerWithSigner(input) {
489
- const requestedName = input.node?.account_name ?? input.node?.account;
490
- if (requestedName !== undefined && (
491
- typeof requestedName !== 'string' || !requestedName.trim() ||
492
- /^0x[0-9a-fA-F]{40}$/u.test(requestedName.trim())
493
- )) {
513
+ // A selection given only as empty values must not quietly sign as dev1.
514
+ const selectors = ['account_name', 'account', 'address', 'userAddress'].filter((key) => input.node?.[key] != null);
515
+ if (selectors.length > 0 && selectors.every((key) => trimmedString(input.node[key]) === undefined)) {
494
516
  throw new Error(
495
- 'Core signing requires a non-empty fixture account name, not an address.\n' +
496
- 'Next: pass account=<reserved-fixture-name>; use addresses only for read-only actions.',
517
+ 'Core signing requires a non-empty fixture account name or address.\n' +
518
+ 'Next: pass account=<fixture account name>, or omit it to sign as dev1.',
497
519
  );
498
520
  }
521
+ // Refuse an account that cannot sign before any controller I/O.
522
+ const { account, address: signerAddress } = await resolveSignerFromFixture(input);
499
523
  const base = await getCoreController(input);
500
524
  const { controller, messenger: childMessenger, rootMessenger, accountAddress } = base;
501
525
  if (!rootMessenger || !childMessenger) {
@@ -517,7 +541,6 @@ export async function getCoreControllerWithSigner(input) {
517
541
  );
518
542
  }
519
543
 
520
- const { account, address: signerAddress } = await resolveSignerFromFixture(input);
521
544
  registerSignerHandlers(rootMessenger, childMessenger, account);
522
545
 
523
546
  // Bring up the active provider. placeOrder/closePosition call
@@ -741,7 +764,7 @@ export function redactOrder(order) {
741
764
  };
742
765
  }
743
766
 
744
- // --- Adapter IO (mirrors the live-adapter contract in src/live-adapter-contract.ts) ---
767
+ // --- Adapter IO (mirrors the live-adapter contract in @farmslot/recipe-cli/harness live-adapter-contract) ---
745
768
 
746
769
  async function loadInput() {
747
770
  const inputPath = process.argv[2] || process.env.METAMASK_RECIPE_ADAPTER_INPUT;
@@ -766,7 +789,7 @@ const fmtError = (e) => e?.stack ?? e?.message ?? String(e);
766
789
  * wsTransport.ready(). Orders themselves go over the HTTP ExchangeClient and
767
790
  * RETURN normally, but that open WebSocket keeps the Node event loop alive, so
768
791
  * the adapter process never exits. The runner only resolves on the child's
769
- * `close` event (src/live-adapter-contract.ts runProcess), so it waits out
792
+ * `close` event (@farmslot/recipe-cli/harness live-adapter-contract runProcess), so it waits out
770
793
  * live_adapter_timeout_ms, SIGTERMs the child, and reports `fail` even though
771
794
  * the trade filled and the output was computed. READ adapters use the
772
795
  * standalone HTTP-only InfoClient (utils/standaloneInfoClient) — no WebSocket —
@@ -0,0 +1,58 @@
1
+ // Quote an order's fees through the controller's calculateFees: the preview the
2
+ // clients show, with the rewards and subscription sources the fee resolver uses.
3
+ // Read-only: nothing is signed or placed.
4
+
5
+ import {
6
+ configuredSymbols,
7
+ getCoreController,
8
+ isDirectRun,
9
+ optionalBooleanParam,
10
+ optionalParam,
11
+ requestedRewardsDiscountBips,
12
+ runAdapter,
13
+ } from './_controller.mjs';
14
+
15
+ const ORDER_TYPES = ['market', 'limit'];
16
+
17
+ export async function quoteFees(input) {
18
+ const symbols = configuredSymbols(input, []);
19
+ if (symbols.length !== 1) {
20
+ throw new Error(`metamask.perps.quote_fees requires exactly one market; got ${JSON.stringify(symbols)}.`);
21
+ }
22
+ const orderType = (optionalParam(input.node, 'order_type', 'orderType') ?? 'market').toLowerCase();
23
+ if (!ORDER_TYPES.includes(orderType)) {
24
+ throw new Error(`metamask.perps.quote_fees order_type must be ${ORDER_TYPES.join(' or ')}; got ${orderType}.`);
25
+ }
26
+ const isMaker = optionalBooleanParam(input.node, 'is_maker', 'isMaker');
27
+ const amount = optionalParam(input.node, 'amount');
28
+ if (amount !== undefined && !(Number(amount) > 0)) {
29
+ throw new Error(`metamask.perps.quote_fees amount must be a positive USD notional; got ${amount}.`);
30
+ }
31
+
32
+ const { controller, accountAddress, network } = await getCoreController(input);
33
+ // calculateFees routes to the active provider, which init() brings up.
34
+ await controller.init();
35
+ const fees = await controller.calculateFees({
36
+ symbol: symbols[0],
37
+ orderType,
38
+ ...(isMaker === undefined ? {} : { isMaker }),
39
+ ...(amount === undefined ? {} : { amount }),
40
+ });
41
+ return {
42
+ action: input.action,
43
+ source: 'perps-controller-calculateFees',
44
+ network,
45
+ account: accountAddress,
46
+ market: symbols[0],
47
+ orderType,
48
+ isMaker: isMaker ?? null,
49
+ amount: amount ?? null,
50
+ rewardsDiscountBips: requestedRewardsDiscountBips(input) ?? null,
51
+ // Core has no RewardsController: a discount here is the one the node supplied.
52
+ rewardsSource: requestedRewardsDiscountBips(input) === undefined ? null : 'node.rewards_discount_bips',
53
+ fees,
54
+ proofPath: 'perps-controller-calculateFees',
55
+ };
56
+ }
57
+
58
+ if (isDirectRun(import.meta.url)) runAdapter(quoteFees);