@deeeed/metamask-harness 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.24.0 - 2026-07-30
6
+
7
+ ### Added
8
+
9
+ - `metamask.perps.edit_order` drives `PerpsController.editOrder()` on the `core` adapter, verifying that a known resting target was replaced by one new order matching the requested attributes rather than trusting the acknowledgement.
10
+ - `expect_error` asserts that a `core` perps operation is REJECTED with a given typed error instead of succeeding. The comparison is exact and code-only: actions attach the controller's error code as data, and a rejection carrying no code does NOT match, so an unrelated failure whose message happens to mention the code cannot satisfy a refusal proof. Exposed only on the five actions with a real controller-refusal boundary (`place_order`, `edit_order`, `update_position_tpsl`, `close_orders`, `close_positions`); on read, assert and ensure actions every throw is argument validation or a postcondition failure, which can carry no code, so the parameter is not advertised there.
11
+ - `expect_count` on `metamask.perps.assert_orders` and `assert_positions` pins the exact number of matching items, which the default at-least-one check cannot do.
12
+ - Attached TP/SL assertions verify that one parent links to live children of the requested TP/SL family, market, side and trigger price, and record those attributes in evidence.
13
+ - Relative price and size inputs on `place_order` and `update_position_tpsl`: `take_profit_offset_pct` / `stop_loss_offset_pct` derive a price from live mid, and `take_profit_fraction` / `stop_loss_fraction` derive a size from the live position, rounded to the market's size precision. An absolute value still wins when supplied.
14
+ - `notional` on `edit_order` derives a size when the resting order cannot be read.
15
+
16
+ ### Fixed
17
+
18
+ - Optional params left unset by a recipe template arrive as an empty string; the `core` perps actions treated that as a supplied value, so an order with no TP/SL could be rejected as having an invalid TP/SL size. Empty now means absent and falls through to a supplied alias.
19
+ - Batch order-cancel and position-close failures retain the failed controller result instead of being flattened, including partial failures.
20
+ - `edit_order` accepts only resting `GTC` and `ALO` replacements; `IOC` cannot satisfy its resting-order postcondition.
21
+ - `place_order` no longer discards a `time_in_force` or a stray trigger price on a non-trigger placement. Dropping a field the caller set is wrong on its own terms, and it also hid the controller's refusal of it; both are forwarded and the controller decides.
22
+ - `place_order` and `edit_order` reject a blank or unrecognised `side` instead of treating anything that is not `short` as a long, which let an unset template place a real order on an assumed side.
23
+
24
+ ## 0.23.1 - 2026-07-29
25
+
26
+ ### Fixed
27
+
28
+ - Mobile recipe evidence no longer triggers Metro Fast Refresh: harness-managed Metro ignores checkout-local `temp` artifacts, and screenshot scratch files stay outside the watched project.
29
+
5
30
  ## 0.23.0 - 2026-07-28
6
31
 
7
32
  ### Added
@@ -42,6 +42,14 @@
42
42
  "inputs": "--target --port --log --pid-file [--workers] [--clear]",
43
43
  "outputs": "Metro PID/launch metadata and redirected log; exit 0/1/2"
44
44
  },
