@deeeed/metamask-harness 0.79.0 → 0.81.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 (252) hide show
  1. package/CHANGELOG.md +73 -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 +16 -64
  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/adapters/terminal/launch.mjs +17 -13
  26. package/adapters/terminal/lib/mainnet-guard.mjs +9 -0
  27. package/adapters/terminal/lib/readiness.mjs +20 -12
  28. package/adapters/terminal/policy.mjs +35 -0
  29. package/adapters/terminal/verify.mjs +10 -3
  30. package/adapters/terminal/wallet-host.mjs +43 -225
  31. package/bin/mm-harness +9 -28
  32. package/dist/adapters/checkout-files.js +16 -0
  33. package/dist/adapters/console-capture.js +14 -1
  34. package/dist/adapters/core/actions.js +8 -4
  35. package/dist/adapters/core/overlay.js +1 -1
  36. package/dist/adapters/core/readiness.js +28 -1
  37. package/dist/adapters/core/run.js +2 -2
  38. package/dist/adapters/core/surface.js +16 -1
  39. package/dist/adapters/extension/actions.js +6 -2
  40. package/dist/adapters/extension/browser-pid.js +56 -0
  41. package/dist/adapters/extension/console-capture.js +1 -1
  42. package/dist/adapters/extension/doctor-checks.js +1 -1
  43. package/dist/adapters/extension/ensure-ready.js +5 -4
  44. package/dist/adapters/extension/extension-id.js +1 -1
  45. package/dist/adapters/extension/feature-flags.js +1 -1
  46. package/dist/adapters/extension/fixtures.js +1 -2
  47. package/dist/adapters/extension/launch.js +4 -5
  48. package/dist/adapters/extension/launched-browser.js +1 -2
  49. package/dist/adapters/extension/network-observer.js +14 -1
  50. package/dist/adapters/extension/overlay.js +1 -1
  51. package/dist/adapters/extension/product-config.js +1 -1
  52. package/dist/adapters/extension/readiness.js +1 -1
  53. package/dist/adapters/extension/reload.js +1 -1
  54. package/dist/adapters/extension/run.js +135 -14
  55. package/dist/adapters/extension/runtime-decision.js +2 -1
  56. package/dist/adapters/extension/runtime.js +9 -5
  57. package/dist/adapters/extension/surface.js +15 -2
  58. package/dist/adapters/extension/verify.js +2 -2
  59. package/dist/adapters/harness-freshness.js +2 -1
  60. package/dist/adapters/mobile/actions.js +4 -1
  61. package/dist/adapters/mobile/android-gfxinfo-performance.js +1 -3
  62. package/dist/adapters/mobile/app-identity.js +10 -0
  63. package/dist/adapters/mobile/doctor-checks.js +1 -1
  64. package/dist/adapters/mobile/fixtures.js +1 -1
  65. package/dist/adapters/mobile/launch.js +27 -28
  66. package/dist/adapters/mobile/lifecycle.js +1 -1
  67. package/dist/adapters/mobile/metro-env.js +9 -59
  68. package/dist/adapters/mobile/network-observer.js +68 -0
  69. package/dist/adapters/mobile/performance-observer.js +2 -4
  70. package/dist/adapters/mobile/prepare-target.js +1 -1
  71. package/dist/adapters/mobile/prepare.js +3 -4
  72. package/dist/adapters/mobile/provision.js +1 -1
  73. package/dist/adapters/mobile/readiness.js +1 -1
  74. package/dist/adapters/mobile/recording-target.js +15 -0
  75. package/dist/adapters/mobile/release-artifact-state.js +1 -1
  76. package/dist/adapters/mobile/reload.js +1 -1
  77. package/dist/adapters/mobile/run.js +39 -15
  78. package/dist/adapters/mobile/runtime-decision.js +2 -4
  79. package/dist/adapters/mobile/source-freshness.js +22 -126
  80. package/dist/adapters/mobile/surface.js +26 -3
  81. package/dist/adapters/mobile/ui.js +16 -5
  82. package/dist/adapters/mobile/verify.js +2 -3
  83. package/dist/adapters/performance/cdp-trace.js +1 -1
  84. package/dist/adapters/performance/device-platform.js +1 -1
  85. package/dist/adapters/perps-order-inputs.js +36 -0
  86. package/dist/adapters/proof-failure.js +11 -0
  87. package/dist/adapters/slot-ports.js +1 -1
  88. package/dist/adapters/surface.js +1 -0
  89. package/dist/adapters/terminal/actions.js +4 -1
  90. package/dist/adapters/terminal/console-capture.js +1 -1
  91. package/dist/adapters/terminal/doctor-checks.js +2 -0
  92. package/dist/adapters/terminal/launch.js +4 -1
  93. package/dist/adapters/terminal/overlay.js +8 -1
  94. package/dist/adapters/terminal/run.js +33 -5
  95. package/dist/adapters/terminal/surface.js +9 -1
  96. package/dist/adapters.js +27 -7
  97. package/dist/advisor-policy.js +1 -1
  98. package/dist/checkout-view.js +1 -2
  99. package/dist/cli.js +59 -21
  100. package/dist/command-contract.js +1 -26
  101. package/dist/commands/check.js +33 -10
  102. package/dist/commands/checklist.js +1 -1
  103. package/dist/commands/completion-candidates.js +6 -14
  104. package/dist/commands/config.js +2 -2
  105. package/dist/commands/core-readiness.js +12 -7
  106. package/dist/commands/debug.js +3 -3
  107. package/dist/commands/device-target.js +5 -5
  108. package/dist/commands/doctor.js +2 -14
  109. package/dist/commands/domain.js +2 -2
  110. package/dist/commands/ensure-ready.js +1 -7
  111. package/dist/commands/fixtures.js +5 -6
  112. package/dist/commands/help.js +3 -4
  113. package/dist/commands/launch/device.js +10 -17
  114. package/dist/commands/launch/extension.js +2 -8
  115. package/dist/commands/launch/mobile.js +1 -1
  116. package/dist/commands/logs.js +3 -3
  117. package/dist/commands/mobile-device-view.js +4 -2
  118. package/dist/commands/overlay.js +110 -0
  119. package/dist/commands/pin.js +1 -1
  120. package/dist/commands/pr-body.js +2 -2
  121. package/dist/commands/prepare.js +4 -9
  122. package/dist/commands/provision.js +3 -12
  123. package/dist/commands/recipe-advice.js +1 -2
  124. package/dist/commands/recipe-quality.js +2 -3
  125. package/dist/commands/resolve-extension.js +1 -7
  126. package/dist/commands/review.js +4 -4
  127. package/dist/commands/runtime-decision.js +1 -6
  128. package/dist/commands/runtime-health.js +1 -7
  129. package/dist/commands/runtime-launch.js +1 -13
  130. package/dist/commands/self-test.js +5 -8
  131. package/dist/commands/status-watch.js +1 -2
  132. package/dist/commands/status.js +2 -5
  133. package/dist/commands/task-init.js +2 -3
  134. package/dist/commands/tutorial.js +2 -2
  135. package/dist/commands/update.js +1 -1
  136. package/dist/completions-cache.js +1 -1
  137. package/dist/console-classifier.js +19 -0
  138. package/dist/doctor.js +2 -3
  139. package/dist/engine.js +57 -0
  140. package/dist/funding-execution-context.js +96 -1
  141. package/dist/host.js +32 -0
  142. package/dist/index.js +1 -6
  143. package/dist/live-tsx.js +49 -0
  144. package/dist/mm-harness-cli.js +4 -6
  145. package/dist/paths.js +34 -64
  146. package/dist/performance-observation.js +3 -5
  147. package/dist/progress.js +1 -1
  148. package/dist/recipe-assistance-context.js +6 -9
  149. package/dist/recipe-security.js +2 -59
  150. package/dist/review/knowledge.js +2 -2
  151. package/dist/runner.js +7 -4
  152. package/dist/runtime-context.js +1 -1
  153. package/dist/task-view.js +2 -3
  154. package/docs/CONTRIBUTING.md +5 -3
  155. package/docs/RECIPES.md +31 -3
  156. package/docs/cli-contract.md +2 -2
  157. package/docs/feature-guard.md +90 -0
  158. package/library/README.md +3 -1
  159. package/library/actions/core/perps/_controller.mjs +153 -134
  160. package/library/actions/core/perps/_rewards.mjs +186 -0
  161. package/library/actions/core/perps/close_positions.mjs +1 -1
  162. package/library/actions/core/perps/place_order.mjs +167 -45
  163. package/library/actions/core/perps/quote_fees.mjs +61 -0
  164. package/library/actions/core/wallet/list_accounts.mjs +14 -57
  165. package/library/actions/extension/feature_flags/state.mjs +1 -1
  166. package/library/actions/extension/perps/close_visible_position.mjs +5 -0
  167. package/library/actions/extension/perps/edit_margin.mjs +4 -0
  168. package/library/actions/extension/perps/mutation-receipt.mjs +95 -3
  169. package/library/actions/extension/perps/perps.mjs +320 -3
  170. package/library/actions/extension/perps/place_order.mjs +3 -1
  171. package/library/actions/extension/perps/update_position_tpsl.mjs +4 -0
  172. package/library/actions/extension/platform/cdp.mjs +73 -11
  173. package/library/actions/harness-exports.mjs +1 -6
  174. package/library/actions/mobile/app/network-control.mjs +6 -8
  175. package/library/actions/mobile/perps/perps.mjs +216 -11
  176. package/library/actions/mobile/perps/place_order.mjs +3 -1
  177. package/library/actions/mobile/perps/prepare_local_snapshot_endpoint.mjs +1 -1
  178. package/library/actions/mobile/platform/bridge.mjs +8 -8
  179. package/library/actions/mobile/platform/native-session-name.mjs +21 -1
  180. package/library/actions/mobile/platform/native-session.mjs +3 -4
  181. package/library/actions/mobile/platform/observe-ui.mjs +12 -6
  182. package/library/actions/mobile/ui/native-navigation.mjs +4 -3
  183. package/library/actions/mobile/wallet/native-ui.mjs +3 -2
  184. package/library/actions/shared/analytics/_adapter.mjs +1 -1
  185. package/library/actions/shared/console-findings.mjs +1 -1
  186. package/library/actions/shared/perps/order-policy.mjs +177 -0
  187. package/library/actions/shared/wallet/fixture-accounts.mjs +84 -0
  188. package/library/actions/terminal/app/launch.mjs +3 -2
  189. package/library/actions/terminal/platform/hud.mjs +2 -1
  190. package/library/actions/terminal/platform/page.mjs +2 -1
  191. package/library/actions/terminal/platform/runtime.mjs +2 -1
  192. package/library/actions/terminal/wallet/_signature-log.mjs +40 -161
  193. package/library/actions/terminal/wallet/assert_signatures.mjs +6 -12
  194. package/library/actions/terminal/wallet/read_signatures.mjs +10 -9
  195. package/library/manifests/core.action-manifest.json +87 -13
  196. package/library/manifests/extension.action-manifest.json +4 -0
  197. package/library/manifests/mobile.action-manifest.json +12 -0
  198. package/package.json +8 -9
  199. package/site/assets/help-recipes.json +1 -0
  200. package/adapters/extension/lib/slot-title.cjs +0 -292
  201. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +0 -472
  202. package/adapters/mobile/bridge-runtime/lib/bridge-errors.cjs +0 -125
  203. package/adapters/mobile/bridge-runtime/lib/cdp-broker.cjs +0 -1055
  204. package/adapters/mobile/bridge-runtime/lib/cdp-eval.cjs +0 -110
  205. package/adapters/mobile/bridge-runtime/lib/config.cjs +0 -61
  206. package/adapters/mobile/bridge-runtime/lib/console-format.cjs +0 -53
  207. package/adapters/mobile/bridge-runtime/lib/devtools-proxy.cjs +0 -177
  208. package/adapters/mobile/bridge-runtime/lib/issue-capture.cjs +0 -446
  209. package/adapters/mobile/bridge-runtime/lib/match-bridge-target.cjs +0 -84
  210. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +0 -403
  211. package/adapters/mobile/bridge-runtime/lib/ws-client.cjs +0 -116
  212. package/adapters/mobile/coalesce-metro-log.cjs +0 -24
  213. package/adapters/mobile/launch-metro.cjs +0 -77
  214. package/adapters/mobile/metro-config.cjs +0 -98
  215. package/adapters/mobile/metro-log-generation.cjs +0 -106
  216. package/adapters/terminal/lib/origin.mjs +0 -30
  217. package/adapters/terminal/lib/page-script.mjs +0 -246
  218. package/adapters/terminal/lib/strict-wallet.mjs +0 -138
  219. package/dist/adapters/mobile/frame-metrics.js +0 -118
  220. package/dist/adapters/mobile/video-recorder.js +0 -289
  221. package/dist/artifact-files.js +0 -92
  222. package/dist/checkout-lock.js +0 -149
  223. package/dist/cli-color.js +0 -98
  224. package/dist/command-journal.js +0 -308
  225. package/dist/commands/call.js +0 -685
  226. package/dist/commands/last.js +0 -60
  227. package/dist/commands/launch/index.js +0 -109
  228. package/dist/commands/list-executables.js +0 -247
  229. package/dist/commands/manifest.js +0 -488
  230. package/dist/commands/parse-args.js +0 -279
  231. package/dist/commands/reload.js +0 -41
  232. package/dist/commands/run-engine.js +0 -1340
  233. package/dist/commands/run-report.js +0 -241
  234. package/dist/commands/run.js +0 -751
  235. package/dist/commands/shared.js +0 -323
  236. package/dist/commands/stop.js +0 -60
  237. package/dist/devices.js +0 -100
  238. package/dist/discovery.js +0 -15
  239. package/dist/execution-provenance.js +0 -332
  240. package/dist/harness.js +0 -451
  241. package/dist/heal-bounds.js +0 -137
  242. package/dist/json-stream.js +0 -60
  243. package/dist/leaf-invoke.js +0 -19
  244. package/dist/library-provenance.js +0 -33
  245. package/dist/live-adapter-contract.js +0 -380
  246. package/dist/metamask-action-validation.js +0 -68
  247. package/dist/network-observation.js +0 -290
  248. package/dist/recording-target.js +0 -120
  249. package/dist/run-diagnostics.js +0 -364
  250. package/dist/run-recording.js +0 -481
  251. package/dist/runtime-proof.js +0 -90
  252. package/library/actions/mobile/platform/tool-paths.mjs +0 -123
