@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.
- package/CHANGELOG.md +26 -0
- package/adapters/manifest.json +14 -30
- package/adapters/terminal/launch.mjs +17 -13
- package/adapters/terminal/lib/mainnet-guard.mjs +9 -0
- package/adapters/terminal/lib/readiness.mjs +20 -12
- package/adapters/terminal/policy.mjs +35 -0
- package/adapters/terminal/verify.mjs +10 -3
- package/adapters/terminal/wallet-host.mjs +43 -225
- package/dist/adapters/core/actions.js +4 -1
- package/dist/adapters/extension/actions.js +6 -2
- package/dist/adapters/extension/network-observer.js +13 -0
- package/dist/adapters/extension/run.js +112 -5
- package/dist/adapters/mobile/actions.js +4 -1
- package/dist/adapters/mobile/network-observer.js +68 -0
- package/dist/adapters/mobile/performance-observer.js +1 -3
- package/dist/adapters/mobile/run.js +32 -9
- package/dist/adapters/perps-order-inputs.js +36 -0
- package/dist/adapters/proof-failure.js +11 -0
- package/dist/adapters/terminal/actions.js +4 -1
- package/dist/adapters/terminal/doctor-checks.js +2 -0
- package/dist/adapters/terminal/launch.js +3 -0
- package/dist/adapters/terminal/overlay.js +8 -1
- package/dist/adapters/terminal/run.js +8 -3
- package/dist/adapters/terminal/surface.js +2 -0
- package/dist/adapters.js +20 -4
- package/dist/cli.js +31 -6
- package/dist/commands/check.js +30 -6
- package/dist/commands/completion-candidates.js +5 -5
- package/dist/commands/self-test.js +4 -3
- package/dist/engine.js +57 -0
- package/dist/funding-execution-context.js +81 -0
- package/dist/mm-harness-cli.js +3 -3
- package/dist/performance-observation.js +1 -3
- package/dist/recipe-assistance-context.js +6 -7
- package/dist/runner.js +2 -1
- package/docs/CONTRIBUTING.md +4 -2
- package/docs/RECIPES.md +31 -3
- package/library/actions/core/perps/_controller.mjs +24 -28
- package/library/actions/core/perps/_rewards.mjs +186 -0
- package/library/actions/core/perps/close_positions.mjs +1 -1
- package/library/actions/core/perps/place_order.mjs +167 -45
- package/library/actions/core/perps/quote_fees.mjs +7 -4
- package/library/actions/core/wallet/list_accounts.mjs +1 -1
- package/library/actions/extension/perps/close_visible_position.mjs +5 -0
- package/library/actions/extension/perps/edit_margin.mjs +4 -0
- package/library/actions/extension/perps/mutation-receipt.mjs +95 -3
- package/library/actions/extension/perps/perps.mjs +320 -3
- package/library/actions/extension/perps/place_order.mjs +3 -1
- package/library/actions/extension/perps/update_position_tpsl.mjs +4 -0
- package/library/actions/extension/platform/cdp.mjs +71 -3
- package/library/actions/mobile/perps/perps.mjs +216 -11
- package/library/actions/mobile/perps/place_order.mjs +3 -1
- package/library/actions/shared/perps/order-policy.mjs +177 -0
- package/library/actions/{core/wallet/_fixture-accounts.mjs → shared/wallet/fixture-accounts.mjs} +18 -3
- package/library/actions/terminal/app/launch.mjs +3 -2
- package/library/actions/terminal/platform/hud.mjs +2 -1
- package/library/actions/terminal/platform/page.mjs +2 -1
- package/library/actions/terminal/wallet/_signature-log.mjs +40 -161
- package/library/actions/terminal/wallet/assert_signatures.mjs +6 -12
- package/library/actions/terminal/wallet/read_signatures.mjs +10 -9
- package/library/manifests/core.action-manifest.json +6 -2
- package/library/manifests/extension.action-manifest.json +4 -0
- package/library/manifests/mobile.action-manifest.json +12 -0
- package/package.json +4 -4
- package/site/assets/help-recipes.json +1 -0
- package/adapters/terminal/lib/origin.mjs +0 -30
- package/adapters/terminal/lib/page-script.mjs +0 -246
- package/adapters/terminal/lib/strict-wallet.mjs +0 -138
- package/dist/commands/call.js +0 -671
- package/dist/commands/list-executables.js +0 -245
- package/dist/commands/manifest.js +0 -477
- package/dist/commands/run-engine.js +0 -1301
- package/dist/commands/run.js +0 -730
- package/dist/discovery.js +0 -15
- package/dist/metamask-action-validation.js +0 -68
- 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,
|
package/dist/mm-harness-cli.js
CHANGED
|
@@ -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 {
|
|
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(
|
|
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 {
|
|
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
|
|
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.");
|
package/docs/CONTRIBUTING.md
CHANGED
|
@@ -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`
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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 '
|
|
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
|
-
|
|
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
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
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)
|
|
90
|
-
// fresh client wallet starts on, Ethereum mainnet
|
|
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
|
-
|
|
124
|
-
|
|
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
|
|
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
|
-
|
|
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 (;;) {
|