45
+ {
46
+ "id": "mobile/metro-config",
47
+ "entry": "adapters/mobile/metro-config.cjs",
48
+ "kind": "module",
49
+ "purpose": "Preserve the product Metro config while excluding harness-owned checkout temp artifacts.",
50
+ "inputs": "base Metro config; env MM_HARNESS_METRO_PROJECT_ROOT, MM_HARNESS_METRO_BASE_CONFIG",
51
+ "outputs": "merged Metro config with resolver.blockList for <projectRoot>/temp"
52
+ },
45
53
  {
46
54
  "id": "mobile/stop-metro",
47
55
  "entry": "adapters/mobile/stop-metro.sh",
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const { createRequire } = require('node:module');
5
+ const path = require('node:path');
6
+ const { pathToFileURL } = require('node:url');
7
+
8
+ function escapeRegExp(value) {
9
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
10
+ }
11
+
12
+ function appendBlockList(blockList, pattern) {
13
+ if (Array.isArray(blockList)) return [...blockList, pattern];
14
+ return blockList instanceof RegExp ? [blockList, pattern] : [pattern];
15
+ }
16
+
17
+ function blockListFlags(blockList) {
18
+ const first = Array.isArray(blockList) ? blockList[0] : blockList;
19
+ return first instanceof RegExp ? first.flags : '';
20
+ }
21
+
22
+ function resolveExpoConfigLoader(projectRoot) {
23
+ const requireFromProject = createRequire(path.join(projectRoot, 'package.json'));
24
+ let requireUtilsPath;
25
+ try {
26
+ requireUtilsPath = requireFromProject.resolve('@expo/require-utils');
27
+ } catch (error) {
28
+ if (error?.code !== 'MODULE_NOT_FOUND') throw error;
29
+ return null;
30
+ }
31
+ const { loadModuleSync } = requireFromProject(requireUtilsPath);
32
+ return typeof loadModuleSync === 'function' ? loadModuleSync : null;
33
+ }
34
+
35
+ async function loadProductConfig(configPath, baseConfig, projectRoot) {
36
+ let loaded;
37
+ const expoConfigLoader = resolveExpoConfigLoader(projectRoot);
38
+ if (expoConfigLoader) {
39
+ loaded = expoConfigLoader(configPath);
40
+ } else {
41
+ try {
42
+ loaded = require(configPath);
43
+ } catch (error) {
44
+ if (error?.code !== 'ERR_REQUIRE_ESM') throw error;
45
+ loaded = await import(pathToFileURL(configPath).href);
46
+ }
47
+ }
48
+
49
+ const moduleValue = await loaded;
50
+ const exported =
51
+ moduleValue?.__esModule ||
52
+ moduleValue?.[Symbol.toStringTag] === 'Module'
53
+ ? moduleValue.default
54
+ : moduleValue;
55
+ const config = await exported;
56
+ return typeof config === 'function' ? config(baseConfig) : config;
57
+ }
58
+
59
+ module.exports = async function harnessMetroConfig(baseConfig) {
60
+ const projectRoot = path.resolve(
61
+ process.env.MM_HARNESS_METRO_PROJECT_ROOT || process.cwd(),
62
+ );
63
+ const productConfigPath = path.resolve(
64
+ process.env.MM_HARNESS_METRO_BASE_CONFIG ||
65
+ path.join(projectRoot, 'metro.config.js'),
66
+ );
67
+ let productConfig = baseConfig;
68
+ if (fs.existsSync(productConfigPath)) {
69
+ productConfig = await loadProductConfig(
70
+ productConfigPath,
71
+ baseConfig,
72
+ projectRoot,
73
+ );
74
+ }
75
+
76
+ const tempRoot = path.join(projectRoot, 'temp');
77
+ const existingBlockList =
78
+ productConfig.resolver?.blockList ?? baseConfig.resolver?.blockList;
79
+ const tempPattern = new RegExp(
80
+ `^${escapeRegExp(tempRoot)}(?:${escapeRegExp(path.sep)}|$)`,
81
+ blockListFlags(existingBlockList),
82
+ );
83
+ return {
84
+ ...productConfig,
85
+ resolver: {
86
+ ...productConfig.resolver,
87
+ blockList: appendBlockList(
88
+ existingBlockList,
89
+ tempPattern,
90
+ ),
91
+ },
92
+ };
93
+ };
@@ -49,6 +49,7 @@ fi
49
49
  # only ever inspect or signal a Metro listening on $PORT.
50
50
  # shellcheck disable=SC1091
51
51
  . "$SCRIPT_DIR/lib/metro-listener.sh"
52
+ MOBILE_METRO_REQUIRED_ENV="${MOBILE_METRO_REQUIRED_ENV:-MM_INFURA_PROJECT_ID} MM_HARNESS_METRO_PROJECT_ROOT EXPO_OVERRIDE_METRO_CONFIG"
52
53
 
53
54
  # RECIPE_RUNTIME_DIR (relative, validated) makes runtime state per-run so jobs
54
55
  # sharing one checkout do not collide on metro.log / metro.pid / metro.tmux.
@@ -98,6 +99,13 @@ set +a
98
99
  export EXPO_NO_TYPESCRIPT_SETUP=1
99
100
  export WATCHER_PORT="$(printf '%q' "$PORT")" METRO_PORT="$(printf '%q' "$PORT")"
100
101
  export METRO_MAX_WORKERS="$(printf '%q' "$METRO_WORKERS")"
102
+ export MM_HARNESS_METRO_PROJECT_ROOT="$(printf '%q' "$TARGET")"
103
+ if [ -n "\${EXPO_OVERRIDE_METRO_CONFIG:-}" ]; then
104
+ export MM_HARNESS_METRO_BASE_CONFIG="\$EXPO_OVERRIDE_METRO_CONFIG"
105
+ else
106
+ export MM_HARNESS_METRO_BASE_CONFIG=$(printf '%q' "$TARGET/metro.config.js")
107
+ fi
108
+ export EXPO_OVERRIDE_METRO_CONFIG="$(printf '%q' "$SCRIPT_DIR/metro-config.cjs")"
101
109
  yarn expo start --port "$(printf '%q' "$PORT")"$clear_flag 2>&1 | tee -a "$(printf '%q' "$LOG_FILE")"
102
110
  EOF
103
111
  chmod +x "$runner"
@@ -184,6 +192,9 @@ if ! start_metro_tmux; then
184
192
  export EXPO_NO_TYPESCRIPT_SETUP=1
185
193
  export WATCHER_PORT="${PORT}" METRO_PORT="${PORT}"
186
194
  export METRO_MAX_WORKERS="${METRO_WORKERS}"
195
+ export MM_HARNESS_METRO_PROJECT_ROOT="$TARGET"
196
+ export MM_HARNESS_METRO_BASE_CONFIG="${EXPO_OVERRIDE_METRO_CONFIG:-$TARGET/metro.config.js}"
197
+ export EXPO_OVERRIDE_METRO_CONFIG="$SCRIPT_DIR/metro-config.cjs"
187
198
  launcher_args=(
188
199
  --target "$TARGET"
189
200
  --port "$PORT"
@@ -9,7 +9,14 @@ const READ_ONLY_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
9
9
  "metamask.perps.read_orders",
10
10
  "metamask.perps.read_account",
11
11
  "metamask.perps.assert_positions",
12
- "metamask.perps.assert_orders"
12
+ "metamask.perps.assert_orders",
13
+ // Both only read the collector's JSONL and export/assert over it.
14
+ // `metamask.analytics.start_capture` is deliberately NOT listed: it spawns a
15
+ // detached HTTP listener, so it falls through to 'arbitrary-code' and needs
16
+ // explicit approval. Classifying a process-spawning action as read-only to
17
+ // save an approval prompt would defeat the point of this allowlist.
18
+ "metamask.analytics.read_events",
19
+ "metamask.analytics.assert_events"
13
20
  ]);