@@ -5,9 +5,9 @@ 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 '../../shared/wallet/fixture-accounts.mjs';
10
+ import { createRewardsClient, rewardsApiUrl } from './_rewards.mjs';
11
11
 
12
12
  function controllerEntry(projectRoot) {
13
13
  return pathToFileURL(path.join(projectRoot, 'packages/perps-controller/src/index.ts')).href;
@@ -27,7 +27,7 @@ function messengerEntry(projectRoot) {
27
27
  // a persistent auto-reconnecting HyperLiquid WebSocket (HyperLiquidClientService
28
28
  // .initialize → wsTransport.ready()); these logs proved the order returns over
29
29
  // HTTP while that open socket kept the event loop alive — see disconnectAndExit.
30
- function buildInfrastructure(stubbed, input) {
30
+ function buildInfrastructure(stubbed, input, rewardsSource) {
31
31
  const noop = () => undefined;
32
32
  const terminalApi = resolveTerminalApi(input);
33
33
  return {
@@ -69,153 +69,159 @@ function buildInfrastructure(stubbed, input) {
69
69
  removeItem: async () => undefined,
70
70
  },
71
71
  ...(terminalApi ? { terminalApi } : {}),
72
- rewards: { getPerpsDiscountForAccount: async () => null },
72
+ rewards: {
73
+ getPerpsDiscountForAccount: (caipAccountId, baseFeeBips) =>
74
+ rewardsSource.messenger.call(REWARDS_DISCOUNT_ACTION, caipAccountId, baseFeeBips),
75
+ },
73
76
  };
74
77
  }
75
78
 
76
- // --- Account resolution from wallet-fixture.json ---
79
+ // --- Rewards ---
77
80
  //
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.
81
+ // The clients answer the rewards dependency through the messenger's
82
+ // RewardsController:getPerpsDiscountForAccount (extension perps-controller-init,
83
+ // mobile mobileInfrastructure). Core has no RewardsController, so the headless
84
+ // wallet registers one: the rewards client (_rewards.mjs) asks the rewards API
85
+ // for the selected fixture account, as Mobile's RewardsController does. A node's
86
+ // rewards_discount_bips replaces that answer, to prove a discount the fixture
87
+ // accounts (non-VIP) cannot get from the backend.
88
+ //
89
+ // The controller asks for the discount with the account's CAIP id, built from
90
+ // the wallet's selected network (NetworkController:getState and
91
+ // getNetworkClientById), so the wallet also answers those with the network a
92
+ // fresh client wallet starts on, Ethereum mainnet.
93
+
94
+ const REWARDS_DISCOUNT_ACTION = 'RewardsController:getPerpsDiscountForAccount';
95
+
84
96
 
85
97
  /**
86
- * Load wallet-fixture.json and return the named account entry.
87
- * Returns null if the fixture file does not exist.
98
+ * The rewards discount a node supplies, in basis points (0-10000), or undefined.
88
99
  *
89
- * @param projectRoot - Absolute path to the project root.
90
- * @param accountName - The `name` field to match in fixture.accounts.
100
+ * @param input - Adapter input (node.rewards_discount_bips).
91
101
  */
92
- async function loadFixtureAccount(projectRoot, accountName) {
93
- const fixturePath = walletFixturePath(projectRoot);
94
- let raw;
95
- try {
96
- raw = await readFile(fixturePath, 'utf8');
97
- } catch (error) {
98
- if (error?.code === 'ENOENT') return null;
99
- throw error;
100
- }
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
- );
102
+ export function requestedRewardsDiscountBips(input) {
103
+ const raw = optionalParam(input.node, 'rewards_discount_bips', 'rewardsDiscountBips');
104
+ if (raw === undefined) return undefined;
105
+ const bips = Number(raw);
106
+ if (!Number.isInteger(bips) || bips < 0 || bips > 10000) {
107
+ throw new Error(`rewards_discount_bips must be an integer from 0 through 10000; got ${raw}.`);
119
108
  }
120
- return entry;
109
+ return bips;
121
110
  }
122
111
 
123
- /**
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.
127
- */
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
- );
139
- }
140
- return privateKeyToAccount(normalized);
112
+ const REWARDS_NETWORK_ACTIONS = ['NetworkController:getState', 'NetworkController:getNetworkClientById'];
113
+
114
+ function registerRewardsHandler(rootMessenger, childMessenger, discountBips, rewardsClient) {
115
+ rootMessenger.registerActionHandler(
116
+ REWARDS_DISCOUNT_ACTION,
117
+ discountBips === undefined
118
+ ? (caipAccountId, baseFeeBips) => rewardsClient.getPerpsDiscountForAccount(caipAccountId, baseFeeBips)
119
+ : async () => discountBips,
120
+ );
121
+ rootMessenger.registerActionHandler('NetworkController:getState', () => ({
122
+ selectedNetworkClientId: 'mainnet',
123
+ }));
124
+ rootMessenger.registerActionHandler('NetworkController:getNetworkClientById', (networkClientId) => {
125
+ if (networkClientId !== 'mainnet') throw new Error(`No network client with id ${networkClientId}.`);
126
+ return { configuration: { chainId: '0x1' } };
127
+ });
128
+ rootMessenger.delegate({ actions: REWARDS_NETWORK_ACTIONS, messenger: childMessenger });
141
129
  }
142
130
 
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';
131
+ // --- Account selection from wallet-fixture.json ---
132
+ //
133
+ // The core adapter reads the same wallet-fixture.json the mobile/extension
134
+ // adapters use (recipeRuntimePath/wallet-fixture.json), expanded the way
135
+ // list_accounts shows it (dev1..devN from a mnemonic with count N). A node
136
+ // selects an account by name (`account_name`, or a non-address `account`) or by
137
+ // 0x address (`account`, `address`, `userAddress`); dev1 when it names none.
138
+ // An unknown name is refused, and an address is never swapped for another
139
+ // account: one outside the fixture is read as given, and cannot sign.
140
+
141
+ const ADDRESS_PATTERN = /^0x[0-9a-fA-F]{40}$/u;
142
+
143
+ function trimmedString(value) {
144
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
162
145
  }
163
146
 
164
147
  /**
165
- * Resolve the viem signer and EVM address for writes.
166
- * The wallet fixture account selected by name is the only signing source.
148
+ * Resolve the account a node selects.
167
149
  *
168
- * @param input - Adapter input (context.projectRoot, node.account_name).
169
- * @returns { account: ViemAccount, address: string }
150
+ * @param input - Adapter input (context.projectRoot, node account fields).
151
+ * @returns { name, address, signer } — name and signer are null for an address outside the fixture.
170
152
  */
171
- async function resolveSignerFromFixture(input) {
153
+ export async function selectAccount(input) {
154
+ const node = input.node ?? {};
172
155
  const projectRoot = input.context?.projectRoot;
173
- const accountName = resolveAccountName(input);
156
+ const accountField = trimmedString(node.account);
157
+ const names = [trimmedString(node.account_name), accountField && !ADDRESS_PATTERN.test(accountField) ? accountField : undefined]
158
+ .filter((value) => value !== undefined);
159
+ const addresses = [node.account, node.address, node.userAddress]
160
+ .map(trimmedString)
161
+ .filter((value) => value !== undefined && ADDRESS_PATTERN.test(value));
162
+ if (new Set(names).size > 1) {
163
+ throw new Error(`core perps account selectors disagree: ${[...new Set(names)].join(' vs ')}; select one account.`);
164
+ }
165
+ if (new Set(addresses.map((value) => value.toLowerCase())).size > 1) {
166
+ throw new Error(`core perps account selectors disagree: ${addresses.join(' vs ')}; select one account.`);
167
+ }
168
+ const [name] = names;
169
+ const [address] = addresses;
170
+ const loaded = projectRoot ? await readFixtureAccounts(projectRoot) : null;
174
171
 
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
- }
172
+ if (!loaded) {
173
+ if (!name && address) return { name: null, address, signer: null };
174
+ throw new Error(
175
+ `core perps account "${name ?? 'dev1'}" needs a wallet fixture at ` +
176
+ `${projectRoot ? walletFixturePath(projectRoot) : 'the checkout'}; ` +
177
+ 'a read-only action can name a 0x address via account= instead.\n' +
178
+ 'Next: run mm-harness fixtures set',
179
+ );
181
180
  }
182
181
 
183
- throw new Error(
184
- `core perps writes require wallet-fixture.json account "${accountName}". ` +
185
- `Next: run mm-harness fixtures set`,
186
- );
182
+ const available = loaded.accounts.map((account) => account.name).join(', ');
183
+ const byName = (wanted) => loaded.accounts.find((account) => account.name === wanted);
184
+ if (name) {
185
+ const matches = loaded.accounts.filter((account) => account.name === name);
186
+ if (matches.length > 1) {
187
+ throw new Error(`wallet fixture has ${matches.length} accounts named "${name}"; rename one in ${loaded.fixturePath}.`);
188
+ }
189
+ const [match] = matches;
190
+ if (!match) {
191
+ throw new Error(`wallet fixture has no account named "${name}". Available: ${available}.`);
192
+ }
193
+ if (address && match.address.toLowerCase() !== address.toLowerCase()) {
194
+ throw new Error(`fixture account "${name}" is ${match.address}, not ${address}; select one of them.`);
195
+ }
196
+ return match;
197
+ }
198
+ if (address) {
199
+ return loaded.accounts.find((account) => account.address.toLowerCase() === address.toLowerCase()) ??
200
+ { name: null, address, signer: null };
201
+ }
202
+ const fallback = byName('dev1');
203
+ if (!fallback) {
204
+ throw new Error(`wallet fixture has no account named "dev1" (the default). Available: ${available}; pass account=<name>.`);
205
+ }
206
+ return fallback;
187
207
  }
188
208
 
189
209
  /**
190
- * Resolve the account address for reads (no signing required).
191
- * Prefer the wallet fixture account; explicit node addresses support read-only calls.
210
+ * Resolve the viem signer and EVM address for writes: a fixture account,
211
+ * selected by name or by its address.
212
+ *
213
+ * @param input - Adapter input (context.projectRoot, node account fields).
214
+ * @returns { account: ViemAccount, address: string }
192
215
  */
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)) {
216
+ async function resolveSignerFromFixture(input) {
217
+ const selected = await selectAccount(input);
218
+ if (!selected.signer) {
213
219
  throw new Error(
214
- `core perps reads require a wallet-fixture.json with account "${accountName}", ` +
215
- `or a 0x EVM address via node.account.`,
220
+ `core perps writes sign with a wallet-fixture.json account; ${selected.address} is not one of them.\n` +
221
+ 'Next: pass account=<fixture account name>, or an address listed by metamask.wallet.list_accounts',
216
222
  );
217
223
  }
218
- return address;
224
+ return { account: selected.signer, address: selected.address };
219
225
  }
220
226
 
221
227
  let cached = null;
@@ -271,17 +277,20 @@ export function requireRuntimeExport(moduleNamespace, exportName, sourceLabel) {
271
277
  export async function getCoreController(input) {
272
278
  const projectRoot = input.context?.projectRoot;
273
279
  if (!projectRoot) throw new Error('core adapter requires context.projectRoot.');
274
- const accountAddress = await requireAccountAddress(input);
280
+ const selected = await selectAccount(input);
281
+ const accountAddress = selected.address;
275
282
  const network = resolveNetwork(input);
276
283
  const terminalApi = resolveTerminalApi(input);
277
284
  const terminalApiKey = JSON.stringify(terminalApi ?? null);
285
+ const rewardsDiscountBips = requestedRewardsDiscountBips(input);
278
286
 
279
287
  if (
280
288
  cached &&
281
289
  cached.projectRoot === projectRoot &&
282
290
  cached.network === network &&
283
291
  cached.accountAddress.toLowerCase() === accountAddress.toLowerCase() &&
284
- cached.terminalApiKey === terminalApiKey
292
+ cached.terminalApiKey === terminalApiKey &&
293
+ cached.rewardsDiscountBips === rewardsDiscountBips
285
294
  ) {
286
295
  return { ...cached, accountAddress };
287
296
  }
@@ -307,7 +316,8 @@ export async function getCoreController(input) {
307
316
  );
308
317
 
309
318
  const stubbed = new Set();
310
- const infrastructure = buildInfrastructure(stubbed, input);
319
+ const rewardsSource = {};
320
+ const infrastructure = buildInfrastructure(stubbed, input, rewardsSource);
311
321
 
312
322
  // Root + child messenger pair, mirroring the real app wiring (and the core
313
323
  // repo's own test harness in
@@ -323,6 +333,11 @@ export async function getCoreController(input) {
323
333
  parent: rootMessenger,
324
334
  });
325
335
  registerSelectedAccountHandler(rootMessenger, messenger, accountAddress);
336
+ // An address outside the fixture has no signer, so the client answers null:
337
+ // no subscription, as on Mobile for an account that never signed in.
338
+ const rewardsClient = createRewardsClient({ signer: selected.signer, baseUrl: rewardsApiUrl() });
339
+ registerRewardsHandler(rootMessenger, messenger, rewardsDiscountBips, rewardsClient);
340
+ rewardsSource.messenger = rootMessenger;
326
341
 
327
342
  // Default testnet. Mainnet only on explicit node.network: "mainnet" — mainnet
328
343
  // reads are safe; mainnet mutations use real funds (gated in getCoreControllerWithSigner).
@@ -365,6 +380,8 @@ export async function getCoreController(input) {
365
380
  network,
366
381
  accountAddress,
367
382
  terminalApiKey,
383
+ rewardsDiscountBips,
384
+ rewardsClient,
368
385
  };
369
386
  return { ...cached, accountAddress };
370
387
  }
@@ -450,6 +467,9 @@ function registerSignerHandlers(rootMessenger, childMessenger, account) {
450
467
  'KeyringController:signTypedMessage received no typed data to sign.',
451
468
  );
452
469
  }
