@deeeed/metamask-harness 0.18.0 → 0.19.1

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 (82) hide show
  1. package/CHANGELOG.md +200 -0
  2. package/README.md +4 -7
  3. package/adapters/core/inject.sh +1 -6
  4. package/adapters/extension/inject.mjs +1 -4
  5. package/adapters/extension/verify.sh +1 -1
  6. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +33 -0
  7. package/adapters/mobile/inject.sh +5 -4
  8. package/adapters/mobile/verify.sh +15 -19
  9. package/adapters/shared/harness-source-fingerprint.mjs +14 -12
  10. package/dist/adapters/harness-freshness.js +49 -0
  11. package/dist/adapters.js +6 -1
  12. package/dist/cli.js +0 -4
  13. package/dist/command-contract.js +51 -40
  14. package/dist/command-journal.js +50 -12
  15. package/dist/commands/call.js +95 -20
  16. package/dist/commands/check.js +1 -1
  17. package/dist/commands/completion-candidates.js +7 -19
  18. package/dist/commands/fixtures.js +50 -1
  19. package/dist/commands/last.js +9 -1
  20. package/dist/commands/launch/extension.js +2 -2
  21. package/dist/commands/launch/mobile.js +2 -0
  22. package/dist/commands/list-executables.js +56 -20
  23. package/dist/commands/manifest.js +28 -12
  24. package/dist/commands/parse-args.js +55 -3
  25. package/dist/commands/provision.js +0 -1
  26. package/dist/commands/run-engine.js +514 -336
  27. package/dist/commands/run.js +25 -37
  28. package/dist/commands/shared.js +1 -1
  29. package/dist/heal-bounds.js +5 -0
  30. package/dist/live-adapter-contract.js +15 -2
  31. package/dist/mm-harness-cli.js +22 -30
  32. package/dist/run-diagnostics.js +1 -1
  33. package/dist/run-recording.js +1 -1
  34. package/dist/runner.js +24 -2
  35. package/docs/CONTRIBUTING.md +2 -3
  36. package/docs/QA.md +0 -1
  37. package/docs/RECIPES.md +67 -91
  38. package/library/README.md +5 -5
  39. package/library/actions/core/perps/_controller.mjs +33 -1
  40. package/library/actions/core/perps/assert_orders.mjs +22 -12
  41. package/library/actions/core/perps/assert_positions.mjs +22 -12
  42. package/library/actions/core/perps/close_orders.mjs +2 -0
  43. package/library/actions/core/perps/close_positions.mjs +2 -0
  44. package/library/actions/core/perps/ensure_orders.mjs +4 -2
  45. package/library/actions/core/perps/ensure_positions.mjs +4 -2
  46. package/library/actions/core/perps/place_order.mjs +7 -3
  47. package/library/actions/extension/perps/assert_orders.mjs +2 -1
  48. package/library/actions/extension/perps/assert_positions.mjs +2 -1
  49. package/library/actions/extension/perps/perps.mjs +151 -30
  50. package/library/actions/mobile/perps/assert_orders.mjs +2 -1
  51. package/library/actions/mobile/perps/assert_positions.mjs +2 -1
  52. package/library/actions/mobile/perps/perps.mjs +148 -22
  53. package/library/library.json +1 -1
  54. package/library/manifests/core.action-manifest.json +1204 -411
  55. package/library/manifests/extension.action-manifest.json +1536 -656
  56. package/library/manifests/mobile.action-manifest.json +1728 -758
  57. package/library/recipes/app/lifecycle.android-smoke.mobile.recipe.json +63 -81
  58. package/library/recipes/perps/clean-market-testnet.core.recipe.json +44 -0
  59. package/library/recipes/perps/clean-market-testnet.recipe.json +49 -0
  60. package/library/recipes/perps/lifecycle.recipe.json +136 -180
  61. package/library/recipes/perps/order-lifecycle.core.recipe.json +71 -67
  62. package/library/recipes/perps/performance.background-resume.mobile.recipe.json +51 -67
  63. package/library/recipes/perps/performance.cold-start.mobile.recipe.json +51 -67
  64. package/library/recipes/perps/performance.mobile.recipe.json +37 -51
  65. package/library/recipes/perps/performance.warm-start.mobile.recipe.json +44 -59
  66. package/library/recipes/perps/read-markets.core.recipe.json +29 -31
  67. package/library/recipes/perps/smoke.core.recipe.json +29 -32
  68. package/library/recipes/perps/smoke.extension.recipe.json +41 -44
  69. package/library/recipes/perps/smoke.mobile.recipe.json +42 -44
  70. package/library/recipes/perps/trading-lifecycle.core.recipe.json +69 -65
  71. package/library/recipes/runner/action-validation.extension.recipe.json +312 -405
  72. package/library/recipes/runner/action-validation.mobile.recipe.json +316 -409
  73. package/library/recipes/runner/smoke.core.recipe.json +18 -20
  74. package/library/recipes/runner/smoke.extension.recipe.json +23 -24
  75. package/library/recipes/runner/smoke.mobile.recipe.json +23 -24
  76. package/library/recipes/wallet/smoke.extension.recipe.json +33 -35
  77. package/library/recipes/wallet/smoke.mobile.recipe.json +33 -35
  78. package/package.json +3 -3
  79. package/scripts/completions.sh +1 -4
  80. package/dist/adapters/extension/harness-freshness.js +0 -39
  81. package/dist/commands/flows.js +0 -91
  82. package/library/flows/perps.flows.json +0 -64