14
21
  const APP_MUTATION_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
15
22
  "metamask.wallet.setup",
@@ -18,16 +25,20 @@ const APP_MUTATION_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
18
25
  "metamask.perps.close_positions",
19
26
  "metamask.perps.close_orders",
20
27
  "metamask.perps.place_order",
28
+ "metamask.perps.edit_order",
21
29
  "metamask.perps.update_position_tpsl",
22
30
  "metamask.perps.ensure_positions",
23
31
  "metamask.perps.ensure_orders",
24
32
  "metamask.perps.start_state",
25
- "metamask.perps.teardown_state"
33
+ "metamask.perps.teardown_state",
34
+ // Flips MetaMetrics consent in the wallet, so app-mutation, not read-only.
35
+ "metamask.analytics.set_consent"
26
36
  ]);
27
37
  const EXTERNAL_MUTATION_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
28
38
  "metamask.perps.close_positions",
29
39
  "metamask.perps.close_orders",
30
40
  "metamask.perps.place_order",
41
+ "metamask.perps.edit_order",
31
42
  "metamask.perps.update_position_tpsl",
32
43
  "metamask.perps.ensure_positions",
33
44
  "metamask.perps.ensure_orders",
@@ -618,6 +618,60 @@ export function redactPosition(position) {
618
618
  };
619
619
  }
620
620
 