470
+ if (msgParams?.from && String(msgParams.from).toLowerCase() !== account.address.toLowerCase()) {
471
+ throw new Error(`KeyringController:signTypedMessage asked to sign for ${msgParams.from}; the selected account is ${account.address}.`);
472
+ }
453
473
  const { domain, types, primaryType, message } = typedData;
454
474
  // Strip the EIP712Domain entry if present: viem derives the domain types
455
475
  // from `domain` itself and rejects a duplicate EIP712Domain in `types`.
@@ -486,16 +506,16 @@ function registerSignerHandlers(rootMessenger, childMessenger, account) {
486
506
  * @returns { controller, projectRoot, network, accountAddress, signerAddress }.
487
507
  */
488
508
  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
- )) {
509
+ // A selection given only as empty values must not quietly sign as dev1.
510
+ const selectors = ['account_name', 'account', 'address', 'userAddress'].filter((key) => input.node?.[key] != null);
511
+ if (selectors.length > 0 && selectors.every((key) => trimmedString(input.node[key]) === undefined)) {
494
512
  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.',
513
+ 'Core signing requires a non-empty fixture account name or address.\n' +
514
+ 'Next: pass account=<fixture account name>, or omit it to sign as dev1.',
497
515
  );
498
516
  }
517
+ // Refuse an account that cannot sign before any controller I/O.
518
+ const { account, address: signerAddress } = await resolveSignerFromFixture(input);
499
519
  const base = await getCoreController(input);