package/library/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Recipe library
2
2
 
3
3
  - An **action** performs one typed operation.
4
- - A **flow** packages a reusable parameterized sequence of actions.
5
- - A **recipe** is a complete proof that may call actions and flows.
4
+ - A **recipe** is a parameterized, composable graph that may call actions and
5
+ other recipes.
6
6
 
7
7
  - `actions/<adapter>/<group>/<name>.mjs` — executable action modules.
8
8
  **Importing an action module RUNS it**: each ends with `runAdapter(main)` at top
@@ -13,6 +13,6 @@
13
13
  - Actions load harness helpers ONLY through `actions/harness-exports.mjs` (the
14
14
  dist-preferring bridge) — never deep-import `src/` (published installs have no
15
15
  src/, and the entrypoint would close an import cycle through adapters.ts).
16
- - `recipes/` — runnable smoke/validation recipes (`mm-harness run <file>`).
17
- - Flow resolution order is personal > team > canonical; shadowing is recorded in
18
- summary.json (never silent).
16
+ - `recipes/` — runnable recipes (`mm-harness run <name-or-file>`).
17
+ - Recipe resolution is explicit library order, then bundled MetaMask; selected
18
+ variants and shadows are recorded in run evidence.
@@ -246,6 +246,14 @@ export function resolveNetwork(input) {
246
246
  return raw;
247
247
  }
248
248
 