621
+ /**
622
+ * Wrap a controller rejection so its typed code survives as data.
623
+ *
624
+ * `expect_error` used to match by substring against the message, which meant a
625
+ * message that merely mentioned a code — or contained a longer code with the
626
+ * expected one as a prefix — matched wrongly. Carrying the code on the error
627
+ * lets the comparison be exact.
628
+ *
629
+ * @param params - Rejection details.
630
+ * @param params.action - Action name, for the message.
631
+ * @param params.detail - Human context for the message.
632
+ * @param params.code - The controller's typed error code.
633
+ * @returns An error carrying `perpsErrorCode`.
634
+ */
635
+ export function controllerRejection(params) {
636
+ const { action, detail, code } = params;
637
+ const error = new Error(`${action} was rejected: ${code}${detail ? ` (${detail})` : ''}`);
638
+ error.perpsErrorCode = code;
639
+ return error;
640
+ }
641
+
642
+ /**
643
+ * Read an optional node param, treating an unset template as absent.
644
+ *
645
+ * Recipes are static JSON, so an optional parameter that a caller leaves at its
646
+ * default arrives as an empty string rather than being omitted. Passing that
647
+ * through as a set-but-empty value is how a plain order ends up carrying an
648
+ * empty TP/SL size, which the controller then rejects as invalid. Whitespace is
649
+ * trimmed for the same reason.
650
+ *
651
+ * @param node - The recipe node.
652
+ * @param snake - snake_case param name.
653
+ * @param camel - camelCase alias.
654
+ * @returns The value as a string, or undefined when the node did not set it.
655
+ */
656
+ export function optionalParam(node, snake, camel) {
657
+ const normalize = (value) => {
658
+ if (value === undefined || value === null) {
659
+ return undefined;
660
+ }
661
+ const text = String(value).trim();
662
+ return text.length === 0 ? undefined : text;
663
+ };
664
+ return normalize(node?.[snake]) ?? normalize(node?.[camel]);
665
+ }
666
+
667
+ export function optionalBooleanParam(node, snake, camel) {
668
+ const value = optionalParam(node, snake, camel);
669
+ if (value === undefined) return undefined;
670
+ if (value.toLowerCase() === 'true') return true;
671
+ if (value.toLowerCase() === 'false') return false;
672
+ throw new Error(`${snake} must be true or false; got ${value}.`);
673
+ }
674
+
621
675
  export function redactOrder(order) {
622
676
  return {
623
677
  coin: order.coin ?? order.symbol ?? null,
@@ -691,16 +745,95 @@ async function disconnectController(exitCode) {
691
745
  }
692
746
  }
693
747
 
748
+ /**
749
+ * Read the rejection a node expects, if any.
750
+ *
751
+ * @param input - Adapter input (node.expect_error / node.expectError).
752
+ * @returns The expected error token, or undefined when the node expects success.
753
+ */
754
+ function expectedErrorFor(input) {
755
+ const node = input?.node ?? {};
756
+ for (const key of ['expect_error', 'expectError']) {
757
+ const value = node[key];
758
+ if (typeof value === 'string' && value.trim().length > 0) {
759
+ return value.trim();
760
+ }
761
+ }
762
+ return undefined;
763
+ }
764
+
765
+ /**
766
+ * Run a Core perps adapter, honouring an expected rejection.
767
+ *
768
+ * Most nodes assert that an operation succeeds. A controller's refusals are part
769
+ * of its contract too — that a partial size below the asset's precision, or a
770
+ * TP/SL linkage the venue cannot express, is rejected with a typed error and
771
+ * before any side effect. Without `expect_error` those can only be proven by a
772
+ * bespoke script, because every action here throws on failure.
773
+ *
774
+ * With `expect_error` set, a throw whose message carries the expected token is
775
+ * the passing outcome: the rejection is written out as evidence and the process
776
+ * exits 0. Anything else fails — a different error, or the operation succeeding
777
+ * when it should have been refused. The success case is checked explicitly
778
+ * rather than left to fall through, so a contract that silently stops rejecting
779
+ * is caught rather than reported as a pass.
780
+ *
781
+ * Actions surface a controller's `{ success: false, error }` result by throwing
782
+ * with the code in the message (see place_order.mjs), so a substring match
783
+ * covers both that shape and a genuine throw.
784
+ *
785
+ * When the param is absent this is byte-identical to the previous behaviour.
786
+ *
787
+ * @param callback - The adapter body, receiving the loaded input.
788
+ */
694
789
  export async function runAdapter(callback) {
695
790
  const input = await loadInput();
791
+ const expectedError = expectedErrorFor(input);
696
792
  let exitCode = 0;
697
793
  try {
698
- await writeOutput(input, await callback(input));
794
+ const output = await callback(input);
795
+ if (expectedError) {
796
+ // Not thrown: a rejection asserted here must not be satisfied by the
797
+ // message this branch would itself produce.
798
+ exitCode = 1;
799
+ process.stderr.write(
800
+ `[core/perps] ${input.action} expected rejection ${expectedError}, but it succeeded.\n`,
801
+ );
802
+ } else {
803
+ await writeOutput(input, output);
804
+ }
699
805
  } catch (error) {
700
- exitCode = 1;
701
- // Surface the failure; still tear down so a half-open WebSocket from a
702
- // failed write doesn't leave the process wedged open.
703
- process.stderr.write(`[core/perps] adapter failed: ${fmtError(error)}\n`);
806
+ const message = error?.message ?? String(error);
807
+ // A typed proof requires the code as data. Matching on the message was a
808
+ // weaker check wearing the same name: an adapter's own error, or any text
809
+ // that merely mentioned a code, could satisfy it. Only a rejection the
810
+ // action tagged with the controller's code counts, compared exactly — so a
811
+ // proof cannot pass on a failure the controller never issued.
812
+ const code = error?.perpsErrorCode;
813
+ if (expectedError && code === expectedError) {
814
+ await writeOutput(input, {
815
+ action: input.action,
816
+ source: 'expect_error',
817
+ rejected: true,
818
+ matched: true,
819
+ matchedOn: 'code',
820
+ expectedError,
821
+ actualErrorCode: code,
822
+ actualError: message,
823
+ });
824
+ } else {
825
+ exitCode = 1;
826
+ if (expectedError) {
827
+ process.stderr.write(
828
+ code === undefined
829
+ ? `[core/perps] ${input.action} expected rejection ${expectedError}, but the failure carries no controller error code — it did not come from the controller.\n`
830
+ : `[core/perps] ${input.action} expected rejection ${expectedError}, but the controller rejected with ${code}.\n`,
831
+ );
832
+ }
833
+ // Surface the failure; still tear down so a half-open WebSocket from a
834
+ // failed write doesn't leave the process wedged open.
835
+ process.stderr.write(`[core/perps] adapter failed: ${fmtError(error)}\n`);
836
+ }
704
837
  } finally {
705
838
  await disconnectController(exitCode);
706
839
  }
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  getCoreController,
3
3
  isDirectRun,
4
+ optionalParam,
4
5
  redactOrder,
5
6
  requireExplicitSelection,
6
7
  runAdapter,
@@ -19,6 +20,123 @@ import {
19
20
  // expect_size (proves a partial TP/SL quantity). They use the controller's own
20
21
  // field names (triggerOrderType, triggerPrice, orderType, reduceOnly, size).
21
22
 
23
+ // A count proves only how many orders exist, never that one is the linked TP/SL
24
+ // the caller asked for: two unrelated orders satisfy `expect_count: 2` just as
25
+ // well as a parent and its child. So assert the LINK the controller exposes —
26
+ // the parent's own takeProfitOrderId / stopLossOrderId — and that the id it
27
+ // names is really resting as a reduce-only trigger.
28
+ export function expectedChildLinks(input) {
29
+ const node = input.node ?? {};
30
+ const wanted = [];
31
+ const truthy = (value) =>
32
+ value !== undefined && value !== null && String(value).trim() !== '' &&
33
+ String(value).toLowerCase() !== 'false';
34
+ if (truthy(optionalParam(node, 'expect_take_profit_child', 'expectTakeProfitChild'))) {
35
+ wanted.push({ label: 'take profit', idField: 'takeProfitOrderId' });
36
+ }
37
+ if (truthy(optionalParam(node, 'expect_stop_loss_child', 'expectStopLossChild'))) {
38
+ wanted.push({ label: 'stop loss', idField: 'stopLossOrderId' });
39
+ }
40
+ return wanted;
41
+ }
42
+
43
+ export function assertChildLinks(matching, allOrders, wanted) {
44
+ const resolved = [];
45
+ if (wanted.length === 0) return resolved;
46
+
47
+ const hasLink = (order, idField) =>
48
+ order[idField] !== undefined && String(order[idField]).trim() !== '';
49
+ const parent = matching.find((order) =>
50
+ wanted.every(({ idField }) => hasLink(order, idField)),
51
+ );
52
+ if (!parent) {
53
+ throw new Error(
54
+ `Expected one matching order to expose ${wanted.map(({ idField }) => idField).join(' and ')}, but none did. Matching orders: ${JSON.stringify(matching.map((order) => ({ id: order.orderId, takeProfitOrderId: order.takeProfitOrderId, stopLossOrderId: order.stopLossOrderId })))}.`,
55
+ );
56
+ }
57
+
58
+ const normalizedSide = (order) => {
59
+ const value = String(order.side ?? order.dir ?? '').toLowerCase();
60
+ if (value === 'buy' || value === 'b' || value.includes('long')) return 'buy';
61
+ if (value === 'sell' || value === 'a' || value.includes('short')) return 'sell';
62
+ return undefined;
63
+ };
64
+ const parentSymbol = parent.symbol ?? parent.coin;
65
+ const parentSide = normalizedSide(parent);
66
+
67
+ for (const { label, idField } of wanted) {
68
+ const isTakeProfit = idField === 'takeProfitOrderId';
69
+ const priceField = isTakeProfit ? 'takeProfitPrice' : 'stopLossPrice';
70
+ const triggerFamily = isTakeProfit ? 'take_profit_' : 'stop_';
71
+ const childId = String(parent[idField]);
72
+ const child = allOrders.find(
73
+ (order) => String(order.orderId ?? order.oid) === childId,
74
+ );
75
+ if (!child) {
76
+ throw new Error(
77
+ `Order ${parent.orderId} names ${idField}=${childId} as its ${label} child, but no such order is live — the child was dropped after being linked.`,
78
+ );
79
+ }
80
+ if (child.reduceOnly !== true) {
81
+ throw new Error(
82
+ `The ${label} child ${childId} is live but not reduce-only, so it would open exposure rather than close it.`,
83
+ );
84
+ }
85
+ if (child.isTrigger !== true) {
86
+ throw new Error(
87
+ `The ${label} child ${childId} is live but is not a trigger order, so it cannot fire as a ${label}.`,
88
+ );
89
+ }
90
+ const childType = String(child.triggerOrderType ?? '').toLowerCase();
91
+ if (!childType.startsWith(triggerFamily)) {
92
+ throw new Error(
93
+ `The ${label} child ${childId} has triggerOrderType=${JSON.stringify(child.triggerOrderType)}, expected the ${triggerFamily} family.`,
94
+ );
95
+ }
96
+ const childSymbol = child.symbol ?? child.coin;
97
+ if (parentSymbol === undefined || childSymbol !== parentSymbol) {
98
+ throw new Error(
99
+ `The ${label} child ${childId} is on ${childSymbol ?? 'an unknown market'}, expected ${parentSymbol ?? 'the parent market'}.`,
100
+ );
101
+ }
102
+ const childSide = normalizedSide(child);
103
+ if (
104
+ parentSide === undefined ||
105
+ childSide === undefined ||
106
+ childSide === parentSide
107
+ ) {
108
+ throw new Error(
109
+ `The ${label} child ${childId} has side=${child.side ?? child.dir ?? 'unknown'}, expected the opposite of parent side=${parent.side ?? parent.dir ?? 'unknown'}.`,
110
+ );
111
+ }
112
+ const parentPrice = parent[priceField];
113
+ const childPrice = child.triggerPrice ?? child.triggerPx;
114
+ if (
115
+ parentPrice === undefined ||
116
+ childPrice === undefined ||
117
+ Number(parentPrice) !== Number(childPrice)
118
+ ) {
119
+ throw new Error(
120
+ `The ${label} child ${childId} has triggerPrice=${JSON.stringify(childPrice)}, expected the parent's ${priceField}=${JSON.stringify(parentPrice)}.`,
121
+ );
122
+ }
123
+ resolved.push({
124
+ link: idField,
125
+ parentOrderId: String(parent.orderId ?? parent.oid),
126
+ childOrderId: childId,
127
+ child: {
128
+ symbol: childSymbol,
129
+ side: childSide,
130
+ triggerOrderType: child.triggerOrderType,
131
+ triggerPrice: childPrice,
132
+ size: child.size ?? child.sz,
133
+ reduceOnly: child.reduceOnly,
134
+ },
135
+ });
136
+ }
137
+ return resolved;
138
+ }
139
+
22
140
  export function expectedOpen(input) {
23
141
  if (input.node?.state == null) throw new Error('metamask.perps.assert_orders requires state=open or state=none.');
24
142
  const state = String(input.node.state).toLowerCase();
@@ -40,23 +158,42 @@ export function expectedOpen(input) {
40
158
  * @returns True when only trigger orders should be considered.
41
159
  */
42
160
  export function onlyTriggerOrders(input) {
43
- const value = input.node?.only_trigger_orders ?? input.node?.onlyTriggerOrders;
161
+ const value = optionalParam(
162
+ input.node ?? {},
163
+ 'only_trigger_orders',
164
+ 'onlyTriggerOrders',
165
+ );
44
166
  return value === true || String(value).toLowerCase() === 'true';
45
167
  }
46
168
 
47
169
  export function expectedTriggerData(input) {
48
170
  const node = input.node ?? {};
171
+ const pick = (snake, camel) => {
172
+ const normalize = (value) =>
173
+ typeof value === 'string' && value.trim().length === 0
174
+ ? undefined
175
+ : value ?? undefined;
176
+ return normalize(node[snake]) ?? normalize(node[camel]);
177
+ };
49
178
  const expectations = {
50
- triggerOrderType:
51
- node.expect_trigger_order_type ?? node.expectTriggerOrderType,
52
- triggerPrice: node.expect_trigger_price ?? node.expectTriggerPrice,
53
- orderType: node.expect_execution ?? node.expectExecution,
54
- reduceOnly: node.expect_reduce_only ?? node.expectReduceOnly,
55
- size: node.expect_size ?? node.expectSize,
179
+ triggerOrderType: pick(
180
+ 'expect_trigger_order_type',
181
+ 'expectTriggerOrderType',
182
+ ),
183
+ triggerPrice: pick('expect_trigger_price', 'expectTriggerPrice'),
184
+ orderType: pick('expect_execution', 'expectExecution'),
185
+ reduceOnly: pick('expect_reduce_only', 'expectReduceOnly'),
186
+ size: pick('expect_size', 'expectSize'),
56
187
  };
188
+ // A blank expectation is an unset recipe template, not a demand that the
189
+ // value equal ''. Keeping it turned an unused param into an assertion against
190
+ // an empty string — which for a price compares as 0.
57
191
  return Object.fromEntries(
58
192
  Object.entries(expectations).filter(
59
- ([, value]) => value !== undefined && value !== null,
193
+ ([, value]) =>
194
+ value !== undefined &&
195
+ value !== null &&
196
+ !(typeof value === 'string' && value.trim().length === 0),
60
197
  ),
61
198
  );
62
199
  }
@@ -103,8 +240,15 @@ export function assertTriggerData(orders, expectations) {
103
240
  export async function assertOrders(input, expectOpen = expectedOpen(input)) {
104
241
  requireExplicitSelection(input);
105
242
  const { controller, accountAddress, network } = await getCoreController(input);
106
- const timeoutMs = Number(input.node?.timeout_ms ?? 0);
243
+ const timeoutMs = Number(
244
+ optionalParam(input.node ?? {}, 'timeout_ms', 'timeoutMs') ?? 0,
245
+ );
107
246
  const deadline = Date.now() + Math.max(0, timeoutMs);
247
+ const expectedCount = optionalParam(
248
+ input.node ?? {},
249
+ 'expect_count',
250
+ 'expectCount',
251
+ );
108
252
  let orders;
109
253
  let matching;
110
254
  while (true) {
@@ -118,7 +262,13 @@ export async function assertOrders(input, expectOpen = expectedOpen(input)) {
118
262
  // applied to an unrelated parent order resting alongside them.
119
263
  matching = matching.filter((order) => order.isTrigger === true);
120
264
  }
121
- if (expectOpen ? matching.length > 0 : matching.length === 0) break;
265
+ // When an exact count is demanded, stopping at the first positive result
266
+ // reads a mid-replace moment as final: the previous order may not have been
267
+ // swept yet, or the new one not yet placed. Wait for the count itself.
268
+ const reached = expectedCount === undefined
269
+ ? matching.length > 0
270
+ : matching.length === Number(expectedCount);
271
+ if (expectOpen ? reached : matching.length === 0) break;
122
272
  if (Date.now() >= deadline) break;
123
273
  await new Promise((resolve) =>
124
274
  setTimeout(resolve, Math.min(500, Math.max(1, deadline - Date.now()))),
@@ -130,9 +280,20 @@ export async function assertOrders(input, expectOpen = expectedOpen(input)) {
130
280
  throw new Error('Expected at least one matching open Perps order, but found none.');
131
281
  }
132
282
 
283
+ // "at least one" cannot distinguish a clean replace from one that added a
284
+ // second order beside the first, so a caller can pin the exact count.
285
+ if (expectedCount !== undefined && matching.length !== Number(expectedCount)) {
286
+ throw new Error(
287
+ `Expected exactly ${expectedCount} matching open Perps order(s), but found ${matching.length}.`,
288
+ );
289
+ }
290
+
133
291
  const triggerExpectations = expectedTriggerData(input);
292
+ const childLinks = expectedChildLinks(input);
293
+ let resolvedChildLinks = [];
134
294
  if (expectOpen) {
135
295
  assertTriggerData(matching, triggerExpectations);
296
+ resolvedChildLinks = assertChildLinks(matching, orders, childLinks);
136
297
  }
137
298
  if (!expectOpen && hasOrder) {
138
299
  throw new Error(`Expected no matching open Perps orders, but found ${matching.length}.`);
@@ -146,7 +307,10 @@ export async function assertOrders(input, expectOpen = expectedOpen(input)) {
146
307
  expectedOpen: expectOpen,
147
308
  expectedTrigger:
148
309
  Object.keys(triggerExpectations).length === 0 ? null : triggerExpectations,
310
+ expectedChildLinks: childLinks.map((link) => link.idField),
311
+ resolvedChildLinks,
149
312
  matchingCount: matching.length,
313
+ expectedCount: expectedCount === undefined ? null : Number(expectedCount),
150
314
  orders: matching.map(redactOrder),
151
315
  proofPath: 'perps-controller-getOpenOrders',
152
316
  };
@@ -2,6 +2,7 @@ import {
2
2
  getCoreController,
3
3
  isDirectRun,
4
4
  redactPosition,
5
+ optionalParam,
5
6
  requireExplicitSelection,
6
7
  runAdapter,
7
8
  selectedItems,
@@ -22,8 +23,15 @@ export function expectedOpen(input) {
22
23
  export async function assertPositions(input, expectOpen = expectedOpen(input)) {
23
24
  requireExplicitSelection(input);
24
25
  const { controller, accountAddress, network } = await getCoreController(input);
25
- const timeoutMs = Number(input.node?.timeout_ms ?? 0);
26
+ const timeoutMs = Number(
27
+ optionalParam(input.node ?? {}, 'timeout_ms', 'timeoutMs') ?? 0,
28
+ );
26
29
  const deadline = Date.now() + Math.max(0, timeoutMs);
30
+ const expectedCount = optionalParam(
31
+ input.node ?? {},
32
+ 'expect_count',
33
+ 'expectCount',
34
+ );
27
35
  let positions;
28
36
  let matching;
29
37
  while (true) {
@@ -32,7 +40,10 @@ export async function assertPositions(input, expectOpen = expectedOpen(input)) {
32
40
  userAddress: accountAddress,
33
41
  });
34
42
  matching = selectedItems(input, positions);
35
- if (expectOpen ? matching.length > 0 : matching.length === 0) break;
43
+ const reached = expectedCount === undefined
44
+ ? matching.length > 0
45
+ : matching.length === Number(expectedCount);
46
+ if (expectOpen ? reached : matching.length === 0) break;
36
47
  if (Date.now() >= deadline) break;
37
48
  await new Promise((resolve) =>
38
49
  setTimeout(resolve, Math.min(500, Math.max(1, deadline - Date.now()))),
@@ -43,6 +54,12 @@ export async function assertPositions(input, expectOpen = expectedOpen(input)) {
43
54
  if (expectOpen && !hasPosition) {
44
55
  throw new Error('Expected at least one matching open Perps position, but found none.');
45
56
  }
57
+
58
+ if (expectedCount !== undefined && matching.length !== Number(expectedCount)) {
59
+ throw new Error(
60
+ `Expected exactly ${expectedCount} matching open Perps position(s), but found ${matching.length}.`,
61
+ );
62
+ }
46
63
  if (!expectOpen && hasPosition) {
47
64
  throw new Error(`Expected no matching open Perps positions, but found ${matching.length}.`);
48
65
  }
@@ -54,6 +71,7 @@ export async function assertPositions(input, expectOpen = expectedOpen(input)) {
54
71
  account: accountAddress,
55
72
  expectedOpen: expectOpen,
56
73
  matchingCount: matching.length,
74
+ expectedCount: expectedCount === undefined ? null : Number(expectedCount),
57
75
  positions: matching.map(redactPosition),
58
76
  proofPath: 'perps-controller-getPositions',
59
77
  };