500
520
  const { controller, messenger: childMessenger, rootMessenger, accountAddress } = base;
501
521
  if (!rootMessenger || !childMessenger) {
@@ -517,7 +537,6 @@ export async function getCoreControllerWithSigner(input) {
517
537
  );
518
538
  }
519
539
 
520
- const { account, address: signerAddress } = await resolveSignerFromFixture(input);
521
540
  registerSignerHandlers(rootMessenger, childMessenger, account);
522
541
 
523
542
  // Bring up the active provider. placeOrder/closePosition call
@@ -741,7 +760,7 @@ export function redactOrder(order) {
741
760
  };
742
761
  }
743
762
 
744
- // --- Adapter IO (mirrors the live-adapter contract in src/live-adapter-contract.ts) ---
763
+ // --- Adapter IO (mirrors the live-adapter contract in @farmslot/recipe-cli/harness live-adapter-contract) ---
745
764
 
746
765
  async function loadInput() {
747
766
  const inputPath = process.argv[2] || process.env.METAMASK_RECIPE_ADAPTER_INPUT;
@@ -766,7 +785,7 @@ const fmtError = (e) => e?.stack ?? e?.message ?? String(e);
766
785
  * wsTransport.ready(). Orders themselves go over the HTTP ExchangeClient and
767
786
  * RETURN normally, but that open WebSocket keeps the Node event loop alive, so
768
787
  * 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
788
+ * `close` event (@farmslot/recipe-cli/harness live-adapter-contract runProcess), so it waits out
770
789
  * live_adapter_timeout_ms, SIGTERMs the child, and reports `fail` even though
771
790
  * the trade filled and the output was computed. READ adapters use the
772
791
  * standalone HTTP-only InfoClient (utils/standaloneInfoClient) — no WebSocket —
@@ -0,0 +1,186 @@
1
+ // Headless rewards client for the core Perps controller's `rewards` dependency.
2
+ // It makes the calls Mobile's RewardsController makes to answer
3
+ // getPerpsDiscountForAccount: public opt-in check, mobile-login with a
4
+ // personal_sign over `rewards,<address>,<unix seconds>`, then GET /vip/fees.
5
+ // The session token stays in memory and is never printed. Mobile also gates the
6
+ // call on its remote VIP feature flag (off: null); core has no remote flags, so
7
+ // the client answers as Mobile does with VIP enabled.
8
+
9
+ // Mobile's default outside production builds (METAMASK_ENVIRONMENT=dev on farm
10
+ // slots), from AppConstants.REWARDS_API_URL.UAT.
11
+ export const DEFAULT_REWARDS_API_URL = 'https://rewards.uat-api.cx.metamask.io';
12
+
13
+ const CLIENT_ID = 'mm-harness-core';
14
+
15
+ // Mobile's rewards-data-service DEFAULT_REQUEST_TIMEOUT_MS. A stalled backend
16
+ // must not hold a fee calculation (and the order behind it) open.
17
+ const REQUEST_TIMEOUT_MS = 10_000;
18
+
19
+ export function rewardsApiUrl(env = process.env) {
20
+ return (env.CORE_PERPS_REWARDS_API_URL?.trim() || DEFAULT_REWARDS_API_URL).replace(/\/+$/u, '');
21
+ }
22
+
23
+ /**
24
+ * Mobile's VIP math (RewardsController #getVipPerpsDiscountBips): the backend
25
+ * returns an absolute builder fee, converted to a discount off `baseFeeBips`.
26
+ *
27
+ * @param vipFees - The /vip/fees body.
28
+ * @param baseFeeBips - The perps MetaMask builder base fee in bips.
29
+ * @returns Discount bips; 0 without a VIP fee; null for an unusable fee.
30
+ */
31
+ export function vipDiscountBips(vipFees, baseFeeBips) {
32
+ if (!vipFees?.fees || !(vipFees.vipTier > 0)) return 0;
33
+ const raw = vipFees.fees.hyperliquid?.builderFeeBips;
34
+ if (raw === undefined || raw === null) return 0;
35
+ const builderFeeBips = Number.parseFloat(raw);
36
+ if (!Number.isFinite(builderFeeBips) || builderFeeBips < 0) return null;
37
+ const discount = Math.round(10000 * (1 - builderFeeBips / baseFeeBips));
38
+ return discount >= 0 && discount <= 10000 ? discount : 0;
39
+ }
40
+
41
+ // An error response keeps only its status and the fields the client acts on:
42
+ // the body itself may echo the request, credentials included, and controller
43
+ // logs print error messages.
44
+ class RewardsApiError extends Error {
45
+ constructor(method, route, status, body) {
46
+ super(`rewards API ${method} ${route} returned ${status}.`);
47
+ this.status = status;
48
+ const data = parseErrorBody(body);
49
+ this.invalidTimestamp = /Invalid timestamp/u.test(String(data?.message ?? ''));
50
+ this.serverTimestampMs = Number(data?.serverTimestamp);
51
+ }
52
+ }
53
+
54
+ function parseErrorBody(body) {
55
+ try {
56
+ return JSON.parse(body);
57
+ } catch (error) {
58
+ // A non-JSON error page carries no field the client acts on; the status
59
+ // already says what failed.
60
+ if (error instanceof SyntaxError) return null;
61
+ throw error;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * @param options.signer - viem account of the selected fixture account, or null.
67
+ * @param options.baseUrl - Rewards API base URL.
68
+ * @param options.fetchImpl - fetch, injectable for tests.
69
+ * @param options.now - clock in ms, injectable for tests.
70
+ * @param options.timeoutMs - per-request deadline, headers and body included.
71
+ * @returns The `rewards` platform dependency plus `calls`, one entry per
72
+ * answered request, for action output.
73
+ */
74
+ export function createRewardsClient({
75
+ signer,
76
+ baseUrl,
77
+ fetchImpl = fetch,
78
+ now = Date.now,
79
+ timeoutMs = REQUEST_TIMEOUT_MS,
80
+ }) {
81
+ const calls = [];
82
+ let feesPromise;
83
+
84
+ async function request(method, route, { body, token } = {}) {
85
+ const response = await fetchImpl(`${baseUrl}${route}`, {
86
+ method,
87
+ headers: {
88
+ 'Content-Type': 'application/json',
89
+ 'rewards-client-id': CLIENT_ID,
90
+ ...(token ? { 'rewards-access-token': token } : {}),
91
+ },
92
+ ...(body ? { body: JSON.stringify(body) } : {}),
93
+ signal: AbortSignal.timeout(timeoutMs),
94
+ });
95
+ const text = await response.text();
96
+ if (!response.ok) throw new RewardsApiError(method, route, response.status, text);
97
+ return JSON.parse(text);
98
+ }
99
+
100
+ async function login(address, timestamp, retried = false) {
101
+ const message = `rewards,${address},${timestamp}`;
102
+ const signature = await signer.signMessage({ message });
103
+ try {
104
+ return await request('POST', '/auth/mobile-login', { body: { account: address, timestamp, signature } });
105
+ } catch (error) {
106
+ // Mobile re-signs once with the server clock when the backend rejects ours.
107
+ if (
108
+ !(error instanceof RewardsApiError) || retried || !error.invalidTimestamp ||
109
+ !Number.isFinite(error.serverTimestampMs)
110
+ ) {
111
+ throw error;
112
+ }
113
+ return login(address, Math.floor(error.serverTimestampMs / 1000), true);
114
+ }
115
+ }
116
+
117
+ // Mobile's silent auth: skip login only when the public opt-in check says the
118
+ // account never opted in, and read a 401 from login as not opted in.
119
+ async function openSession(address) {
120
+ let optedIn;
121
+ try {
122
+ const status = await request('POST', '/public/rewards/ois', { body: { addresses: [address] } });
123
+ optedIn = status?.ois?.[0];
124
+ } catch (error) {
125
+ // Mobile logs in anyway when the opt-in check fails: login answers the
126
+ // same question. A failure to reach the backend at all still throws.
127
+ if (!(error instanceof RewardsApiError)) throw error;
128
+ }
129
+ if (optedIn === false) return { optedIn: false };
130
+ let sessionId;
131
+ try {
132
+ ({ sessionId } = await login(address, Math.floor(now() / 1000)));
133
+ } catch (error) {
134
+ if (error instanceof RewardsApiError && error.status === 401) return { optedIn: false };
135
+ throw error;
136
+ }
137
+ if (typeof sessionId !== 'string' || !sessionId) {
138
+ throw new Error('rewards API mobile-login returned no sessionId.');
139
+ }
140
+ return { optedIn: true, token: sessionId };
141
+ }
142
+
143
+ // One login and one /vip/fees read per process (Mobile caches the fees for
144
+ // five minutes; an action process is shorter). The token is fresh, so the
145
+ // 403 re-login Mobile needs for a stored token does not arise here.
146
+ async function vipFees(address) {
147
+ const session = await openSession(address);
148
+ if (!session.optedIn) return session;
149
+ return { optedIn: true, fees: await request('GET', '/vip/fees', { token: session.token }) };
150
+ }
151
+
152
+ async function getPerpsDiscountForAccount(caipAccountId, baseFeeBips) {
153
+ // Lowercase, as Mobile's internal accounts store EVM addresses; the backend
154
+ // matches addresses case-insensitively.
155
+ const address = String(caipAccountId).split(':')[2]?.toLowerCase();
156
+ const record = (entry) => {
157
+ calls.push({ caipAccountId, baseFeeBips, ...entry });
158
+ return entry.discountBips;
159
+ };
160
+ if (!(Number.isFinite(baseFeeBips) && baseFeeBips > 0)) {
161
+ return record({ discountBips: null, reason: 'invalid-base-fee' });
162
+ }
163
+ // Only the selected fixture account can sign in; any other account has no
164
+ // subscription here, which Mobile also answers with null.
165
+ if (!signer || address !== signer.address.toLowerCase()) {
166
+ return record({ discountBips: null, reason: 'no-signer-for-account' });
167
+ }
168
+ feesPromise ??= vipFees(address);
169
+ let result;
170
+ try {
171
+ result = await feesPromise;
172
+ } catch (error) {
173
+ // Not cached: the next fee calculation asks the backend again.
174
+ feesPromise = undefined;
175
+ throw error;
176
+ }
177
+ if (!result.optedIn) return record({ discountBips: null, reason: 'not-opted-in' });
178
+ return record({
179
+ discountBips: vipDiscountBips(result.fees, baseFeeBips),
180
+ vipTier: result.fees.vipTier,
181
+ builderFeeBips: result.fees.fees?.hyperliquid?.builderFeeBips ?? null,
182
+ });
183
+ }
184
+
185
+ return { getPerpsDiscountForAccount, calls };
186
+ }
@@ -19,7 +19,7 @@ function sleep(ms) {
19
19
  return new Promise((resolve) => setTimeout(resolve, ms));
20
20
  }
21
21
 
22
- async function waitForPositionsAbsent(controller, accountAddress, symbols, timeoutMs) {
22
+ export async function waitForPositionsAbsent(controller, accountAddress, symbols, timeoutMs) {
23
23
  const deadline = Date.now() + timeoutMs;
24
24
  let positions = [];
25
25
  for (;;) {