@deeeed/metamask-harness 0.19.0 → 0.20.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 (43) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/adapters/extension/ensure-browser.sh +6 -4
  3. package/adapters/extension/launch-browser.cjs +6 -1
  4. package/adapters/extension/lib/chrome-args.cjs +11 -1
  5. package/adapters/extension/start-watch.sh +1 -1
  6. package/adapters/extension/verify.sh +17 -0
  7. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +33 -0
  8. package/adapters/mobile/open-device.sh +12 -1
  9. package/adapters/mobile/wait-for-bridge.sh +1 -1
  10. package/dist/adapters/extension/ensure-ready.js +7 -2
  11. package/dist/adapters/extension/runtime-decision.js +4 -6
  12. package/dist/adapters/extension/runtime.js +114 -17
  13. package/dist/adapters/mobile/prepare.js +40 -6
  14. package/dist/adapters.js +6 -1
  15. package/dist/command-contract.js +58 -5
  16. package/dist/commands/call.js +6 -3
  17. package/dist/commands/check.js +9 -2
  18. package/dist/commands/checklist.js +117 -14
  19. package/dist/commands/launch/extension.js +8 -3
  20. package/dist/commands/launch/index.js +38 -25
  21. package/dist/commands/launch/mobile.js +2 -2
  22. package/dist/commands/manifest.js +72 -5
  23. package/dist/commands/run-engine.js +7 -2
  24. package/dist/heal-bounds.js +1 -1
  25. package/dist/live-adapter-contract.js +15 -2
  26. package/dist/metamask-action-validation.js +45 -0
  27. package/dist/mm-harness-cli.js +7 -4
  28. package/docs/CONTRIBUTING.md +8 -0
  29. package/docs/RECIPES.md +15 -0
  30. package/library/actions/core/perps/assert_orders.mjs +16 -5
  31. package/library/actions/core/perps/assert_positions.mjs +16 -5
  32. package/library/actions/extension/perps/perps.mjs +111 -19
  33. package/library/actions/extension/platform/cdp.mjs +8 -3
  34. package/library/actions/extension/ui/navigate.mjs +239 -16
  35. package/library/actions/mobile/perps/perps.mjs +108 -10
  36. package/library/actions/mobile/ui/navigate.mjs +1 -1
  37. package/library/manifests/core.action-manifest.json +100 -11
  38. package/library/manifests/extension.action-manifest.json +80 -12
  39. package/library/manifests/mobile.action-manifest.json +85 -10
  40. package/library/recipes/perps/lifecycle.recipe.json +3 -9
  41. package/library/recipes/runner/action-validation.extension.recipe.json +5 -4
  42. package/package.json +5 -4
  43. package/scripts/completions.sh +2 -2
