@deeeed/metamask-harness 0.23.1 → 0.25.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 (33) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/adapters/extension/live.sh +2 -2
  3. package/adapters/extension/wallet-fixture-state.cjs +57 -19
  4. package/adapters/shared/ensure-runner-deps.sh +64 -5
  5. package/bin/mm-harness +26 -8
  6. package/dist/adapters/extension/runtime.js +1 -10
  7. package/dist/adapters.js +5 -1
  8. package/dist/commands/run-engine.js +18 -13
  9. package/dist/commands/runtime-launch.js +7 -2
  10. package/dist/live-adapter-contract.js +4 -2
  11. package/dist/recipe-security.js +10 -2
  12. package/dist/run-recording.js +156 -39
  13. package/library/actions/core/perps/_controller.mjs +138 -5
  14. package/library/actions/core/perps/assert_orders.mjs +174 -10
  15. package/library/actions/core/perps/assert_positions.mjs +20 -2
  16. package/library/actions/core/perps/close_orders.mjs +24 -5
  17. package/library/actions/core/perps/close_positions.mjs +22 -8
  18. package/library/actions/core/perps/edit_order.mjs +331 -0
  19. package/library/actions/core/perps/place_order.mjs +192 -43
  20. package/library/actions/core/perps/update_position_tpsl.mjs +121 -15
  21. package/library/actions/extension/analytics/set_consent.mjs +165 -0
  22. package/library/actions/extension/platform/cdp.mjs +112 -14
  23. package/library/actions/mobile/analytics/set_consent.mjs +90 -0
  24. package/library/actions/shared/analytics/_adapter.mjs +24 -0
  25. package/library/actions/shared/analytics/assert_events.mjs +168 -0
  26. package/library/actions/shared/analytics/collector.mjs +505 -0
  27. package/library/actions/shared/analytics/consent.mjs +14 -0
  28. package/library/actions/shared/analytics/read_events.mjs +22 -0
  29. package/library/actions/shared/analytics/start_capture.mjs +24 -0
  30. package/library/manifests/core.action-manifest.json +468 -27
  31. package/library/manifests/extension.action-manifest.json +188 -1
  32. package/library/manifests/mobile.action-manifest.json +161 -0
  33. package/package.json +3 -3
@@ -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
  };
@@ -1,4 +1,5 @@
1
1
  import {
2
+ controllerRejection,
2
3
  getCoreControllerWithSigner,
3
4
  isDirectRun,
4
5
  redactOrder,
@@ -6,6 +7,7 @@ import {
6
7
  runAdapter,
7
8
  selectedItems,
8
9
  symbolForItem,
10
+ optionalParam,
9
11
  } from './_controller.mjs';
10
12
 
11
13
  // core Cancel selected live Perps open orders on HyperLiquid testnet by driving
@@ -37,10 +39,24 @@ async function waitForOrdersAbsent(controller, accountAddress, symbols, timeoutM
37
39
  }
38
40
  }
39
41
 
42
+ export function cancelOrdersFailure(result) {
43
+ if (!result) return 'unknown error';
44
+ const failed = Array.isArray(result.results)
45
+ ? result.results.find((entry) => entry?.success !== true)
46
+ : undefined;
47
+ const hasFailure =
48
+ result.success !== true ||
49
+ Number(result.failureCount ?? 0) > 0 ||
50
+ failed !== undefined;
51
+ return hasFailure ? failed?.error ?? 'unknown error' : undefined;
52
+ }
53
+
40
54
  export async function closeOrders(input) {
41
55
  requireExplicitSelection(input);
42
56
  const { controller, accountAddress, network } = await getCoreControllerWithSigner(input);
43
- const timeoutMs = Number(input.node?.timeout_ms ?? 30000);
57
+ const timeoutMs = Number(
58
+ optionalParam(input.node ?? {}, 'timeout_ms', 'timeoutMs') ?? 30000,
59
+ );
44
60
 
45
61
  const orders = await controller.getOpenOrders({
46
62
  standalone: true,
@@ -66,10 +82,13 @@ export async function closeOrders(input) {
66
82
  // Cancel by the selected symbols. cancelOrders() filters the active provider's
67
83
  // open orders to these symbols and batch-cancels them on the exchange.
68
84
  const result = await controller.cancelOrders({ symbols });
69
- if (!result || result.success !== true) {
70
- throw new Error(
71
- `core cancelOrders failed for ${JSON.stringify(symbols)}: ${JSON.stringify(result)}`,
72
- );
85
+ const failure = cancelOrdersFailure(result);
86
+ if (failure !== undefined) {
87
+ throw controllerRejection({
88
+ action: 'core cancelOrders',
89
+ detail: `${JSON.stringify(symbols)}: ${JSON.stringify(result)}`,
90
+ code: failure,
91
+ });
73
92
  }
74
93
 
75
94
  const after = await waitForOrdersAbsent(controller, accountAddress, symbols, timeoutMs);
@@ -1,4 +1,5 @@
1
1
  import {
2
+ controllerRejection,
2
3
  currentMarketPrice,
3
4
  getCoreControllerWithSigner,
4
5
  isDirectRun,
@@ -7,6 +8,7 @@ import {
7
8
  runAdapter,
8
9
  selectedItems,
9
10
  symbolForItem,
11
+ optionalParam,
10
12
  } from './_controller.mjs';
11
13
 
12
14
  // core Close selected live Perps positions on HyperLiquid testnet by driving the
@@ -36,12 +38,23 @@ async function waitForPositionsAbsent(controller, accountAddress, symbols, timeo
36
38
  }
37
39
  }
38
40
 
41
+ export function closePositionsFailure(results) {
42
+ const failed = results.find((entry) => entry?.success !== true);
43
+ return failed ? failed.result?.error ?? 'unknown error' : undefined;
44
+ }
45
+
39
46
  export async function closePositions(input) {
40
47
  requireExplicitSelection(input);
41
48
  const { controller, accountAddress, network } = await getCoreControllerWithSigner(input);
42
- const timeoutMs = Number(input.node?.timeout_ms ?? 30000);
49
+ const timeoutMs = Number(
50
+ optionalParam(input.node ?? {}, 'timeout_ms', 'timeoutMs') ?? 30000,
51
+ );
43
52
  const maxSlippageBps = Number(
44
- input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300,
53
+ optionalParam(
54
+ input.node ?? {},
55
+ 'max_slippage_bps',
56
+ 'maxSlippageBps',
57
+ ) ?? 300,
45
58
  );
46
59
 
47
60
  const positions = await controller.getPositions({
@@ -86,12 +99,13 @@ export async function closePositions(input) {
86
99
  if (success) successCount += 1;
87
100
  }
88
101
 
89
- // A wholesale close failure must surface even if a read race shows the
90
- // positions absent mirrors close_orders, which throws on success !== true.
91
- if (matching.length > 0 && successCount === 0) {
92
- throw new Error(
93
- `All ${matching.length} closePosition call(s) reported failure (successCount=0): ${JSON.stringify(results)}`,
94
- );
102
+ const failure = closePositionsFailure(results);
103
+ if (failure !== undefined) {
104
+ throw controllerRejection({
105
+ action: 'core closePosition',
106
+ detail: `${matching.length} call(s): ${JSON.stringify(results)}`,
107
+ code: failure,
108
+ });
95
109
  }
96
110
 
97
111
  const after = await waitForPositionsAbsent(controller, accountAddress, symbols, timeoutMs);