@deeeed/metamask-harness 0.18.0 → 0.19.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 (79) hide show
  1. package/CHANGELOG.md +187 -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/inject.sh +5 -4
  7. package/adapters/mobile/verify.sh +15 -19
  8. package/adapters/shared/harness-source-fingerprint.mjs +14 -12
  9. package/dist/adapters/harness-freshness.js +49 -0
  10. package/dist/cli.js +0 -4
  11. package/dist/command-contract.js +51 -40
  12. package/dist/command-journal.js +50 -12
  13. package/dist/commands/call.js +95 -20
  14. package/dist/commands/check.js +1 -1
  15. package/dist/commands/completion-candidates.js +7 -19
  16. package/dist/commands/fixtures.js +50 -1
  17. package/dist/commands/last.js +9 -1
  18. package/dist/commands/launch/extension.js +2 -2
  19. package/dist/commands/launch/mobile.js +2 -0
  20. package/dist/commands/list-executables.js +56 -20
  21. package/dist/commands/manifest.js +28 -12
  22. package/dist/commands/parse-args.js +55 -3
  23. package/dist/commands/provision.js +0 -1
  24. package/dist/commands/run-engine.js +514 -336
  25. package/dist/commands/run.js +25 -37
  26. package/dist/commands/shared.js +1 -1
  27. package/dist/heal-bounds.js +5 -0
  28. package/dist/mm-harness-cli.js +22 -30
  29. package/dist/run-diagnostics.js +1 -1
  30. package/dist/run-recording.js +1 -1
  31. package/dist/runner.js +24 -2
  32. package/docs/CONTRIBUTING.md +2 -3
  33. package/docs/QA.md +0 -1
  34. package/docs/RECIPES.md +52 -91
  35. package/library/README.md +5 -5
  36. package/library/actions/core/perps/_controller.mjs +33 -1
  37. package/library/actions/core/perps/assert_orders.mjs +6 -7
  38. package/library/actions/core/perps/assert_positions.mjs +6 -7
  39. package/library/actions/core/perps/close_orders.mjs +2 -0
  40. package/library/actions/core/perps/close_positions.mjs +2 -0
  41. package/library/actions/core/perps/ensure_orders.mjs +4 -2
  42. package/library/actions/core/perps/ensure_positions.mjs +4 -2
  43. package/library/actions/core/perps/place_order.mjs +7 -3
  44. package/library/actions/extension/perps/assert_orders.mjs +2 -1
  45. package/library/actions/extension/perps/assert_positions.mjs +2 -1
  46. package/library/actions/extension/perps/perps.mjs +43 -14
  47. package/library/actions/mobile/perps/assert_orders.mjs +2 -1
  48. package/library/actions/mobile/perps/assert_positions.mjs +2 -1
  49. package/library/actions/mobile/perps/perps.mjs +40 -12
  50. package/library/library.json +1 -1
  51. package/library/manifests/core.action-manifest.json +1170 -413
  52. package/library/manifests/extension.action-manifest.json +1495 -641
  53. package/library/manifests/mobile.action-manifest.json +1704 -769
  54. package/library/recipes/app/lifecycle.android-smoke.mobile.recipe.json +63 -81
  55. package/library/recipes/perps/clean-market-testnet.core.recipe.json +44 -0
  56. package/library/recipes/perps/clean-market-testnet.recipe.json +49 -0
  57. package/library/recipes/perps/lifecycle.recipe.json +136 -180
  58. package/library/recipes/perps/order-lifecycle.core.recipe.json +71 -67
  59. package/library/recipes/perps/performance.background-resume.mobile.recipe.json +51 -67
  60. package/library/recipes/perps/performance.cold-start.mobile.recipe.json +51 -67
  61. package/library/recipes/perps/performance.mobile.recipe.json +37 -51
  62. package/library/recipes/perps/performance.warm-start.mobile.recipe.json +44 -59
  63. package/library/recipes/perps/read-markets.core.recipe.json +29 -31
  64. package/library/recipes/perps/smoke.core.recipe.json +29 -32
  65. package/library/recipes/perps/smoke.extension.recipe.json +41 -44
  66. package/library/recipes/perps/smoke.mobile.recipe.json +42 -44
  67. package/library/recipes/perps/trading-lifecycle.core.recipe.json +69 -65
  68. package/library/recipes/runner/action-validation.extension.recipe.json +312 -405
  69. package/library/recipes/runner/action-validation.mobile.recipe.json +316 -409
  70. package/library/recipes/runner/smoke.core.recipe.json +18 -20
  71. package/library/recipes/runner/smoke.extension.recipe.json +23 -24
  72. package/library/recipes/runner/smoke.mobile.recipe.json +23 -24
  73. package/library/recipes/wallet/smoke.extension.recipe.json +33 -35
  74. package/library/recipes/wallet/smoke.mobile.recipe.json +33 -35
  75. package/package.json +3 -3
  76. package/scripts/completions.sh +1 -4
  77. package/dist/adapters/extension/harness-freshness.js +0 -39
  78. package/dist/commands/flows.js +0 -91
  79. package/library/flows/perps.flows.json +0 -64
