@deeeed/metamask-harness 0.80.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 (76) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/adapters/manifest.json +14 -30
  3. package/adapters/terminal/launch.mjs +17 -13
  4. package/adapters/terminal/lib/mainnet-guard.mjs +9 -0
  5. package/adapters/terminal/lib/readiness.mjs +20 -12
  6. package/adapters/terminal/policy.mjs +35 -0
  7. package/adapters/terminal/verify.mjs +10 -3
  8. package/adapters/terminal/wallet-host.mjs +43 -225
  9. package/dist/adapters/core/actions.js +4 -1
  10. package/dist/adapters/extension/actions.js +6 -2
  11. package/dist/adapters/extension/network-observer.js +13 -0
  12. package/dist/adapters/extension/run.js +112 -5
  13. package/dist/adapters/mobile/actions.js +4 -1
  14. package/dist/adapters/mobile/network-observer.js +68 -0
  15. package/dist/adapters/mobile/performance-observer.js +1 -3
  16. package/dist/adapters/mobile/run.js +32 -9
  17. package/dist/adapters/perps-order-inputs.js +36 -0
  18. package/dist/adapters/proof-failure.js +11 -0
  19. package/dist/adapters/terminal/actions.js +4 -1
  20. package/dist/adapters/terminal/doctor-checks.js +2 -0
  21. package/dist/adapters/terminal/launch.js +3 -0
  22. package/dist/adapters/terminal/overlay.js +8 -1
  23. package/dist/adapters/terminal/run.js +8 -3
  24. package/dist/adapters/terminal/surface.js +2 -0
  25. package/dist/adapters.js +20 -4
  26. package/dist/cli.js +31 -6
  27. package/dist/commands/check.js +30 -6
  28. package/dist/commands/completion-candidates.js +5 -5
  29. package/dist/commands/self-test.js +4 -3
  30. package/dist/engine.js +57 -0
  31. package/dist/funding-execution-context.js +81 -0
  32. package/dist/mm-harness-cli.js +3 -3
  33. package/dist/performance-observation.js +1 -3
  34. package/dist/recipe-assistance-context.js +6 -7
  35. package/dist/runner.js +2 -1
  36. package/docs/CONTRIBUTING.md +4 -2
  37. package/docs/RECIPES.md +31 -3
  38. package/library/actions/core/perps/_controller.mjs +24 -28
  39. package/library/actions/core/perps/_rewards.mjs +186 -0
  40. package/library/actions/core/perps/close_positions.mjs +1 -1
  41. package/library/actions/core/perps/place_order.mjs +167 -45
  42. package/library/actions/core/perps/quote_fees.mjs +7 -4
  43. package/library/actions/core/wallet/list_accounts.mjs +1 -1
  44. package/library/actions/extension/perps/close_visible_position.mjs +5 -0
  45. package/library/actions/extension/perps/edit_margin.mjs +4 -0
  46. package/library/actions/extension/perps/mutation-receipt.mjs +95 -3
  47. package/library/actions/extension/perps/perps.mjs +320 -3
  48. package/library/actions/extension/perps/place_order.mjs +3 -1
  49. package/library/actions/extension/perps/update_position_tpsl.mjs +4 -0
  50. package/library/actions/extension/platform/cdp.mjs +71 -3
  51. package/library/actions/mobile/perps/perps.mjs +216 -11
  52. package/library/actions/mobile/perps/place_order.mjs +3 -1
  53. package/library/actions/shared/perps/order-policy.mjs +177 -0
  54. package/library/actions/{core/wallet/_fixture-accounts.mjs → shared/wallet/fixture-accounts.mjs} +18 -3
  55. package/library/actions/terminal/app/launch.mjs +3 -2
  56. package/library/actions/terminal/platform/hud.mjs +2 -1
  57. package/library/actions/terminal/platform/page.mjs +2 -1
  58. package/library/actions/terminal/wallet/_signature-log.mjs +40 -161
  59. package/library/actions/terminal/wallet/assert_signatures.mjs +6 -12
  60. package/library/actions/terminal/wallet/read_signatures.mjs +10 -9
  61. package/library/manifests/core.action-manifest.json +6 -2
  62. package/library/manifests/extension.action-manifest.json +4 -0
  63. package/library/manifests/mobile.action-manifest.json +12 -0
  64. package/package.json +4 -4
  65. package/site/assets/help-recipes.json +1 -0
  66. package/adapters/terminal/lib/origin.mjs +0 -30
  67. package/adapters/terminal/lib/page-script.mjs +0 -246
  68. package/adapters/terminal/lib/strict-wallet.mjs +0 -138
  69. package/dist/commands/call.js +0 -671
  70. package/dist/commands/list-executables.js +0 -245
  71. package/dist/commands/manifest.js +0 -477
  72. package/dist/commands/run-engine.js +0 -1301
  73. package/dist/commands/run.js +0 -730
  74. package/dist/discovery.js +0 -15
  75. package/dist/metamask-action-validation.js +0 -68
  76. package/dist/network-observation.js +0 -290