249
+ export function requireRuntimeExport(moduleNamespace, exportName, sourceLabel) {
250
+ const value = moduleNamespace?.[exportName] ?? moduleNamespace?.default?.[exportName];
251
+ if (value === undefined) {
252
+ throw new Error(`${sourceLabel} does not export ${exportName}.`);
253
+ }
254
+ return value;
255
+ }
256
+
249
257
  /**
250
258
  * Instantiate the PerpsController headlessly against the resolved core checkout.
251
259
  * Returns the controller plus the resolved read account address. Cached per
@@ -261,10 +269,25 @@ export async function getCoreController(input) {
261
269
  return { ...cached, accountAddress };
262
270
  }
263
271
 
264
- const [{ PerpsController }, { Messenger, MOCK_ANY_NAMESPACE }] = await Promise.all([
272
+ const [controllerModule, messengerModule] = await Promise.all([
265
273
  import(controllerEntry(projectRoot)),
266
274
  import(messengerEntry(projectRoot)),
267
275
  ]);
276
+ const PerpsController = requireRuntimeExport(
277
+ controllerModule,
278
+ 'PerpsController',
279
+ 'Core perps-controller entrypoint',
280
+ );
281
+ const Messenger = requireRuntimeExport(
282
+ messengerModule,
283
+ 'Messenger',
284
+ 'Core messenger entrypoint',
285
+ );
286
+ const MOCK_ANY_NAMESPACE = requireRuntimeExport(
287
+ messengerModule,
288
+ 'MOCK_ANY_NAMESPACE',
289
+ 'Core messenger entrypoint',
290
+ );
268
291
 
269
292
  const stubbed = new Set();
270
293
  const infrastructure = buildInfrastructure(stubbed);
@@ -558,6 +581,15 @@ export function configuredSymbols(input, items) {
558
581
  return single ? [normalizeMarketSymbol(single)] : [];
559
582
  }
560
583
 
584
+ export function requireExplicitSelection(input) {
585
+ const selector =
586
+ input.node?.selector && typeof input.node.selector === 'object' ? input.node.selector : {};
587
+ const mode = String(input.node?.mode ?? selector.mode ?? '').toLowerCase();
588
+ if (configuredSymbols(input, []).length === 0 && mode !== 'all') {
589
+ throw new Error(`${input.action} requires market=<symbol> or mode=all.`);
590
+ }
591
+ }
592
+
561
593
  /**
562
594
  * Filter live items to the recipe-selected subset using the same market/side
563
595
  * vocabulary as the extension perps adapter. With no selector and no
@@ -2,6 +2,7 @@ import {
2
2
  getCoreController,
3
3
  isDirectRun,
4
4
  redactOrder,
5
+ requireExplicitSelection,
5
6
  runAdapter,
6
7
  selectedItems,
7
8
  } from './_controller.mjs';
@@ -12,30 +13,39 @@ import {
12
13
  // assert_positions.mjs.
13
14
 
14
15
  export function expectedOpen(input) {
15
- const state = String(input.node?.state ?? input.node?.order ?? 'open').toLowerCase();
16
+ if (input.node?.state == null) throw new Error('metamask.perps.assert_orders requires state=open or state=none.');
17
+ const state = String(input.node.state).toLowerCase();
16
18
  if (state === 'open' || state === 'present') return true;
17
19
  if (state === 'none' || state === 'closed' || state === 'absent') return false;
18
20
  throw new Error(`metamask.perps.assert_orders received unsupported state: ${state}`);
19
21
  }
20
22
 
21
23
  export async function assertOrders(input, expectOpen = expectedOpen(input)) {
24
+ requireExplicitSelection(input);
22
25
  const { controller, accountAddress, network } = await getCoreController(input);
23
- const orders = await controller.getOpenOrders({
24
- standalone: true,
25
- userAddress: accountAddress,
26
- });
27
- 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
+ }
28
42
  const hasOrder = matching.length > 0;
29
43
 
30
44
  if (expectOpen && !hasOrder) {
31
- throw new Error(
32
- `Expected selected Perps open order(s), but none matched ${JSON.stringify(input.node)}.`,
33
- );
45
+ throw new Error('Expected at least one matching open Perps order, but found none.');
34
46
  }
35
47
  if (!expectOpen && hasOrder) {
36
- throw new Error(
37
- `Expected no selected Perps open orders, but ${matching.length} matched ${JSON.stringify(input.node)}.`,
38
- );
48
+ throw new Error(`Expected no matching open Perps orders, but found ${matching.length}.`);
39
49
  }
40
50
 
41
51
  return {
@@ -2,6 +2,7 @@ import {
2
2
  getCoreController,
3
3
  isDirectRun,
4
4
  redactPosition,
5
+ requireExplicitSelection,
5
6
  runAdapter,
6
7
  selectedItems,
7
8
  } from './_controller.mjs';
@@ -11,30 +12,39 @@ import {
11
12
  // throws on mismatch so the recipe fails loudly.
12
13
 
13
14
  export function expectedOpen(input) {
14
- const state = String(input.node?.state ?? input.node?.position ?? 'open').toLowerCase();
15
+ if (input.node?.state == null) throw new Error('metamask.perps.assert_positions requires state=open or state=none.');
16
+ const state = String(input.node.state).toLowerCase();
15
17
  if (state === 'open' || state === 'present') return true;
16
18
  if (state === 'none' || state === 'closed' || state === 'absent') return false;
17
19
  throw new Error(`metamask.perps.assert_positions received unsupported state: ${state}`);
18
20
  }
19
21
 
20
22
  export async function assertPositions(input, expectOpen = expectedOpen(input)) {
23
+ requireExplicitSelection(input);
21
24
  const { controller, accountAddress, network } = await getCoreController(input);
22
- const positions = await controller.getPositions({
23
- standalone: true,
24
- userAddress: accountAddress,
25
- });
26
- 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
+ }
27
41
  const hasPosition = matching.length > 0;
28
42
 
29
43
  if (expectOpen && !hasPosition) {
30
- throw new Error(
31
- `Expected selected Perps position(s), but none matched ${JSON.stringify(input.node)}.`,
32
- );
44
+ throw new Error('Expected at least one matching open Perps position, but found none.');
33
45
  }
34
46
  if (!expectOpen && hasPosition) {
35
- throw new Error(
36
- `Expected no selected Perps positions, but ${matching.length} matched ${JSON.stringify(input.node)}.`,
37
- );
47
+ throw new Error(`Expected no matching open Perps positions, but found ${matching.length}.`);
38
48
  }
39
49
 
40
50
  return {
@@ -2,6 +2,7 @@ import {
2
2
  getCoreControllerWithSigner,
3
3
  isDirectRun,
4
4
  redactOrder,
5
+ requireExplicitSelection,
5
6
  runAdapter,
6
7
  selectedItems,
7
8
  symbolForItem,
@@ -37,6 +38,7 @@ async function waitForOrdersAbsent(controller, accountAddress, symbols, timeoutM
37
38
  }
38
39
 
39
40
  export async function closeOrders(input) {
41
+ requireExplicitSelection(input);
40
42
  const { controller, accountAddress, network } = await getCoreControllerWithSigner(input);
41
43
  const timeoutMs = Number(input.node?.timeout_ms ?? 30000);
42
44
 
@@ -3,6 +3,7 @@ import {
3
3
  getCoreControllerWithSigner,
4
4
  isDirectRun,
5
5
  redactPosition,
6
+ requireExplicitSelection,
6
7
  runAdapter,
7
8
  selectedItems,
8
9
  symbolForItem,
@@ -36,6 +37,7 @@ async function waitForPositionsAbsent(controller, accountAddress, symbols, timeo
36
37
  }
37
38
 
38
39
  export async function closePositions(input) {
40
+ requireExplicitSelection(input);
39
41
  const { controller, accountAddress, network } = await getCoreControllerWithSigner(input);
40
42
  const timeoutMs = Number(input.node?.timeout_ms ?? 30000);
41
43
  const maxSlippageBps = Number(
@@ -1,4 +1,4 @@
1
- import { getCoreController, isDirectRun, runAdapter, selectedItems } from './_controller.mjs';
1
+ import { getCoreController, isDirectRun, requireExplicitSelection, runAdapter, selectedItems } from './_controller.mjs';
2
2
  import { assertOrders } from './assert_orders.mjs';
3
3
  import { closeOrders } from './close_orders.mjs';
4
4
  import { placeOrder } from './place_order.mjs';
@@ -8,7 +8,9 @@ import { placeOrder } from './place_order.mjs';
8
8
  // order state. Mirrors ensure_positions.mjs.
9
9
 
10
10
  export async function ensureOrders(input) {
11
- const state = String(input.node?.state ?? input.node?.order ?? 'none').toLowerCase();
11
+ if (input.node?.state == null) throw new Error('metamask.perps.ensure_orders requires state=open or state=none.');
12
+ requireExplicitSelection(input);
13
+ const state = String(input.node.state).toLowerCase();
12
14
 
13
15
  if (state === 'none' || state === 'closed' || state === 'absent') {
14
16
  const close = await closeOrders(input);
@@ -1,4 +1,4 @@
1
- import { getCoreController, isDirectRun, runAdapter, selectedItems } from './_controller.mjs';
1
+ import { getCoreController, isDirectRun, requireExplicitSelection, runAdapter, selectedItems } from './_controller.mjs';
2
2
  import { assertPositions } from './assert_positions.mjs';
3
3
  import { closePositions } from './close_positions.mjs';
4
4
  import { placeOrder } from './place_order.mjs';
@@ -8,7 +8,9 @@ import { placeOrder } from './place_order.mjs';
8
8
  // state. Mirrors the extension ensure_positions semantics.
9
9
 
10
10
  export async function ensurePositions(input) {
11
- const state = String(input.node?.state ?? input.node?.position ?? 'none').toLowerCase();
11
+ if (input.node?.state == null) throw new Error('metamask.perps.ensure_positions requires state=open or state=none.');
12
+ requireExplicitSelection(input);
13
+ const state = String(input.node.state).toLowerCase();
12
14
 
13
15
  if (state === 'none' || state === 'closed' || state === 'absent') {
14
16
  const close = await closePositions(input);
@@ -81,12 +81,16 @@ function resolveLimitPrice(input, isBuy, mid) {
81
81
  }
82
82
 
83
83
  export async function placeOrder(input) {
84
- const { controller, accountAddress, network } = await getCoreControllerWithSigner(input);
85
84
  const symbol = resolveSymbol(input);
86
- const side = String(input.node?.side ?? 'long').toLowerCase();
85
+ if (input.node?.side == null) throw new Error('metamask.perps.place_order requires side=long or side=short.');
86
+ if (input.node?.amount == null && input.node?.notional == null) {
87
+ throw new Error('metamask.perps.place_order requires amount=<usd> or notional=<usd>.');
88
+ }
89
+ const { controller, accountAddress, network } = await getCoreControllerWithSigner(input);
90
+ const side = String(input.node.side).toLowerCase();
87
91
  const isBuy = side !== 'short';
88
92
  const orderType = resolveOrderType(input);
89
- const usdAmount = String(input.node?.amount ?? input.node?.notional ?? '11');
93
+ const usdAmount = String(input.node.amount ?? input.node.notional);
90
94
  const leverage = Number(input.node?.leverage ?? 3);
91
95
  const maxSlippageBps = Number(
92
96
  input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300,
@@ -2,7 +2,8 @@ import { runAdapter } from '../platform/cdp.mjs';
2
2
  import { assertOrders } from './perps.mjs';
3
3
 
4
4
  function expectedOpen(input) {
5
- const state = String(input.node?.state ?? input.node?.orders ?? 'open').toLowerCase();
5
+ if (input.node?.state == null) throw new Error('metamask.perps.assert_orders requires state=open or state=none.');
6
+ const state = String(input.node.state).toLowerCase();
6
7
  if (state === 'open' || state === 'present') return true;
7
8
  if (state === 'none' || state === 'closed' || state === 'absent') return false;
8
9
  throw new Error(`metamask.perps.assert_orders received unsupported state: ${state}`);
@@ -2,7 +2,8 @@ import { runAdapter } from '../platform/cdp.mjs';
2
2
  import { assertPositions } from './perps.mjs';
3
3
 
4
4
  function expectedOpen(input) {
5
- const state = String(input.node?.state ?? input.node?.position ?? 'open').toLowerCase();
5
+ if (input.node?.state == null) throw new Error('metamask.perps.assert_positions requires state=open or state=none.');
6
+ const state = String(input.node.state).toLowerCase();
6
7
  if (state === 'open' || state === 'present') return true;
7
8
  if (state === 'none' || state === 'closed' || state === 'absent') return false;
8
9
  throw new Error(`metamask.perps.assert_positions received unsupported state: ${state}`);
@@ -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) {
@@ -24,20 +27,68 @@ function configuredSymbols(input, items) {
24
27
  const explicit = input.node?.markets ?? input.node?.symbols ?? selector.markets ?? selector.symbols;
25
28
  if (Array.isArray(explicit) && explicit.length > 0) return uniqueSymbols(explicit.map(normalizeMarketSymbol));
26
29
  if (typeof explicit === 'string' && explicit.length > 0) return uniqueSymbols(explicit.split(',').map((part) => normalizeMarketSymbol(part.trim())).filter(Boolean));
27
- return [marketSymbol(input)];
30
+ const single = input.node?.market ?? input.node?.symbol ?? selector.market ?? selector.symbol;
31
+ return single ? [normalizeMarketSymbol(single)] : [];
28
32
  }
29
33
 
30
- function selectedItems(input, items) {
31
- const symbols = new Set(configuredSymbols(input, items));
34
+ export function selectedItems(input, items) {
35
+ const requested = configuredSymbols(input, items);
36
+ const symbols = requested.length > 0 ? new Set(requested) : null;
32
37
  const selector = input.node?.selector && typeof input.node.selector === 'object' ? input.node.selector : {};
33
38
  const side = input.node?.side ?? selector.side;
34
39
  return items.filter((item) => {
35
- if (!symbols.has(symbolForItem(item))) return false;
40
+ if (symbols && !symbols.has(symbolForItem(item))) return false;
36
41
  if (side && sideForItem(item) && sideForItem(item) !== String(side).toLowerCase()) return false;
37
42
  return true;
38
43
  });
39
44
  }
40
45
 
46
+ function requireExplicitSelection(input) {
47
+ const node = input.node ?? {};
48
+ const selector = node.selector && typeof node.selector === 'object' ? node.selector : {};
49
+ const values = [node.market, node.symbol, node.markets, node.symbols, selector.market, selector.symbol, selector.markets, selector.symbols];
50
+ const selected = values.some((value) => Array.isArray(value) ? value.length > 0 : value != null && String(value).trim() !== '');
51
+ if (!selected && node.mode !== 'all' && selector.mode !== 'all') {
52
+ throw new Error(`${input.action} requires market=<symbol> or mode=all.`);
53
+ }
54
+ }
55
+
56
+ function requirePlaceOrderInputs(input) {
57
+ const node = input.node ?? {};
58
+ if (node.market == null && node.symbol == null) throw new Error(`${input.action} requires market=<symbol>.`);
59
+ if (node.side == null) throw new Error(`${input.action} requires side=long or side=short.`);
60
+ if (node.amount == null && node.notional == null) throw new Error(`${input.action} requires amount=<usd> or notional=<usd>.`);
61
+ }
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
+
41
92
  export function positionSelectionState(input, positions) {
42
93
  return { hasMatching: selectedItems(input, positions).length > 0 };
43
94
  }
@@ -78,40 +129,41 @@ export async function readPositions(input) {
78
129
 
79
130
  export async function readOrders(input) {
80
131
  return withExtensionPage(input, async (page) => {
81
- const orders = await readOpenOrders(page);
82
- const matching = selectedItems(input, orders);
83
- 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) };
84
135
  });
85
136
  }
86
137
 
87
138
  export async function assertPositions(input, expectedOpen) {
139
+ requireExplicitSelection(input);
88
140
  return withExtensionPage(input, async (page) => {
89
141
  const timeoutMs = Number(input.node?.timeout_ms ?? 0);
90
- if (expectedOpen && timeoutMs > 0) {
91
- await waitForPositionPresent(page, marketSymbol(input), timeoutMs);
92
- }
93
- const state = await page.readPositions();
142
+ const state = await waitForSelectedPositionState(page, input, expectedOpen, timeoutMs);
94
143
  if (!state.available) throw new Error('stateHooks.submitRequestToBackground is unavailable; cannot assert live Perps positions.');
95
144
  const matching = selectedItems(input, state.positions);
96
145
  const hasPosition = matching.length > 0;
97
- if (expectedOpen && !hasPosition) throw new Error(`Expected selected Perps position(s), but none matched ${JSON.stringify(input.node)}.`);
98
- if (!expectedOpen && hasPosition) throw new Error(`Expected no selected Perps positions, but ${matching.length} matched ${JSON.stringify(input.node)}.`);
146
+ if (expectedOpen && !hasPosition) throw new Error('Expected at least one matching open Perps position, but found none.');
147
+ if (!expectedOpen && hasPosition) throw new Error(`Expected no matching open Perps positions, but found ${matching.length}.`);
99
148
  return { action: input.action, expectedOpen, matchingCount: matching.length, positions: matching.map(redactPosition), source: state.source ?? 'perps-stream-manager-cache' };
100
149
  });
101
150
  }
102
151
 
103
152
  export async function assertOrders(input, expectedOpen) {
153
+ requireExplicitSelection(input);
104
154
  return withExtensionPage(input, async (page) => {
105
- const orders = await readOpenOrders(page);
106
- 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);
107
158
  const hasOrders = matching.length > 0;
108
- if (expectedOpen && !hasOrders) throw new Error(`Expected selected Perps order(s), but none matched ${JSON.stringify(input.node)}.`);
109
- if (!expectedOpen && hasOrders) throw new Error(`Expected no selected Perps orders, but ${matching.length} matched ${JSON.stringify(input.node)}.`);
110
- return { action: input.action, expectedOpen, matchingCount: matching.length, orders: matching.map(redactOrder), source: 'perps-stream-manager-cache' };
159
+ if (expectedOpen && !hasOrders) throw new Error('Expected at least one matching open Perps order, but found none.');
160
+ if (!expectedOpen && hasOrders) throw new Error(`Expected no matching open Perps orders, but found ${matching.length}.`);
161
+ return { action: input.action, expectedOpen, matchingCount: matching.length, orders: matching.map(redactOrder), source: state.source };
111
162
  });
112
163
  }
113
164
 
114
165
  export async function closePositions(input) {
166
+ requireExplicitSelection(input);
115
167
  return withExtensionPage(input, async (page) => {
116
168
  const timeoutMs = Number(input.node?.timeout_ms ?? 30000);
117
169
  const state = await page.readPositions();
@@ -147,6 +199,7 @@ export async function closePositions(input) {
147
199
  }
148
200
 
149
201
  export async function closeOrders(input) {
202
+ requireExplicitSelection(input);
150
203
  return withExtensionPage(input, async (page) => {
151
204
  const timeoutMs = Number(input.node?.timeout_ms ?? 30000);
152
205
  const before = await readOpenOrders(page);
@@ -282,6 +335,10 @@ async function readCurrentMarketPrice(page, symbol, explicitPrice) {
282
335
  }
283
336
 
284
337
  async function readOpenOrders(page) {
338
+ return (await readOpenOrdersState(page)).orders;
339
+ }
340
+
341
+ async function readOpenOrdersState(page) {
285
342
  const state = await page.evaluate(`(async () => {
286
343
  const request = globalThis.stateHooks?.submitRequestToBackground;
287
344
  const manager = globalThis.stateHooks?.getPerpsStreamManager?.();
@@ -302,7 +359,7 @@ async function readOpenOrders(page) {
302
359
  };
303
360
  })()`);
304
361
  if (!state.available) throw new Error('Perps orders are unavailable from both background and stream manager cache.');
305
- return state.orders;
362
+ return state;
306
363
  }
307
364
 
308
365
  async function readAccountState(page) {
@@ -369,11 +426,41 @@ async function waitForPositionPresent(page, symbol, timeoutMs) {
369
426
  return lastState;
370
427
  }
371
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
+
372
456
  export async function placeOrder(input) {
457
+ requirePlaceOrderInputs(input);
373
458
  const symbol = marketSymbol(input);
374
- const side = String(input.node?.side ?? 'long').toLowerCase();
375
- const amount = String(input.node?.amount ?? input.node?.notional ?? '11');
459
+ const side = String(input.node.side).toLowerCase();
460
+ const amount = String(input.node.amount ?? input.node.notional);
376
461
  const leverage = Number(input.node?.leverage ?? 3);
462
+ const isBuy = side !== 'short';
463
+ const orderType = resolveOrderType(input);
377
464
  return withExtensionPage(input, async (page) => {
378
465
  await page.navigateHash(`#/perps/market/${encodeURIComponent(symbol)}`);
379
466
  await page.evaluate(`(async () => {
@@ -390,6 +477,9 @@ export async function placeOrder(input) {
390
477
  symbol,
391
478
  input.node?.current_price ?? input.node?.currentPrice,
392
479
  );
480
+ const limitPrice = orderType === 'limit'
481
+ ? resolveLimitPrice(input, isBuy, resolvedPrice)
482
+ : null;
393
483
  await page.navigateHash(`#/perps/trade/${encodeURIComponent(symbol)}?direction=${encodeURIComponent(side)}&mode=new`);
394
484
  await page.waitForSelector(dataTestId('perps-order-entry-page'), { timeoutMs: 20000 });
395
485
  let order;
@@ -401,13 +491,27 @@ export async function placeOrder(input) {
401
491
  if (!Number.isFinite(currentPrice) || currentPrice <= 0) throw new Error('Unable to determine current Perps price for order placement.');
402
492
  const usdAmount = ${JSON.stringify(amount)};
403
493
  const leverage = ${JSON.stringify(leverage)};
404
- const orderParams = {
494
+ const orderType = ${JSON.stringify(orderType)};
495
+ const limitPrice = ${JSON.stringify(limitPrice)};
496
+ const orderParams = orderType === 'limit' ? {
405
497
  symbol: ${JSON.stringify(symbol)},
406
- 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)},
407
510
  size: ((Number(usdAmount) * Number(leverage)) / currentPrice).toString(),
408
- orderType: 'market',
511
+ orderType,
409
512
  leverage,
410
513
  currentPrice,
514
+ priceAtCalculation: currentPrice,
411
515
  usdAmount,
412
516
  maxSlippageBps: ${JSON.stringify(Number(input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300))},
413
517
  };
@@ -421,13 +525,19 @@ export async function placeOrder(input) {
421
525
  const message = error instanceof Error ? error.message : String(error);
422
526
  throw new Error(`${message} (resolvedPrice=${resolvedPrice})`);
423
527
  }
424
- await waitForPositionPresent(page, symbol, Number(input.node?.timeout_ms ?? 30000));
425
- 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' };
426
534
  });
427
535
  }
428
536
 
429
537
  export async function ensurePositions(input) {
430
- const state = String(input.node?.state ?? input.node?.position ?? 'none').toLowerCase();
538
+ if (input.node?.state == null) throw new Error('metamask.perps.ensure_positions requires state=open or state=none.');
539
+ requireExplicitSelection(input);
540
+ const state = String(input.node.state).toLowerCase();
431
541
  if (state === 'none' || state === 'closed' || state === 'absent') {
432
542
  const close = await closePositions(input);
433
543
  return { ...(await assertPositions(input, false)), close };
@@ -445,12 +555,23 @@ export async function ensurePositions(input) {
445
555
  }
446
556
 
447
557
  export async function ensureOrders(input) {
448
- const state = String(input.node?.state ?? input.node?.orders ?? 'none').toLowerCase();
558
+ if (input.node?.state == null) throw new Error('metamask.perps.ensure_orders requires state=open or state=none.');
559
+ requireExplicitSelection(input);
560
+ const state = String(input.node.state).toLowerCase();
449
561
  if (state === 'none' || state === 'closed' || state === 'absent') {
450
562
  const close = await closeOrders(input);
451
563
  return { ...(await assertOrders(input, false)), close };
452
564
  }
453
- 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
+ }
454
575
  throw new Error(`metamask.perps.ensure_orders received unsupported state: ${state}`);
455
576
  }
456
577
 
@@ -2,7 +2,8 @@ import { runAdapter } from '../platform/bridge.mjs';
2
2
  import { assertOrders } from './perps.mjs';
3
3
 
4
4
  function expectedOpen(input) {
5
- const state = String(input.node?.state ?? input.node?.orders ?? 'open').toLowerCase();
5
+ if (input.node?.state == null) throw new Error('metamask.perps.assert_orders requires state=open or state=none.');
6
+ const state = String(input.node.state).toLowerCase();
6
7
  if (state === 'open' || state === 'present') return true;
7
8
  if (state === 'none' || state === 'closed' || state === 'absent') return false;
8
9
  throw new Error(`metamask.perps.assert_orders received unsupported state: ${state}`);
@@ -2,7 +2,8 @@ import { runAdapter } from '../platform/bridge.mjs';
2
2
  import { assertPositions } from './perps.mjs';
3
3
 
4
4
  function expectedOpen(input) {
5
- const state = String(input.node?.state ?? input.node?.position ?? 'open').toLowerCase();
5
+ if (input.node?.state == null) throw new Error('metamask.perps.assert_positions requires state=open or state=none.');
6
+ const state = String(input.node.state).toLowerCase();
6
7
  if (state === 'open' || state === 'present') return true;
7
8
  if (state === 'none' || state === 'closed' || state === 'absent') return false;
8
9
  throw new Error(`metamask.perps.assert_positions received unsupported state: ${state}`);