@@ -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,13 +13,15 @@ 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
26
  const orders = await controller.getOpenOrders({
24
27
  standalone: true,
@@ -28,14 +31,10 @@ export async function assertOrders(input, expectOpen = expectedOpen(input)) {
28
31
  const hasOrder = matching.length > 0;
29
32
 
30
33
  if (expectOpen && !hasOrder) {
31
- throw new Error(
32
- `Expected selected Perps open order(s), but none matched ${JSON.stringify(input.node)}.`,
33
- );
34
+ throw new Error('Expected at least one matching open Perps order, but found none.');
34
35
  }
35
36
  if (!expectOpen && hasOrder) {
36
- throw new Error(
37
- `Expected no selected Perps open orders, but ${matching.length} matched ${JSON.stringify(input.node)}.`,
38
- );
37
+ throw new Error(`Expected no matching open Perps orders, but found ${matching.length}.`);
39
38
  }
40
39
 
41
40
  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,13 +12,15 @@ 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
25
  const positions = await controller.getPositions({
23
26
  standalone: true,
@@ -27,14 +30,10 @@ export async function assertPositions(input, expectOpen = expectedOpen(input)) {
27
30
  const hasPosition = matching.length > 0;
28
31
 
29
32
  if (expectOpen && !hasPosition) {
30
- throw new Error(
31
- `Expected selected Perps position(s), but none matched ${JSON.stringify(input.node)}.`,
32
- );
33
+ throw new Error('Expected at least one matching open Perps position, but found none.');
33
34
  }
34
35
  if (!expectOpen && hasPosition) {
35
- throw new Error(
36
- `Expected no selected Perps positions, but ${matching.length} matched ${JSON.stringify(input.node)}.`,
37
- );
36
+ throw new Error(`Expected no matching open Perps positions, but found ${matching.length}.`);
38
37
  }
39
38
 
40
39
  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}`);
@@ -24,20 +24,39 @@ function configuredSymbols(input, items) {
24
24
  const explicit = input.node?.markets ?? input.node?.symbols ?? selector.markets ?? selector.symbols;
25
25
  if (Array.isArray(explicit) && explicit.length > 0) return uniqueSymbols(explicit.map(normalizeMarketSymbol));
26
26
  if (typeof explicit === 'string' && explicit.length > 0) return uniqueSymbols(explicit.split(',').map((part) => normalizeMarketSymbol(part.trim())).filter(Boolean));
27
- return [marketSymbol(input)];
27
+ const single = input.node?.market ?? input.node?.symbol ?? selector.market ?? selector.symbol;
28
+ return single ? [normalizeMarketSymbol(single)] : [];
28
29
  }
29
30
 
30
- function selectedItems(input, items) {
31
- const symbols = new Set(configuredSymbols(input, items));
31
+ export function selectedItems(input, items) {
32
+ const requested = configuredSymbols(input, items);
33
+ const symbols = requested.length > 0 ? new Set(requested) : null;
32
34
  const selector = input.node?.selector && typeof input.node.selector === 'object' ? input.node.selector : {};
33
35
  const side = input.node?.side ?? selector.side;
34
36
  return items.filter((item) => {
35
- if (!symbols.has(symbolForItem(item))) return false;
37
+ if (symbols && !symbols.has(symbolForItem(item))) return false;
36
38
  if (side && sideForItem(item) && sideForItem(item) !== String(side).toLowerCase()) return false;
37
39
  return true;
38
40
  });
39
41
  }
40
42
 
43
+ function requireExplicitSelection(input) {
44
+ const node = input.node ?? {};
45
+ const selector = node.selector && typeof node.selector === 'object' ? node.selector : {};
46
+ const values = [node.market, node.symbol, node.markets, node.symbols, selector.market, selector.symbol, selector.markets, selector.symbols];
47
+ const selected = values.some((value) => Array.isArray(value) ? value.length > 0 : value != null && String(value).trim() !== '');
48
+ if (!selected && node.mode !== 'all' && selector.mode !== 'all') {
49
+ throw new Error(`${input.action} requires market=<symbol> or mode=all.`);
50
+ }
51
+ }
52
+
53
+ function requirePlaceOrderInputs(input) {
54
+ const node = input.node ?? {};
55
+ if (node.market == null && node.symbol == null) throw new Error(`${input.action} requires market=<symbol>.`);
56
+ if (node.side == null) throw new Error(`${input.action} requires side=long or side=short.`);
57
+ if (node.amount == null && node.notional == null) throw new Error(`${input.action} requires amount=<usd> or notional=<usd>.`);
58
+ }
59
+
41
60
  export function positionSelectionState(input, positions) {
42
61
  return { hasMatching: selectedItems(input, positions).length > 0 };
43
62
  }
@@ -85,33 +104,37 @@ export async function readOrders(input) {
85
104
  }
86
105
 
87
106
  export async function assertPositions(input, expectedOpen) {
107
+ requireExplicitSelection(input);
88
108
  return withExtensionPage(input, async (page) => {
89
109
  const timeoutMs = Number(input.node?.timeout_ms ?? 0);
90
- if (expectedOpen && timeoutMs > 0) {
91
- await waitForPositionPresent(page, marketSymbol(input), timeoutMs);
110
+ const requested = configuredSymbols(input, []);
111
+ if (expectedOpen && timeoutMs > 0 && requested.length === 1) {
112
+ await waitForPositionPresent(page, requested[0], timeoutMs);
92
113
  }
93
114
  const state = await page.readPositions();
94
115
  if (!state.available) throw new Error('stateHooks.submitRequestToBackground is unavailable; cannot assert live Perps positions.');
95
116
  const matching = selectedItems(input, state.positions);
96
117
  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)}.`);
118
+ if (expectedOpen && !hasPosition) throw new Error('Expected at least one matching open Perps position, but found none.');
119
+ if (!expectedOpen && hasPosition) throw new Error(`Expected no matching open Perps positions, but found ${matching.length}.`);
99
120
  return { action: input.action, expectedOpen, matchingCount: matching.length, positions: matching.map(redactPosition), source: state.source ?? 'perps-stream-manager-cache' };
100
121
  });
101
122
  }
102
123
 
103
124
  export async function assertOrders(input, expectedOpen) {
125
+ requireExplicitSelection(input);
104
126
  return withExtensionPage(input, async (page) => {
105
127
  const orders = await readOpenOrders(page);
106
128
  const matching = selectedItems(input, orders);
107
129
  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)}.`);
130
+ if (expectedOpen && !hasOrders) throw new Error('Expected at least one matching open Perps order, but found none.');
131
+ if (!expectedOpen && hasOrders) throw new Error(`Expected no matching open Perps orders, but found ${matching.length}.`);
110
132
  return { action: input.action, expectedOpen, matchingCount: matching.length, orders: matching.map(redactOrder), source: 'perps-stream-manager-cache' };
111
133
  });
112
134
  }
113
135
 
114
136
  export async function closePositions(input) {
137
+ requireExplicitSelection(input);
115
138
  return withExtensionPage(input, async (page) => {
116
139
  const timeoutMs = Number(input.node?.timeout_ms ?? 30000);
117
140
  const state = await page.readPositions();
@@ -147,6 +170,7 @@ export async function closePositions(input) {
147
170
  }
148
171
 
149
172
  export async function closeOrders(input) {
173
+ requireExplicitSelection(input);
150
174
  return withExtensionPage(input, async (page) => {
151
175
  const timeoutMs = Number(input.node?.timeout_ms ?? 30000);
152
176
  const before = await readOpenOrders(page);
@@ -370,9 +394,10 @@ async function waitForPositionPresent(page, symbol, timeoutMs) {
370
394
  }
371
395
 
372
396
  export async function placeOrder(input) {
397
+ requirePlaceOrderInputs(input);
373
398
  const symbol = marketSymbol(input);
374
- const side = String(input.node?.side ?? 'long').toLowerCase();
375
- const amount = String(input.node?.amount ?? input.node?.notional ?? '11');
399
+ const side = String(input.node.side).toLowerCase();
400
+ const amount = String(input.node.amount ?? input.node.notional);
376
401
  const leverage = Number(input.node?.leverage ?? 3);
377
402
  return withExtensionPage(input, async (page) => {
378
403
  await page.navigateHash(`#/perps/market/${encodeURIComponent(symbol)}`);
@@ -427,7 +452,9 @@ export async function placeOrder(input) {
427
452
  }
428
453
 
429
454
  export async function ensurePositions(input) {
430
- const state = String(input.node?.state ?? input.node?.position ?? 'none').toLowerCase();
455
+ if (input.node?.state == null) throw new Error('metamask.perps.ensure_positions requires state=open or state=none.');
456
+ requireExplicitSelection(input);
457
+ const state = String(input.node.state).toLowerCase();
431
458
  if (state === 'none' || state === 'closed' || state === 'absent') {
432
459
  const close = await closePositions(input);
433
460
  return { ...(await assertPositions(input, false)), close };
@@ -445,7 +472,9 @@ export async function ensurePositions(input) {
445
472
  }
446
473
 
447
474
  export async function ensureOrders(input) {
448
- const state = String(input.node?.state ?? input.node?.orders ?? 'none').toLowerCase();
475
+ if (input.node?.state == null) throw new Error('metamask.perps.ensure_orders requires state=open or state=none.');
476
+ requireExplicitSelection(input);
477
+ const state = String(input.node.state).toLowerCase();
449
478
  if (state === 'none' || state === 'closed' || state === 'absent') {
450
479
  const close = await closeOrders(input);
451
480
  return { ...(await assertOrders(input, false)), close };
@@ -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}`);
@@ -34,20 +34,39 @@ function configuredSymbols(input, items) {
34
34
  const explicit = input.node?.markets ?? input.node?.symbols ?? selector.markets ?? selector.symbols;
35
35
  if (Array.isArray(explicit) && explicit.length > 0) return uniqueSymbols(explicit.map(normalizeMarketSymbol));
36
36
  if (typeof explicit === 'string' && explicit.length > 0) return uniqueSymbols(explicit.split(',').map((part) => normalizeMarketSymbol(part.trim())).filter(Boolean));
37
- return [marketSymbol(input)];
37
+ const single = input.node?.market ?? input.node?.symbol ?? selector.market ?? selector.symbol;
38
+ return single ? [normalizeMarketSymbol(single)] : [];
38
39
  }
39
40
 
40
- function selectedItems(input, items) {
41
- const symbols = new Set(configuredSymbols(input, items));
41
+ export function selectedItems(input, items) {
42
+ const requested = configuredSymbols(input, items);
43
+ const symbols = requested.length > 0 ? new Set(requested) : null;
42
44
  const selector = input.node?.selector && typeof input.node.selector === 'object' ? input.node.selector : {};
43
45
  const side = input.node?.side ?? selector.side;
44
46
  return items.filter((item) => {
45
- if (!symbols.has(symbolForItem(item))) return false;
47
+ if (symbols && !symbols.has(symbolForItem(item))) return false;
46
48
  if (side && sideForItem(item) && sideForItem(item) !== String(side).toLowerCase()) return false;
47
49
  return true;
48
50
  });
49
51
  }
50
52
 
53
+ function requireExplicitSelection(input) {
54
+ const node = input.node ?? {};
55
+ const selector = node.selector && typeof node.selector === 'object' ? node.selector : {};
56
+ const values = [node.market, node.symbol, node.markets, node.symbols, selector.market, selector.symbol, selector.markets, selector.symbols];
57
+ const selected = values.some((value) => Array.isArray(value) ? value.length > 0 : value != null && String(value).trim() !== '');
58
+ if (!selected && node.mode !== 'all' && selector.mode !== 'all') {
59
+ throw new Error(`${input.action} requires market=<symbol> or mode=all.`);
60
+ }
61
+ }
62
+
63
+ function requirePlaceOrderInputs(input) {
64
+ const node = input.node ?? {};
65
+ if (node.market == null && node.symbol == null) throw new Error(`${input.action} requires market=<symbol>.`);
66
+ if (node.side == null) throw new Error(`${input.action} requires side=long or side=short.`);
67
+ if (node.amount == null && node.notional == null) throw new Error(`${input.action} requires amount=<usd> or notional=<usd>.`);
68
+ }
69
+
51
70
  function uniqueSymbols(symbols) {
52
71
  return Array.from(new Set(symbols.filter(Boolean)));
53
72
  }
@@ -250,20 +269,22 @@ export async function readOrders(input) {
250
269
  }
251
270
 
252
271
  export async function assertPositions(input, expectedOpen) {
272
+ requireExplicitSelection(input);
253
273
  const positions = await readPositions(input);
254
274
  const matching = selectedItems(input, positions);
255
275
  const hasPosition = matching.length > 0;
256
- if (expectedOpen && !hasPosition) throw new Error(`Expected selected Perps position(s), but none matched ${JSON.stringify(input.node)}.`);
257
- if (!expectedOpen && hasPosition) throw new Error(`Expected no selected Perps positions, but ${matching.length} matched ${JSON.stringify(input.node)}.`);
276
+ if (expectedOpen && !hasPosition) throw new Error('Expected at least one matching open Perps position, but found none.');
277
+ if (!expectedOpen && hasPosition) throw new Error(`Expected no matching open Perps positions, but found ${matching.length}.`);
258
278
  return { action: input.action, expectedOpen, matchingCount: matching.length, positions: matching.map(redactPosition), source: 'mobile-perps-controller' };
259
279
  }
260
280
 
261
281
  export async function assertOrders(input, expectedOpen) {
282
+ requireExplicitSelection(input);
262
283
  const orders = await readOpenOrders(input);
263
284
  const matching = selectedItems(input, orders);
264
285
  const hasOrders = matching.length > 0;
265
- if (expectedOpen && !hasOrders) throw new Error(`Expected selected Perps order(s), but none matched ${JSON.stringify(input.node)}.`);
266
- if (!expectedOpen && hasOrders) throw new Error(`Expected no selected Perps orders, but ${matching.length} matched ${JSON.stringify(input.node)}.`);
286
+ if (expectedOpen && !hasOrders) throw new Error('Expected at least one matching open Perps order, but found none.');
287
+ if (!expectedOpen && hasOrders) throw new Error(`Expected no matching open Perps orders, but found ${matching.length}.`);
267
288
  return { action: input.action, expectedOpen, matchingCount: matching.length, orders: matching.map(redactOrder), source: 'mobile-perps-controller-open-orders' };
268
289
  }
269
290
 
@@ -315,6 +336,7 @@ async function closePositionItem(input, position) {
315
336
  }
316
337
 
317
338
  export async function closePositions(input) {
339
+ requireExplicitSelection(input);
318
340
  const before = await readPositions(input);
319
341
  const matching = selectedItems(input, before);
320
342
  const symbols = uniqueSymbols(matching.map(symbolForItem));
@@ -336,6 +358,7 @@ export async function closePositions(input) {
336
358
  }
337
359
 
338
360
  export async function closeOrders(input) {
361
+ requireExplicitSelection(input);
339
362
  const before = await readOpenOrders(input);
340
363
  const matching = selectedItems(input, before);
341
364
  const symbols = uniqueSymbols(matching.map(symbolForItem));
@@ -380,9 +403,10 @@ export async function closeOrders(input) {
380
403
  }
381
404
 
382
405
  export async function placeOrder(input) {
406
+ requirePlaceOrderInputs(input);
383
407
  const symbol = marketSymbol(input);
384
- const side = String(input.node?.side ?? 'long').toLowerCase();
385
- const amount = String(input.node?.amount ?? input.node?.notional ?? '11');
408
+ const side = String(input.node.side).toLowerCase();
409
+ const amount = String(input.node.amount ?? input.node.notional);
386
410
  const size = String(input.node?.size ?? '0.0001');
387
411
  const leverage = Number(input.node?.leverage ?? 3);
388
412
  const maxSlippageBps = Number(input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300);
@@ -401,7 +425,9 @@ export async function placeOrder(input) {
401
425
  }
402
426
 
403
427
  export async function ensurePositions(input) {
404
- const state = String(input.node?.state ?? input.node?.position ?? 'none').toLowerCase();
428
+ if (input.node?.state == null) throw new Error('metamask.perps.ensure_positions requires state=open or state=none.');
429
+ requireExplicitSelection(input);
430
+ const state = String(input.node.state).toLowerCase();
405
431
  if (state === 'none' || state === 'closed' || state === 'absent') {
406
432
  const close = await closePositions(input);
407
433
  return { ...(await assertPositions(input, false)), close };
@@ -415,7 +441,9 @@ export async function ensurePositions(input) {
415
441
  }
416
442
 
417
443
  export async function ensureOrders(input) {
418
- const state = String(input.node?.state ?? input.node?.orders ?? 'none').toLowerCase();
444
+ if (input.node?.state == null) throw new Error('metamask.perps.ensure_orders requires state=open or state=none.');
445
+ requireExplicitSelection(input);
446
+ const state = String(input.node.state).toLowerCase();
419
447
  if (state === 'none' || state === 'closed' || state === 'absent') {
420
448
  const close = await closeOrders(input);
421
449
  return { ...(await assertOrders(input, false)), close };
@@ -3,5 +3,5 @@
3
3
  "schema_version": 1,
4
4
  "name": "metamask",
5
5
  "owner": "mm-harness-runner",
6
- "description": "Canonical MetaMask recipe library: minimal, stable flows every recipe can compose. Team and personal libraries layer on top and win resolution by default."
6
+ "description": "Canonical MetaMask library of reusable, parameterized recipes. Team and personal libraries layer on top and win resolution by default."
7
7
  }