@@ -35,6 +35,7 @@ const CLAIM_DIFF_FIELDS = [
35
35
  "operationCounts"
36
36
  ];
37
37
  const trustedActionContexts = /* @__PURE__ */ new WeakSet();
38
+ const fixturePolicySources = /* @__PURE__ */ new WeakMap();
38
39
  const fundingSources = /* @__PURE__ */ new WeakMap();
39
40
  const CURRENT_FUNDING_REGISTRY_SHA256 = "d4416e0e8a812a0e2f1798ae6f901a5820f7ee0b9783cfa5d515d0c7863ea80e";
40
41
  const PREVIOUS_FUNDING_REGISTRY_SHA256 = "7e79ee2a5f7bdde0a5548208cb2eab55881575cc7d826ee5f7acdbf15d49609c";
@@ -141,7 +142,70 @@ async function loadTrustedMutationBase(options) {
141
142
  });
142
143
  return trusted;
143
144
  }
145
+ const FIXTURE_POLICY_LANE = "fixture-policy";
146
+ const fixturePolicyMarker = (field) => sha256(`${FIXTURE_POLICY_LANE}:${field}`);
147
+ function fixturePolicyMutationBase(options) {
148
+ if (!path.isAbsolute(options.artifactsDir)) {
149
+ throw new Error("Runner artifactsDir must be absolute before a mutation context is created.");
150
+ }
151
+ const rootRecipeSha256 = normalizeRecipeDigest(digestRecipeDocument(options.rootRecipe));
152
+ const base = deepFreeze({
153
+ runId: sha256(canonical({ lane: FIXTURE_POLICY_LANE, artifactsDir: options.artifactsDir, rootRecipeSha256 })),
154
+ rootRecipeSha256,
155
+ requestSha256: fixturePolicyMarker("request"),
156
+ preflightSha256: fixturePolicyMarker("preflight"),
157
+ releaseProofSha256: fixturePolicyMarker("release-proof"),
158
+ balanceProofSha256: fixturePolicyMarker("balance-proof"),
159
+ fundingRequirementsSha256: fixturePolicyMarker("requirements"),
160
+ runtimeArtifactSha256: fixturePolicyMarker("runtime-artifact"),
161
+ runtimeTreeSha256: fixturePolicyMarker("runtime-tree"),
162
+ registrySha256: fixturePolicyMarker("registry"),
163
+ profileId: FIXTURE_POLICY_LANE,
164
+ domain: "perps",
165
+ client: "extension",
166
+ proofLane: FIXTURE_POLICY_LANE,
167
+ targetId: FIXTURE_POLICY_LANE,
168
+ subjectDigest: fixturePolicyMarker("subject"),
169
+ selectedIdentitySha256: "",
170
+ runtimeSessionId: "",
171
+ cdpPort: 0,
172
+ cdpTargetId: "",
173
+ extensionOrigin: "",
174
+ operations: [],
175
+ budgets: [],
176
+ requirements: []
177
+ });
178
+ fixturePolicySources.set(base, { artifactsDir: options.artifactsDir });
179
+ return base;
180
+ }
181
+ function isFixturePolicyBase(base) {
182
+ return base?.proofLane === FIXTURE_POLICY_LANE;
183
+ }
184
+ async function authorizeFixturePolicyPlan(base, plan) {
185
+ const source = fixturePolicySources.get(base);
186
+ if (!source) throw new Error("Fixture-policy plan authorization requires the runner-created context.");
187
+ const executionPlanSha256 = normalizeRecipeDigest(plan.digest);
188
+ await mkdir(source.artifactsDir, { recursive: true, mode: 448 });
189
+ const artifactsDir = await realpath(source.artifactsDir);
190
+ const artifactsIdentitySha256 = await directoryIdentity(artifactsDir);
191
+ const executionLedgerRoot = path.join(artifactsDir, "mutation-ledger");
192
+ await ensurePrivateDirectory(artifactsDir, executionLedgerRoot);
193
+ const runId = sha256(canonical({
194
+ lane: FIXTURE_POLICY_LANE,
195
+ rootRecipeSha256: base.rootRecipeSha256,
196
+ executionPlanSha256,
197
+ artifactsIdentitySha256
198
+ }));
199
+ const authorized = deepFreeze({ ...base, runId, executionPlanSha256, artifactsIdentitySha256 });
200
+ fixturePolicySources.set(authorized, {
201
+ ...source,
202
+ executionLedgerRoot,
203
+ executionClaimSha256: sha256(canonical({ kind: "fixture-policy-execution-claim", runId, executionPlanSha256 }))
204
+ });
205
+ return authorized;
206
+ }
144
207
  async function authorizeTrustedMutationPlan(base, plan, testIo = {}) {
208
+ if (isFixturePolicyBase(base)) return authorizeFixturePolicyPlan(base, plan);
145
209
  const source = fundingSources.get(base);
146
210
  if (!source) throw new Error("Funding plan authorization requires the original runner-verified context.");
147
211
  const executionPlanSha256 = normalizeRecipeDigest(plan.digest);
@@ -228,6 +292,20 @@ async function authorizeTrustedMutationPlan(base, plan, testIo = {}) {
228
292
  return authorized;
229
293
  }
230
294
  async function trustedMutationForAction(base, recipe, selectedIdentitySha256) {
295
+ if (isFixturePolicyBase(base)) {
296
+ const source2 = fixturePolicySources.get(base);
297
+ if (!source2?.executionLedgerRoot || !source2.executionClaimSha256) {
298
+ throw new Error("External mutation requires an authorized run-owned execution ledger.");
299
+ }
300
+ const trusted2 = deepFreeze({
301
+ ...base,
302
+ recipeSha256: normalizeRecipeDigest(digestRecipeDocument(recipe)),
303
+ executionLedgerRoot: source2.executionLedgerRoot,
304
+ executionClaimSha256: source2.executionClaimSha256
305
+ });
306
+ trustedActionContexts.add(trusted2);
307
+ return trusted2;
308
+ }
231
309
  if (!selectedIdentitySha256 || !SHA256.test(selectedIdentitySha256)) {
232
310
  throw new Error(
233
311
  "External mutation requires a successful same-run metamask.wallet.import selected identity proof. Next: run metamask.wallet.import before the mutation node."
@@ -1442,8 +1520,11 @@ function deepFreeze(value) {
1442
1520
  return value;
1443
1521
  }
1444
1522
  export {
1523
+ FIXTURE_POLICY_LANE,
1445
1524
  authorizeTrustedMutationPlan,
1525
+ fixturePolicyMutationBase,
1446
1526
  fundingOptionsFromCli,
1527
+ isFixturePolicyBase,
1447
1528
  loadTrustedMutationBase,
1448
1529
  selectedIdentityFromWalletImport,
1449
1530
  serializeTrustedMutationContext,
@@ -4,10 +4,10 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { Command } from "commander";
7
- import { color, withCommandJournal, JsonStreamWriter, detectAdapter } from "@farmslot/recipe-cli/harness";
7
+ import { color, withCommandJournal, JsonStreamWriter, detectAdapter, handleCallHelp } from "@farmslot/recipe-cli/harness";
8
8
  import { handleUpdate, maybeNudge } from "./commands/update.js";
9
9
  import { handlePin } from "./commands/pin.js";
10
- import { handleCallHelp } from "./commands/call.js";
10
+ import { metaMaskEngine } from "./engine.js";
11
11
  import { handleHelp } from "./commands/help.js";
12
12
  import { handleTutorial } from "./commands/tutorial.js";
13
13
  import { handleSetupBase } from "./commands/setup-base.js";
@@ -1231,6 +1231,6 @@ if (hasPassthroughHelp(rawArgv)) {
1231
1231
  }
1232
1232
  if (isCallActionHelp(rawArgv)) {
1233
1233
  const callHelp = REAL.find((command) => command.name === "call")?.helpText ?? "";
1234
- process.exit(await handleCallHelp(rawArgv.slice(1), callHelp));
1234
+ process.exit(await handleCallHelp(rawArgv.slice(1), callHelp, { catalog: metaMaskEngine }));
1235
1235
  }
1236
1236
  await program.parseAsync(process.argv);
@@ -15,9 +15,7 @@ const sessions = /* @__PURE__ */ new Map();
15
15
  const DEFAULT_MAX_DURATION_MS = 5 * 60 * 1e3;
16
16
  const MAX_DURATION_MS = 60 * 60 * 1e3;
17
17
  const gzipAsync = promisify(gzip);
18
- function startRunPerformanceObservation(adapter, target, artifactsDir, env, ports = {}) {
19
- const observer = getAdapterSurface(adapter).observation?.performance;
20
- if (!observer) return void 0;
18
+ function startRunPerformanceObservation(observer, { target, artifactsDir, env, ports }) {
21
19
  const key = path.resolve(artifactsDir);
22
20
  const session = {
23
21
  observer,
@@ -1,11 +1,10 @@
1
1
  import fs from "node:fs";
2
2
  import { createHash } from "node:crypto";
3
3
  import { loadRecipeLibraries, resolveRecipeDependencies, rootResolutionRef } from "@farmslot/recipe-runner";
4
- import { isSensitiveKey, CliError, isRecord, optionStrings, EXIT } from "@farmslot/recipe-cli/harness";
4
+ import { isSensitiveKey, CliError, describeManifestActions, isRecord, optionStrings, EXIT, resolveLibrarySources, validateRunRecipeStatic } from "@farmslot/recipe-cli/harness";
5
5
  import { resolveActionManifest } from "./manifest.js";
6
- import { describeManifestActions } from "./commands/manifest.js";
7
6
  import { recipeComposition } from "@farmslot/recipe-cli";
8
- import { resolveMetaMaskLibrarySources, validateRunRecipeStatic } from "./commands/run-engine.js";
7
+ import { metaMaskEngine } from "./engine.js";
9
8
  import { MAX_ADVICE_STATE_BYTES } from "./recipe-assistance-rubric.js";
10
9
  function digest(value) {
11
10
  return createHash("sha256").update(value).digest("hex");
@@ -46,7 +45,7 @@ async function buildAdviceContext(input) {
46
45
  const task = readInput(input.task);
47
46
  if (!input.recipe) throw new CliError("Advice requires a recipe. Next: use recipe-quality plan before authoring.", EXIT.usage);
48
47
  readInput(input.recipe);
49
- const validated = await validateRunRecipeStatic(input.recipe, input.adapter, input.options ?? {}, input.params);
48
+ const validated = await validateRunRecipeStatic(metaMaskEngine, input.recipe, input.adapter, input.options ?? {}, input.params);
50
49
  if (validated.usageError || !validated.schemaValid || !isRecord(validated.recipe)) {
51
50
  const detail = validated.usageError?.message ?? validated.findings.map((finding) => finding.message).join("; ");
52
51
  throw new CliError(`Recipe is not statically valid: ${detail}. Next: run mm-harness run ${input.recipe} --plan --adapter ${input.adapter}.`, EXIT.validation);
@@ -56,7 +55,7 @@ async function buildAdviceContext(input) {
56
55
  const dependencies = resolveRecipeDependencies({ rootRef: rootResolutionRef(rootDigest), root: validated.recipe, rootSource: { kind: "recipe-file", trust: "unknown", digest: rootDigest }, recipes: library.recipes });
57
56
  const composition = recipeComposition(validated.recipe, dependencies.recipes);
58
57
  const { manifest, actionSources } = await resolveActionManifest(input.adapter, void 0, validated.librarySources);
59
- const catalog = describeManifestActions(manifest, actionSources);
58
+ const catalog = describeManifestActions(metaMaskEngine, manifest, actionSources);
60
59
  const used = new Set(composition.actions);
61
60
  const actions = catalog.filter((action) => used.has(action.name)).map(({ name, description, schema, capabilities }) => ({ name, description, schema, capabilities }));
62
61
  const domains = new Set(composition.actions.map((name) => name.split(".").slice(0, -1).join(".")));
@@ -108,12 +107,12 @@ async function buildPlanningContext(input) {
108
107
  throw new CliError("Planning requires 1\u201320 non-empty acceptanceCriteria strings. Next: supply a focused JSON task.", EXIT.validation);
109
108
  }
110
109
  const criteria = task.acceptanceCriteria;
111
- const sources = await resolveMetaMaskLibrarySources(optionStrings(input.options ?? {}, "library"));
110
+ const sources = await resolveLibrarySources(metaMaskEngine, optionStrings(input.options ?? {}, "library"));
112
111
  const library = await loadRecipeLibraries(sources ?? [], { adapter: input.adapter });
113
112
  const { manifest, actionSources } = await resolveActionManifest(input.adapter, void 0, sources);
114
113
  const candidates = [
115
114
  ...[...library.recipes.values()].map((recipe) => ({ id: `recipe:${recipe.ref}`, kind: "recipe", ref: recipe.ref, description: typeof recipe.document.description === "string" ? recipe.document.description : "", parameters: recipe.document.paramsSchema, source: recipe.source, provenance: recipe.provenance, shadows: recipe.shadows })),
116
- ...describeManifestActions(manifest, actionSources).map((action) => ({ id: `action:${action.name}`, kind: "action", ref: action.name, description: action.description, parameters: action.schema, capabilities: action.capabilities, source: action.source, sourceTier: action.sourceTier, shadows: action.shadows }))
115
+ ...describeManifestActions(metaMaskEngine, manifest, actionSources).map((action) => ({ id: `action:${action.name}`, kind: "action", ref: action.name, description: action.description, parameters: action.schema, capabilities: action.capabilities, source: action.source, sourceTier: action.sourceTier, shadows: action.shadows }))
117
116
  ];
118
117
  const terms = new Set(criteria.join(" ").toLowerCase().match(/[a-z][a-z_]{3,}/gu) ?? []);
119
118
  const score = (candidate) => [...new Set(`${candidate.ref} ${candidate.description}`.toLowerCase().match(/[a-z][a-z_]{3,}/gu) ?? [])].filter((word) => terms.has(word)).length;
package/dist/runner.js CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  runnerDir
24
24
  } from "./paths.js";
25
25
  import {
26
+ isFixturePolicyBase,
26
27
  selectedIdentityFromWalletImport,
27
28
  trustedMutationForAction
28
29
  } from "./funding-execution-context.js";
@@ -166,7 +167,7 @@ function withTrustedMutationContext(adapters, base, externalMutationActions) {
166
167
  throw error;
167
168
  });
168
169
  if (invalidatesIdentity) selectedIdentitySha256 = void 0;
169
- const selected = selectedIdentityFromWalletImport(entry.action, result, base);
170
+ const selected = isFixturePolicyBase(base) ? void 0 : selectedIdentityFromWalletImport(entry.action, result, base);
170
171
  if (selected) {
171
172
  if (base && base.selectedIdentitySha256 !== selected) {
172
173
  throw new Error("Same-run wallet identity changed after its trusted import proof.");
@@ -79,8 +79,10 @@ context, prepare) and `readiness.ts` (doctor and status). Do not add
79
79
  command-local platform ladders for surface-owned behavior:
80
80
  - `launch` dispatches through `AdapterSurface.launch` (the device platforms
81
81
  share `src/commands/launch/device.ts` through a `DeviceLaunchProfile`);
82
- - `run`/`call` read `run`, `recording`, `diagnostics`, `observation` and
83
- `sourceFingerprint`;
82
+ - `run`/`call` come from `@farmslot/recipe-cli/harness` and read `run`,
83
+ `recording`, `diagnostics`, `observation` and `sourceFingerprint`;
84
+ mm-harness hands them its engine (`src/engine.ts`) and its device targeting
85
+ and plan steps (`src/cli.ts`);
84
86
  - the runner reads `actions` (manifest, bundled semantic actions, live adapter
85
87
  scripts, `cdp.target`, the `ui.*` transport, adapter wrappers, input checks);
86
88
  - `install`/`verify`/`cleanup` read `harness`, `agentic-runtime.json` reads
package/docs/RECIPES.md CHANGED
@@ -245,9 +245,37 @@ Use the snapshot recipe's `history` object to bound existing record dates and
245
245
  `capture_path` to separate evidence when composing calls. Market sampling does
246
246
  not repeatedly fetch history.
247
247
 
248
- The two live recipes require explicit `confirm_live=true`. The trusted runner
249
- must also verify the exact consented funding request, preflight, release proof,
250
- and contained balance proof. Market placement declares the exact provider and
248
+ Perps orders follow one rule on Mobile, Extension and Core
249
+ (`library/actions/shared/perps/order-policy.mjs`), checked before dispatch:
250
+
251
+ - Testnet: allowed with no funding proof.
252
+ - Mainnet: only from a synced fixture account (`wallet-fixture.json`), as a 1x
253
+ isolated market order with `close_immediately: true`, and the node must
254
+ declare `network: mainnet`. The $15 limit bounds the size the controller would
255
+ submit: (amount + one size step × price) × (1 + max slippage). Anything else
256
+ is refused with `PERPS_MAINNET_ORDER_REFUSED`, naming each broken limit, and
257
+ no order is sent.
258
+ - `close_immediately: true` needs a market order from a flat market: no
259
+ position and no resting order in it. Positions are netted per market, so the
260
+ close takes the market's whole position; don't share a fixture account's
261
+ market with a concurrent run. The action closes the opened position itself
262
+ and proves the market flat. On mainnet it also checks the position opened
263
+ isolated 1x and within $15. A placement that fails after dispatch has its
264
+ position closed too.
265
+ - The account and network the rule allowed are rechecked around the flat-market
266
+ reads, before dispatch and before any close; if either changed, nothing is
267
+ sent or closed.
268
+ - Core still needs `CORE_PERPS_ALLOW_MAINNET_WRITES=1` for any mainnet write.
269
+
270
+ On the Extension, a run without funding files gets that rule through the
271
+ fixture-policy lane: the runner binds the run and a run-owned execution ledger
272
+ (`<artifacts>/mutation-ledger`), and each mutation completes the context from
273
+ the live app, so receipts and resume work as in a funded run. The funding files
274
+ below remain the stricter release lane.
275
+
276
+ The two live recipes require explicit `confirm_live=true`. A funded run's
277
+ trusted runner must also verify the exact consented funding request, preflight,
278
+ release proof, and contained balance proof. Market placement declares the exact provider and
251
279
  network; background placement and cancellation refuse a mismatch without
252
280
  toggling it. Immediately before publication and dispatch, the action also reads
253
281
  the current visible Perps Home balance and requires the runner-trusted minimum.
@@ -6,7 +6,8 @@ import path from 'node:path';
6
6
  import { pathToFileURL } from 'node:url';
7
7
 
8
8
  import { walletFixturePath } from '../../harness-exports.mjs';
9
- import { readFixtureAccounts } from '../wallet/_fixture-accounts.mjs';
9
+ import { readFixtureAccounts } from '../../shared/wallet/fixture-accounts.mjs';
10
+ import { createRewardsClient, rewardsApiUrl } from './_rewards.mjs';
10
11
 
11
12
  function controllerEntry(projectRoot) {
12
13
  return pathToFileURL(path.join(projectRoot, 'packages/perps-controller/src/index.ts')).href;
@@ -70,7 +71,7 @@ function buildInfrastructure(stubbed, input, rewardsSource) {
70
71
  ...(terminalApi ? { terminalApi } : {}),
71
72
  rewards: {
72
73
  getPerpsDiscountForAccount: (caipAccountId, baseFeeBips) =>
73
- rewardsDiscount(rewardsSource.messenger, caipAccountId, baseFeeBips),
74
+ rewardsSource.messenger.call(REWARDS_DISCOUNT_ACTION, caipAccountId, baseFeeBips),
74
75
  },
75
76
  };
76
77
  }
@@ -79,28 +80,19 @@ function buildInfrastructure(stubbed, input, rewardsSource) {
79
80
  //
80
81
  // The clients answer the rewards dependency through the messenger's
81
82
  // 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".
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.
86
88
  //
87
89
  // The controller asks for the discount with the account's CAIP id, built from
88
90
  // 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.
91
+ // getNetworkClientById), so the wallet also answers those with the network a
92
+ // fresh client wallet starts on, Ethereum mainnet.
92
93
 
93
94
  const REWARDS_DISCOUNT_ACTION = 'RewardsController:getPerpsDiscountForAccount';
94
95
 
95
- async function rewardsDiscount(rootMessenger, caipAccountId, baseFeeBips) {
96
- if (!rootMessenger) return null;
97
- try {
98
- return await rootMessenger.call(REWARDS_DISCOUNT_ACTION, caipAccountId, baseFeeBips);
99
- } catch (error) {
100
- if (String(error?.message ?? '').includes(`${REWARDS_DISCOUNT_ACTION} has not been registered`)) return null;
101
- throw error;
102
- }
103
- }
104
96
 
105
97
  /**
106
98
  * The rewards discount a node supplies, in basis points (0-10000), or undefined.
@@ -119,9 +111,13 @@ export function requestedRewardsDiscountBips(input) {
119
111
 
120
112
  const REWARDS_NETWORK_ACTIONS = ['NetworkController:getState', 'NetworkController:getNetworkClientById'];
121
113
 
122
- function registerRewardsHandler(rootMessenger, childMessenger, discountBips) {
123
- if (discountBips === undefined) return;
124
- rootMessenger.registerActionHandler(REWARDS_DISCOUNT_ACTION, async () => discountBips);
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
+ );
125
121
  rootMessenger.registerActionHandler('NetworkController:getState', () => ({
126
122
  selectedNetworkClientId: 'mainnet',
127
123
  }));
@@ -228,11 +224,6 @@ async function resolveSignerFromFixture(input) {
228
224
  return { account: selected.signer, address: selected.address };
229
225
  }
230
226
 
231
- /** The address a read acts on (no signing required). */
232
- async function requireAccountAddress(input) {
233
- return (await selectAccount(input)).address;
234
- }
235
-
236
227
  let cached = null;
237
228
 
238
229
  /**
@@ -286,7 +277,8 @@ export function requireRuntimeExport(moduleNamespace, exportName, sourceLabel) {
286
277
  export async function getCoreController(input) {
287
278
  const projectRoot = input.context?.projectRoot;
288
279
  if (!projectRoot) throw new Error('core adapter requires context.projectRoot.');
289
- const accountAddress = await requireAccountAddress(input);
280
+ const selected = await selectAccount(input);
281
+ const accountAddress = selected.address;
290
282
  const network = resolveNetwork(input);
291
283
  const terminalApi = resolveTerminalApi(input);
292
284
  const terminalApiKey = JSON.stringify(terminalApi ?? null);
@@ -341,7 +333,10 @@ export async function getCoreController(input) {
341
333
  parent: rootMessenger,
342
334
  });
343
335
  registerSelectedAccountHandler(rootMessenger, messenger, accountAddress);
344
- registerRewardsHandler(rootMessenger, messenger, rewardsDiscountBips);
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);
345
340
  rewardsSource.messenger = rootMessenger;
346
341
 
347
342
  // Default testnet. Mainnet only on explicit node.network: "mainnet" — mainnet
@@ -386,6 +381,7 @@ export async function getCoreController(input) {
386
381
  accountAddress,
387
382
  terminalApiKey,
388
383
  rewardsDiscountBips,
384
+ rewardsClient,
389
385
  };
390
386
  return { ...cached, accountAddress };
391
387
  }
@@ -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 (;;) {