@@ -312,6 +312,7 @@ async function runLiveAdapterScript({ platform, action, node, context, prepared
312
312
  `);
313
313
  const command = prepared ? commandForPrepared(context.projectRoot, platform) : commandFor(script, context.projectRoot, platform);
314
314
  const platformEnv = await platformAdapterEnv(platform, context.projectRoot, tempDir);
315
+ const processTimeoutMs = liveAdapterProcessTimeoutMs(node);
315
316
  const result = await runProcess(command.command, [...command.args, inputPath], {
316
317
  cwd: context.projectRoot,
317
318
  env: {
@@ -321,12 +322,12 @@ async function runLiveAdapterScript({ platform, action, node, context, prepared
321
322
  METAMASK_RECIPE_ADAPTER_INPUT: inputPath,
322
323
  METAMASK_RECIPE_ADAPTER_OUTPUT: outputPath
323
324
  },
324
- timeoutMs: Number(node.live_adapter_timeout_ms ?? node.timeout_ms ?? 6e4),
325
+ timeoutMs: processTimeoutMs,
325
326
  ...prepared ? { stdin: prepared.sourceText } : {}
326
327
  });
327
328
  try {
328
329
  if (result.timedOut) {
329
- throw new Error(`Live adapter ${script} timed out after ${Number(node.live_adapter_timeout_ms ?? node.timeout_ms ?? 6e4)}ms.`);
330
+ throw new Error(`Live adapter ${script} timed out after ${processTimeoutMs}ms.`);
330
331
  }
331
332
  if (result.exitCode !== 0) {
332
333
  throw new Error(`Live adapter ${script} exited ${result.exitCode}: ${result.stderr || result.stdout}`);
@@ -344,6 +345,17 @@ async function runLiveAdapterScript({ platform, action, node, context, prepared
344
345
  await rm(tempDir, { recursive: true, force: true });
345
346
  }
346
347
  }
348
+ function liveAdapterProcessTimeoutMs(node) {
349
+ if (node.live_adapter_timeout_ms != null) {
350
+ const timeoutMs = Number(node.live_adapter_timeout_ms);
351
+ return Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 6e4;
352
+ }
353
+ if (node.timeout_ms != null) {
354
+ const timeoutMs = Number(node.timeout_ms);
355
+ return Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs + 5e3 : 6e4;
356
+ }
357
+ return 6e4;
358
+ }
347
359
  function commandForPrepared(projectRoot, platform) {
348
360
  if (platform !== "core") {
349
361
  return { command: process.execPath, args: ["--input-type=module", "-"] };
@@ -364,6 +376,7 @@ function isPathWithin(root, candidate) {
364
376
  return relative === "" || relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
365
377
  }
366
378
  export {
379
+ liveAdapterProcessTimeoutMs,
367
380
  prepareLiveAdapterScript,
368
381
  resolveLiveAdapter,
369
382
  runLiveAdapterScript
@@ -0,0 +1,45 @@
1
+ function isRecord(value) {
2
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
3
+ }
4
+ function validateMetaMaskActionInputs(recipe) {
5
+ if (!isRecord(recipe) || !isRecord(recipe.workflow)) return [];
6
+ const nodes = isRecord(recipe.workflow.nodes) ? recipe.workflow.nodes : void 0;
7
+ if (!nodes) return [];
8
+ const findings = [];
9
+ for (const [nodeId, node] of Object.entries(nodes)) {
10
+ if (!isRecord(node)) continue;
11
+ const action = node.action;
12
+ const state = typeof node.state === "string" ? node.state.toLowerCase() : void 0;
13
+ const createsOrder = action === "metamask.perps.place_order" || (action === "metamask.perps.ensure_positions" || action === "metamask.perps.ensure_orders") && (state === "open" || state === "present");
14
+ if (!createsOrder) continue;
15
+ const requireOneOf = (names, message) => {
16
+ const present = names.some((name) => {
17
+ const value = node[name];
18
+ return value !== void 0 && value !== null && value !== "";
19
+ });
20
+ if (present) return;
21
+ findings.push({
22
+ severity: "error",
23
+ code: "recipe.missing_param",
24
+ path: `workflow.nodes.${nodeId}.${names[0]}`,
25
+ message
26
+ });
27
+ };
28
+ requireOneOf(
29
+ ["market", "symbol"],
30
+ `${action} requires market=<symbol> (or symbol=<symbol>) before it may create an order`
31
+ );
32
+ requireOneOf(
33
+ ["side"],
34
+ `${action} requires side=long or side=short before it may create an order`
35
+ );
36
+ requireOneOf(
37
+ ["amount", "notional"],
38
+ `${action} requires amount=<usd> or notional=<usd> before it may create an order`
39
+ );
40
+ }
41
+ return findings;
42
+ }
43
+ export {
44
+ validateMetaMaskActionInputs
45
+ };
@@ -60,11 +60,13 @@ Example:
60
60
  },
61
61
  {
62
62
  name: "checklist",
63
- summary: "Mark task-local checklist progress through the bundled agent runtime.",
63
+ summary: "Mark checklist progress and close out scrubbed learning packages.",
64
64
  example: "mm-harness checklist mark <task-dir> start",
65
- helpText: `mm-harness checklist mark <task-dir> <step> [options]
65
+ helpText: `mm-harness checklist <mark|closeout> <task-dir> [step] [options]
66
66
 
67
- Mark checklist progress through mm-harness's bundled @farmslot/agent-runtime.
67
+ mark delegates checklist state to @farmslot/agent-runtime. Successful complete
68
+ and no-change marks stage a learning package through @farmslot/handoff.
69
+ closeout exposes that same Handoff boundary directly; --share remains explicit.
68
70
  The task directory must contain CHECKLIST.md and checklist-target.json.
69
71
 
70
72
  Steps: start | 1 | 2 | ... | complete | no-change | blocked
@@ -72,7 +74,8 @@ Example:
72
74
  Example:
73
75
  mm-harness checklist mark temp/tasks/recipe-cook/<task> start
74
76
  mm-harness checklist mark temp/tasks/recipe-cook/<task> 1
75
- mm-harness checklist mark temp/tasks/recipe-cook/<task> complete --mark-last`
77
+ mm-harness checklist mark temp/tasks/recipe-cook/<task> complete --mark-last
78
+ mm-harness checklist closeout temp/tasks/recipe-cook/<task> --share`
76
79
  },
77
80
  {
78
81
  name: "actions",
@@ -8,6 +8,8 @@ both the machine contract and the visible human result.
8
8
  ```text
9
9
  bin/mm-harness
10
10
  -> src/ typed CLI and product decisions
11
+ -> @farmslot/agent-runtime checklist state
12
+ -> @farmslot/handoff scrubbed learning capture/share
11
13
  -> @farmslot/recipe-harness generic execution, UI transports, evidence
12
14
  -> @farmslot/protocol schemas
13
15
  -> adapters/ focused host/browser/device leaves
@@ -18,6 +20,8 @@ bin/mm-harness
18
20
  | Layer | Owns |
19
21
  |---|---|
20
22
  | `@farmslot/protocol` | recipe and evidence schemas |
23
+ | `@farmslot/agent-runtime` | task-local checklist state and terminal contracts |
24
+ | `@farmslot/handoff` | learning assembly, scrubbing, validation, approval, and publication |
21
25
  | `@farmslot/recipe-harness` | generic execution, recovery, traces, artifacts, `ui.*` |
22
26
  | `mm-harness` | MetaMask runtime control, diagnostics, durable domain actions |
23
27
  | skills/checklists | task workflow and proof expectations |
@@ -27,6 +31,10 @@ Generic bootstrap, trust, receipts, and recovery belong upstream. MetaMask
27
31
  platform behavior and risk classification belong here. Ticket assertions stay
28
32
  task-local.
29
33
 
34
+ `mm-harness checklist` is only the MetaMask-facing dispatch layer. Terminal
35
+ marks stage learnings through Handoff; sharing always requires a separate
36
+ explicit `--share` call.
37
+
30
38
  ## Repository map
31
39
 
32
40
  | Path | Responsibility |
package/docs/RECIPES.md CHANGED
@@ -67,6 +67,21 @@ Rules:
67
67
  - Parameterize repeated behavior instead of multiplying names.
68
68
  - Keep secrets out of recipes, libraries, and evidence.
69
69
 
70
+ Keep responsibilities narrow:
71
+
72
+ - **Parameters** expose caller choices that may vary without changing the
73
+ journey's meaning.
74
+ - **Recipes** compose actions and own journey order, safe defaults, invariants,
75
+ and proof.
76
+ - **Actions** translate one stable operation to existing UI, CDP, or controller
77
+ capabilities; they do not reimplement product business logic.
78
+ - **Product/controllers** remain the source of truth for state transitions,
79
+ validation, transactions, and domain behavior.
80
+
81
+ If changing a value would violate the recipe's postcondition, keep it as a
82
+ documented recipe invariant rather than a parameter. Add a shared action only
83
+ when repeated direct access has a stable cross-task contract.
84
+
70
85
  The protocol is authoritative:
71
86
  <https://farmslot.io/docs/reference/recipe-protocol-v1>.
72
87
 
@@ -23,11 +23,22 @@ export function expectedOpen(input) {
23
23
  export async function assertOrders(input, expectOpen = expectedOpen(input)) {
24
24
  requireExplicitSelection(input);
25
25
  const { controller, accountAddress, network } = await getCoreController(input);
26
- const orders = await controller.getOpenOrders({
27
- standalone: true,
28
- userAddress: accountAddress,
29
- });
30
- const matching = selectedItems(input, orders);
26
+ const timeoutMs = Number(input.node?.timeout_ms ?? 0);
27
+ const deadline = Date.now() + Math.max(0, timeoutMs);
28
+ let orders;
29
+ let matching;
30
+ while (true) {
31
+ orders = await controller.getOpenOrders({
32
+ standalone: true,
33
+ userAddress: accountAddress,
34
+ });
35
+ matching = selectedItems(input, orders);
36
+ if (expectOpen ? matching.length > 0 : matching.length === 0) break;
37
+ if (Date.now() >= deadline) break;
38
+ await new Promise((resolve) =>
39
+ setTimeout(resolve, Math.min(500, Math.max(1, deadline - Date.now()))),
40
+ );
41
+ }
31
42
  const hasOrder = matching.length > 0;
32
43
 
33
44
  if (expectOpen && !hasOrder) {
@@ -22,11 +22,22 @@ export function expectedOpen(input) {
22
22
  export async function assertPositions(input, expectOpen = expectedOpen(input)) {
23
23
  requireExplicitSelection(input);
24
24
  const { controller, accountAddress, network } = await getCoreController(input);
25
- const positions = await controller.getPositions({
26
- standalone: true,
27
- userAddress: accountAddress,
28
- });
29
- const matching = selectedItems(input, positions);
25
+ const timeoutMs = Number(input.node?.timeout_ms ?? 0);
26
+ const deadline = Date.now() + Math.max(0, timeoutMs);
27
+ let positions;
28
+ let matching;
29
+ while (true) {
30
+ positions = await controller.getPositions({
31
+ standalone: true,
32
+ userAddress: accountAddress,
33
+ });
34
+ matching = selectedItems(input, positions);
35
+ if (expectOpen ? matching.length > 0 : matching.length === 0) break;
36
+ if (Date.now() >= deadline) break;
37
+ await new Promise((resolve) =>
38
+ setTimeout(resolve, Math.min(500, Math.max(1, deadline - Date.now()))),
39
+ );
40
+ }
30
41
  const hasPosition = matching.length > 0;
31
42
 
32
43
  if (expectOpen && !hasPosition) {
@@ -10,7 +10,10 @@ function symbolForItem(item) {
10
10
  }
11
11
 
12
12
  function sideForItem(item) {
13
- return String(item?.side ?? item?.direction ?? '').toLowerCase();
13
+ const side = String(item?.side ?? item?.direction ?? '').toLowerCase();
14
+ if (side === 'buy' || side === 'b') return 'long';
15
+ if (side === 'sell' || side === 'a' || side === 's') return 'short';
16
+ return side;
14
17
  }
15
18
 
16
19
  function uniqueSymbols(symbols) {
@@ -57,6 +60,35 @@ function requirePlaceOrderInputs(input) {
57
60
  if (node.amount == null && node.notional == null) throw new Error(`${input.action} requires amount=<usd> or notional=<usd>.`);
58
61
  }
59
62
 
63
+ function resolveOrderType(input) {
64
+ const value = String(input.node?.order_type ?? input.node?.orderType ?? 'market').toLowerCase();
65
+ if (value !== 'market' && value !== 'limit') {
66
+ throw new Error(`${input.action} received unsupported order_type: ${value}.`);
67
+ }
68
+ return value;
69
+ }
70
+
71
+ function resolveLimitPrice(input, isBuy, currentPrice) {
72
+ const explicit = input.node?.limit_price ?? input.node?.limitPrice ?? input.node?.price;
73
+ if (explicit != null && String(explicit).length > 0) {
74
+ const value = Number(explicit);
75
+ if (!Number.isFinite(value) || value <= 0) {
76
+ throw new Error(`${input.action} received invalid limit_price: ${explicit}.`);
77
+ }
78
+ return value;
79
+ }
80
+ const rawOffset = input.node?.offset_pct ?? input.node?.offsetPct;
81
+ const offset = rawOffset == null ? (isBuy ? -30 : 30) : Number(rawOffset);
82
+ if (!Number.isFinite(offset)) {
83
+ throw new Error(`${input.action} received invalid offset_pct: ${rawOffset}.`);
84
+ }
85
+ const value = currentPrice * (1 + offset / 100);
86
+ if (!Number.isFinite(value) || value <= 0) {
87
+ throw new Error(`${input.action} computed a non-positive limit price (${value}).`);
88
+ }
89
+ return value;
90
+ }
91
+
60
92
  export function positionSelectionState(input, positions) {
61
93
  return { hasMatching: selectedItems(input, positions).length > 0 };
62
94
  }
@@ -97,9 +129,9 @@ export async function readPositions(input) {
97
129
 
98
130
  export async function readOrders(input) {
99
131
  return withExtensionPage(input, async (page) => {
100
- const orders = await readOpenOrders(page);
101
- const matching = selectedItems(input, orders);
102
- return { action: input.action, source: 'perps-stream-manager-cache', count: orders.length, matchingCount: matching.length, orders: matching.map(redactOrder) };
132
+ const state = await readOpenOrdersState(page);
133
+ const matching = selectedItems(input, state.orders);
134
+ return { action: input.action, source: state.source, count: state.orders.length, matchingCount: matching.length, orders: matching.map(redactOrder) };
103
135
  });
104
136
  }
105
137
 
@@ -107,11 +139,7 @@ export async function assertPositions(input, expectedOpen) {
107
139
  requireExplicitSelection(input);
108
140
  return withExtensionPage(input, async (page) => {
109
141
  const timeoutMs = Number(input.node?.timeout_ms ?? 0);
110
- const requested = configuredSymbols(input, []);
111
- if (expectedOpen && timeoutMs > 0 && requested.length === 1) {
112
- await waitForPositionPresent(page, requested[0], timeoutMs);
113
- }
114
- const state = await page.readPositions();
142
+ const state = await waitForSelectedPositionState(page, input, expectedOpen, timeoutMs);
115
143
  if (!state.available) throw new Error('stateHooks.submitRequestToBackground is unavailable; cannot assert live Perps positions.');
116
144
  const matching = selectedItems(input, state.positions);
117
145
  const hasPosition = matching.length > 0;
@@ -124,12 +152,13 @@ export async function assertPositions(input, expectedOpen) {
124
152
  export async function assertOrders(input, expectedOpen) {
125
153
  requireExplicitSelection(input);
126
154
  return withExtensionPage(input, async (page) => {
127
- const orders = await readOpenOrders(page);
128
- const matching = selectedItems(input, orders);
155
+ const timeoutMs = Number(input.node?.timeout_ms ?? 0);
156
+ const state = await waitForSelectedOrderState(page, input, expectedOpen, timeoutMs);
157
+ const matching = selectedItems(input, state.orders);
129
158
  const hasOrders = matching.length > 0;
130
159
  if (expectedOpen && !hasOrders) throw new Error('Expected at least one matching open Perps order, but found none.');
131
160
  if (!expectedOpen && hasOrders) throw new Error(`Expected no matching open Perps orders, but found ${matching.length}.`);
132
- return { action: input.action, expectedOpen, matchingCount: matching.length, orders: matching.map(redactOrder), source: 'perps-stream-manager-cache' };
161
+ return { action: input.action, expectedOpen, matchingCount: matching.length, orders: matching.map(redactOrder), source: state.source };
133
162
  });
134
163
  }
135
164
 
@@ -306,6 +335,10 @@ async function readCurrentMarketPrice(page, symbol, explicitPrice) {
306
335
  }
307
336
 
308
337
  async function readOpenOrders(page) {
338
+ return (await readOpenOrdersState(page)).orders;
339
+ }
340
+
341
+ async function readOpenOrdersState(page) {
309
342
  const state = await page.evaluate(`(async () => {
310
343
  const request = globalThis.stateHooks?.submitRequestToBackground;
311
344
  const manager = globalThis.stateHooks?.getPerpsStreamManager?.();
@@ -326,7 +359,7 @@ async function readOpenOrders(page) {
326
359
  };
327
360
  })()`);
328
361
  if (!state.available) throw new Error('Perps orders are unavailable from both background and stream manager cache.');
329
- return state.orders;
362
+ return state;
330
363
  }
331
364
 
332
365
  async function readAccountState(page) {
@@ -393,12 +426,41 @@ async function waitForPositionPresent(page, symbol, timeoutMs) {
393
426
  return lastState;
394
427
  }
395
428
 
429
+ async function waitForSelectedPositionState(page, input, expectedOpen, timeoutMs) {
430
+ const deadline = Date.now() + Math.max(0, timeoutMs);
431
+ let state;
432
+ while (true) {
433
+ state = await page.readPositions();
434
+ if (!state.available) {
435
+ throw new Error('stateHooks.submitRequestToBackground is unavailable; cannot assert live Perps positions.');
436
+ }
437
+ const matching = selectedItems(input, state.positions);
438
+ if (expectedOpen ? matching.length > 0 : matching.length === 0) return state;
439
+ if (Date.now() >= deadline) return state;
440
+ await sleep(Math.min(500, Math.max(1, deadline - Date.now())));
441
+ }
442
+ }
443
+
444
+ async function waitForSelectedOrderState(page, input, expectedOpen, timeoutMs) {
445
+ const deadline = Date.now() + Math.max(0, timeoutMs);
446
+ let state;
447
+ while (true) {
448
+ state = await readOpenOrdersState(page);
449
+ const matching = selectedItems(input, state.orders);
450
+ if (expectedOpen ? matching.length > 0 : matching.length === 0) return state;
451
+ if (Date.now() >= deadline) return state;
452
+ await sleep(Math.min(500, Math.max(1, deadline - Date.now())));
453
+ }
454
+ }
455
+
396
456
  export async function placeOrder(input) {
397
457
  requirePlaceOrderInputs(input);
398
458
  const symbol = marketSymbol(input);
399
459
  const side = String(input.node.side).toLowerCase();
400
460
  const amount = String(input.node.amount ?? input.node.notional);
401
461
  const leverage = Number(input.node?.leverage ?? 3);
462
+ const isBuy = side !== 'short';
463
+ const orderType = resolveOrderType(input);
402
464
  return withExtensionPage(input, async (page) => {
403
465
  await page.navigateHash(`#/perps/market/${encodeURIComponent(symbol)}`);
404
466
  await page.evaluate(`(async () => {
@@ -415,6 +477,9 @@ export async function placeOrder(input) {
415
477
  symbol,
416
478
  input.node?.current_price ?? input.node?.currentPrice,
417
479
  );
480
+ const limitPrice = orderType === 'limit'
481
+ ? resolveLimitPrice(input, isBuy, resolvedPrice)
482
+ : null;
418
483
  await page.navigateHash(`#/perps/trade/${encodeURIComponent(symbol)}?direction=${encodeURIComponent(side)}&mode=new`);
419
484
  await page.waitForSelector(dataTestId('perps-order-entry-page'), { timeoutMs: 20000 });
420
485
  let order;
@@ -426,13 +491,27 @@ export async function placeOrder(input) {
426
491
  if (!Number.isFinite(currentPrice) || currentPrice <= 0) throw new Error('Unable to determine current Perps price for order placement.');
427
492
  const usdAmount = ${JSON.stringify(amount)};
428
493
  const leverage = ${JSON.stringify(leverage)};
429
- const orderParams = {
494
+ const orderType = ${JSON.stringify(orderType)};
495
+ const limitPrice = ${JSON.stringify(limitPrice)};
496
+ const orderParams = orderType === 'limit' ? {
430
497
  symbol: ${JSON.stringify(symbol)},
431
- isBuy: ${JSON.stringify(side !== 'short')},
498
+ isBuy: ${JSON.stringify(isBuy)},
499
+ size: ((Number(usdAmount) * Number(leverage)) / Number(limitPrice)).toString(),
500
+ orderType,
501
+ price: String(limitPrice),
502
+ timeInForce: 'GTC',
503
+ leverage,
504
+ currentPrice,
505
+ priceAtCalculation: currentPrice,
506
+ maxSlippageBps: ${JSON.stringify(Number(input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300))},
507
+ } : {
508
+ symbol: ${JSON.stringify(symbol)},
509
+ isBuy: ${JSON.stringify(isBuy)},
432
510
  size: ((Number(usdAmount) * Number(leverage)) / currentPrice).toString(),
433
- orderType: 'market',
511
+ orderType,
434
512
  leverage,
435
513
  currentPrice,
514
+ priceAtCalculation: currentPrice,
436
515
  usdAmount,
437
516
  maxSlippageBps: ${JSON.stringify(Number(input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300))},
438
517
  };
@@ -446,8 +525,12 @@ export async function placeOrder(input) {
446
525
  const message = error instanceof Error ? error.message : String(error);
447
526
  throw new Error(`${message} (resolvedPrice=${resolvedPrice})`);
448
527
  }
449
- await waitForPositionPresent(page, symbol, Number(input.node?.timeout_ms ?? 30000));
450
- return { action: input.action, market: symbol, side, amount, leverage, submitted: true, order, proofPath: 'background-perpsPlaceOrder' };
528
+ if (orderType === 'limit') {
529
+ await waitForSelectedOrderState(page, input, true, Number(input.node?.timeout_ms ?? 30000));
530
+ } else {
531
+ await waitForPositionPresent(page, symbol, Number(input.node?.timeout_ms ?? 30000));
532
+ }
533
+ return { action: input.action, market: symbol, side, orderType, amount, leverage, limitPrice, submitted: true, order, proofPath: 'background-perpsPlaceOrder' };
451
534
  });
452
535
  }
453
536
 
@@ -479,7 +562,16 @@ export async function ensureOrders(input) {
479
562
  const close = await closeOrders(input);
480
563
  return { ...(await assertOrders(input, false)), close };
481
564
  }
482
- if (state === 'open' || state === 'present') return assertOrders(input, true);
565
+ if (state === 'open' || state === 'present') {
566
+ const current = await withExtensionPage(input, async (page) => ({
567
+ orders: await readOpenOrders(page),
568
+ }));
569
+ let order = null;
570
+ if (selectedItems(input, current.orders).length === 0) {
571
+ order = await placeOrder({ ...input, node: { ...input.node, order_type: 'limit' } });
572
+ }
573
+ return { ...(await assertOrders(input, true)), order };
574
+ }
483
575
  throw new Error(`metamask.perps.ensure_orders received unsupported state: ${state}`);
484
576
  }
485
577
 
@@ -575,10 +575,15 @@ export class ExtensionPage extends CdpWebPage {
575
575
  this.port = port;
576
576
  }
577
577
 
578
- async navigateHash(hash) {
578
+ async navigateHash(hash, timeoutMs = 10000) {
579
579
  const normalizedHash = String(hash || '').startsWith('#') ? hash : `#${hash || '/'}`;
580
- const href = `${this.origin}/home.html${normalizedHash}`;
581
- return this.navigate(href);
580
+ const navigation = await this.evaluate(`(() => {
581
+ const before = location.href;
582
+ location.hash = ${JSON.stringify(normalizedHash)};
583
+ return { before, href: location.href };
584
+ })()`);
585
+ await this.waitForDocumentReady({ expectedUrl: navigation.href, timeoutMs });
586
+ return { ...navigation, sameDocument: true };
582
587
  }
583
588
 
584
589
  async readPositions() {