@deeeed/metamask-harness 0.22.0 → 0.23.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,17 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.23.0 - 2026-07-28
6
+
7
+ ### Added
8
+
9
+ - Core Perps actions support trigger orders, attached and partial TP/SL, and position TP/SL updates.
10
+
11
+ ### Fixed
12
+
13
+ - Core advanced-order mutations verify the submitted order fields, use executable trigger-limit defaults, honor bounded evidence polling, and classify mutation capabilities correctly.
14
+ - Core advanced-order camelCase aliases validate consistently with their documented snake_case forms.
15
+
5
16
  ## 0.22.0 - 2026-07-26
6
17
 
7
18
  ### Added
@@ -18,6 +18,7 @@ const APP_MUTATION_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
18
18
  "metamask.perps.close_positions",
19
19
  "metamask.perps.close_orders",
20
20
  "metamask.perps.place_order",
21
+ "metamask.perps.update_position_tpsl",
21
22
  "metamask.perps.ensure_positions",
22
23
  "metamask.perps.ensure_orders",
23
24
  "metamask.perps.start_state",
@@ -27,6 +28,7 @@ const EXTERNAL_MUTATION_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
27
28
  "metamask.perps.close_positions",
28
29
  "metamask.perps.close_orders",
29
30
  "metamask.perps.place_order",
31
+ "metamask.perps.update_position_tpsl",
30
32
  "metamask.perps.ensure_positions",
31
33
  "metamask.perps.ensure_orders",
32
34
  "metamask.perps.start_state",
@@ -625,6 +625,13 @@ export function redactOrder(order) {
625
625
  size: order.size ?? order.sz ?? order.szi ?? null,
626
626
  price: order.price ?? order.limitPx ?? order.px ?? null,
627
627
  type: order.orderType ?? order.type ?? null,
628
+ // Trigger data, so evidence shows what a stop / take-profit placement
629
+ // actually round-tripped from the exchange.
630
+ triggerOrderType: order.triggerOrderType ?? null,
631
+ triggerPrice: order.triggerPrice ?? order.triggerPx ?? null,
632
+ detailedOrderType: order.detailedOrderType ?? null,
633
+ isTrigger: order.isTrigger ?? null,
634
+ reduceOnly: order.reduceOnly ?? null,
628
635
  };
629
636
  }
630
637
 
@@ -11,6 +11,13 @@ import {
11
11
  // over the controller's standalone getOpenOrders path (no signer / provider init
12
12
  // needed) — throws on mismatch so the recipe fails loudly. Mirrors
13
13
  // assert_positions.mjs.
14
+ //
15
+ // Optional expect_* fields additionally assert the TRIGGER DATA the exchange
16
+ // round-tripped for every matching order: expect_trigger_order_type (the
17
+ // normalized placement type, e.g. stop_market), expect_trigger_price,
18
+ // expect_execution (market | limit once triggered), expect_reduce_only, and
19
+ // expect_size (proves a partial TP/SL quantity). They use the controller's own
20
+ // field names (triggerOrderType, triggerPrice, orderType, reduceOnly, size).
14
21
 
15
22
  export function expectedOpen(input) {
16
23
  if (input.node?.state == null) throw new Error('metamask.perps.assert_orders requires state=open or state=none.');
@@ -20,6 +27,79 @@ export function expectedOpen(input) {
20
27
  throw new Error(`metamask.perps.assert_orders received unsupported state: ${state}`);
21
28
  }
22
29
 
30
+ /**
31
+ * Collect the optional trigger-data expectations from the node.
32
+ *
33
+ * @param input - Adapter input.
34
+ * @returns The expectations the node set, keyed by controller field name.
35
+ */
36
+ /**
37
+ * Whether the node narrowed matching to trigger orders only.
38
+ *
39
+ * @param input - Adapter input.
40
+ * @returns True when only trigger orders should be considered.
41
+ */
42
+ export function onlyTriggerOrders(input) {
43
+ const value = input.node?.only_trigger_orders ?? input.node?.onlyTriggerOrders;
44
+ return value === true || String(value).toLowerCase() === 'true';
45
+ }
46
+
47
+ export function expectedTriggerData(input) {
48
+ const node = input.node ?? {};
49
+ 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,
56
+ };
57
+ return Object.fromEntries(
58
+ Object.entries(expectations).filter(
59
+ ([, value]) => value !== undefined && value !== null,
60
+ ),
61
+ );
62
+ }
63
+
64
+ /**
65
+ * Assert every matching order carries the expected trigger data.
66
+ *
67
+ * @param orders - Matching open orders.
68
+ * @param expectations - Expectations from `expectedTriggerData`.
69
+ */
70
+ export function assertTriggerData(orders, expectations) {
71
+ const fields = Object.keys(expectations);
72
+ if (fields.length === 0) return;
73
+
74
+ for (const order of orders) {
75
+ for (const field of fields) {
76
+ const expected = expectations[field];
77
+ const actual = order[field];
78
+ const expectedNumber = Number(expected);
79
+ const actualNumber = Number(actual);
80
+ const numeric =
81
+ typeof expected !== 'boolean' &&
82
+ expected !== '' &&
83
+ actual !== null &&
84
+ actual !== undefined &&
85
+ Number.isFinite(expectedNumber) &&
86
+ Number.isFinite(actualNumber);
87
+ const matches =
88
+ typeof expected === 'boolean'
89
+ ? Boolean(actual) === expected
90
+ : // Prices and sizes round-trip with exchange formatting
91
+ // ('44000' -> '44000.0'), so compare them numerically.
92
+ (numeric && expectedNumber === actualNumber) ||
93
+ String(actual) === String(expected);
94
+ if (!matches) {
95
+ throw new Error(
96
+ `Open Perps order ${order.orderId ?? '?'} (${order.symbol ?? '?'}) has ${field}=${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}.`,
97
+ );
98
+ }
99
+ }
100
+ }
101
+ }
102
+
23
103
  export async function assertOrders(input, expectOpen = expectedOpen(input)) {
24
104
  requireExplicitSelection(input);
25
105
  const { controller, accountAddress, network } = await getCoreController(input);
@@ -33,6 +113,11 @@ export async function assertOrders(input, expectOpen = expectedOpen(input)) {
33
113
  userAddress: accountAddress,
34
114
  });
35
115
  matching = selectedItems(input, orders);
116
+ if (onlyTriggerOrders(input)) {
117
+ // Narrow to the trigger orders on the market so expectations are not
118
+ // applied to an unrelated parent order resting alongside them.
119
+ matching = matching.filter((order) => order.isTrigger === true);
120
+ }
36
121
  if (expectOpen ? matching.length > 0 : matching.length === 0) break;
37
122
  if (Date.now() >= deadline) break;
38
123
  await new Promise((resolve) =>
@@ -44,6 +129,11 @@ export async function assertOrders(input, expectOpen = expectedOpen(input)) {
44
129
  if (expectOpen && !hasOrder) {
45
130
  throw new Error('Expected at least one matching open Perps order, but found none.');
46
131
  }
132
+
133
+ const triggerExpectations = expectedTriggerData(input);
134
+ if (expectOpen) {
135
+ assertTriggerData(matching, triggerExpectations);
136
+ }
47
137
  if (!expectOpen && hasOrder) {
48
138
  throw new Error(`Expected no matching open Perps orders, but found ${matching.length}.`);
49
139
  }
@@ -54,6 +144,8 @@ export async function assertOrders(input, expectOpen = expectedOpen(input)) {
54
144
  network,
55
145
  account: accountAddress,
56
146
  expectedOpen: expectOpen,
147
+ expectedTrigger:
148
+ Object.keys(triggerExpectations).length === 0 ? null : triggerExpectations,
57
149
  matchingCount: matching.length,
58
150
  orders: matching.map(redactOrder),
59
151
  proofPath: 'perps-controller-getOpenOrders',
@@ -27,6 +27,17 @@ import {
27
27
  // on-exchange notional (size * limitPrice) clears HyperLiquid's ~$10 minimum even
28
28
  // though the limit sits far from mid. After placing we verify the resting open
29
29
  // order exists (not a filled position).
30
+ //
31
+ // TRIGGER placements (order_type=stop_market | stop_limit | take_profit_market |
32
+ // take_profit_limit) rest off-book until `trigger_price` is reached, then execute
33
+ // as a market or limit order per the suffix. `trigger_price` is absolute, or
34
+ // derived from mid via `trigger_offset_pct`; `*_limit` types also take the
35
+ // execution limit price (`limit_price` / `offset_pct`). Like limit orders they are
36
+ // verified as RESTING open orders. `reduce_only` is passed through as a
37
+ // first-class placement flag, and `take_profit_price`/`take_profit_size` +
38
+ // `stop_loss_price`/`stop_loss_size` (with `tpsl_linkage`) exercise attached and
39
+ // partial TP/SL. Controller param names are used verbatim (triggerPrice,
40
+ // reduceOnly, takeProfitSize, stopLossSize, tpslLinkage).
30
41
 
31
42
  function resolveSymbol(input) {
32
43
  const symbols = configuredSymbols(input, []);
@@ -38,16 +49,105 @@ function resolveSymbol(input) {
38
49
  return symbols[0];
39
50
  }
40
51
 
52
+ const ORDER_TYPES = [
53
+ 'market',
54
+ 'limit',
55
+ 'stop_market',
56
+ 'stop_limit',
57
+ 'take_profit_market',
58
+ 'take_profit_limit',
59
+ ];
60
+
61
+ const TRIGGER_ORDER_TYPES = ORDER_TYPES.filter((type) => type.includes('_'));
62
+
63
+ // Order types whose price field is a real limit price the exchange must honour.
64
+ const LIMIT_EXECUTION_ORDER_TYPES = ['limit', 'stop_limit', 'take_profit_limit'];
65
+
41
66
  function resolveOrderType(input) {
42
67
  const raw = String(
43
68
  input.node?.order_type ?? input.node?.orderType ?? 'market',
44
69
  ).toLowerCase();
45
- if (raw !== 'market' && raw !== 'limit') {
46
- throw new Error(`metamask.perps.place_order received unsupported order_type: ${raw}.`);
70
+ if (!ORDER_TYPES.includes(raw)) {
71
+ throw new Error(
72
+ `metamask.perps.place_order received unsupported order_type: ${raw}. Supported: ${ORDER_TYPES.join(' | ')}.`,
73
+ );
47
74
  }
48
75
  return raw;
49
76
  }
50
77
 
78
+ const isTriggerOrderType = (orderType) => TRIGGER_ORDER_TYPES.includes(orderType);
79
+ const isLimitExecution = (orderType) => LIMIT_EXECUTION_ORDER_TYPES.includes(orderType);
80
+
81
+ /**
82
+ * Resolve the trigger price for a stop / take-profit placement.
83
+ * Precedence: absolute `trigger_price` → `trigger_offset_pct` from mid.
84
+ * Defaults keep the trigger far from mid so it will not fire during the proof:
85
+ * a stop sits below mid for a long (sell-side protection) and above for a short;
86
+ * a take profit sits on the opposite side.
87
+ *
88
+ * @param input - Adapter input (node.trigger_price / node.trigger_offset_pct).
89
+ * @param orderType - Resolved placement type.
90
+ * @param isBuy - Order direction.
91
+ * @param mid - Current mid price.
92
+ */
93
+ function resolveTriggerPrice(input, orderType, isBuy, mid) {
94
+ const absolute = input.node?.trigger_price ?? input.node?.triggerPrice;
95
+ if (absolute !== undefined && absolute !== null && String(absolute).length > 0) {
96
+ const numeric = Number(absolute);
97
+ if (!Number.isFinite(numeric) || numeric <= 0) {
98
+ throw new Error(
99
+ `metamask.perps.place_order received invalid trigger_price: ${absolute}.`,
100
+ );
101
+ }
102
+ return numeric;
103
+ }
104
+ const rawOffset = input.node?.trigger_offset_pct ?? input.node?.triggerOffsetPct;
105
+ const isStop = orderType.startsWith('stop');
106
+ // A stop fires against the position: below mid when selling to close a long.
107
+ const defaultOffset = isStop === isBuy ? 30 : -30;
108
+ const offsetPct =
109
+ rawOffset === undefined || rawOffset === null ? defaultOffset : Number(rawOffset);
110
+ if (!Number.isFinite(offsetPct)) {
111
+ throw new Error(
112
+ `metamask.perps.place_order received invalid trigger_offset_pct: ${rawOffset}.`,
113
+ );
114
+ }
115
+ const price = mid * (1 + offsetPct / 100);
116
+ if (!Number.isFinite(price) || price <= 0) {
117
+ throw new Error(
118
+ `metamask.perps.place_order computed a non-positive trigger price (${price}).`,
119
+ );
120
+ }
121
+ return price;
122
+ }
123
+
124
+ /**
125
+ * Collect the attached TP/SL fields, using the controller's own param names.
126
+ *
127
+ * @param input - Adapter input.
128
+ * @returns Attached TP/SL params, omitting anything the node did not set.
129
+ */
130
+ function resolveAttachedTpsl(input) {
131
+ const node = input.node ?? {};
132
+ const pick = (snake, camel) => {
133
+ const value = node[snake] ?? node[camel];
134
+ return value === undefined || value === null ? undefined : String(value);
135
+ };
136
+ const attached = {
137
+ takeProfitPrice: pick('take_profit_price', 'takeProfitPrice'),
138
+ takeProfitSize: pick('take_profit_size', 'takeProfitSize'),
139
+ stopLossPrice: pick('stop_loss_price', 'stopLossPrice'),
140
+ stopLossSize: pick('stop_loss_size', 'stopLossSize'),
141
+ };
142
+ const linkage = node.tpsl_linkage ?? node.tpslLinkage;
143
+ if (linkage !== undefined && linkage !== null) {
144
+ attached.tpslLinkage = String(linkage);
145
+ }
146
+ return Object.fromEntries(
147
+ Object.entries(attached).filter(([, value]) => value !== undefined),
148
+ );
149
+ }
150
+
51
151
  /**
52
152
  * Resolve the resting limit price for a limit order.
53
153
  * Precedence: explicit absolute `limit_price` → `offset_pct` from mid.
@@ -80,6 +180,89 @@ function resolveLimitPrice(input, isBuy, mid) {
80
180
  return price;
81
181
  }
82
182
 
183
+ export function resolveTriggerLimitPrice(input, isBuy, triggerPrice) {
184
+ const node = input.node ?? {};
185
+ const hasExplicitPrice =
186
+ node.limit_price !== undefined ||
187
+ node.limitPrice !== undefined ||
188
+ node.price !== undefined;
189
+ const hasExplicitOffset =
190
+ node.offset_pct !== undefined || node.offsetPct !== undefined;
191
+ if (hasExplicitPrice || hasExplicitOffset) {
192
+ return resolveLimitPrice(input, isBuy, triggerPrice);
193
+ }
194
+
195
+ return triggerPrice * (isBuy ? 1.01 : 0.99);
196
+ }
197
+
198
+ function numericValuesMatch(actual, expected) {
199
+ const actualNumber = Number(actual);
200
+ const expectedNumber = Number(expected);
201
+ if (!Number.isFinite(actualNumber) || !Number.isFinite(expectedNumber)) {
202
+ return String(actual) === String(expected);
203
+ }
204
+ const tolerance = Math.max(1e-12, Math.abs(expectedNumber) * 1e-4);
205
+ return Math.abs(actualNumber - expectedNumber) <= tolerance;
206
+ }
207
+
208
+ export function orderMatchesPlacement(order, expected) {
209
+ const orderId = order.orderId ?? order.oid ?? order.id;
210
+ if (
211
+ expected.orderId !== undefined &&
212
+ expected.orderId !== null &&
213
+ String(orderId) !== String(expected.orderId)
214
+ ) {
215
+ return false;
216
+ }
217
+ if (expected.triggerOrderType) {
218
+ if (order.isTrigger !== true) return false;
219
+ if (order.triggerOrderType !== expected.triggerOrderType) return false;
220
+ if (!numericValuesMatch(order.triggerPrice ?? order.triggerPx, expected.triggerPrice)) {
221
+ return false;
222
+ }
223
+ } else if (order.isTrigger === true) {
224
+ return false;
225
+ }
226
+ if (
227
+ expected.limitPrice !== null &&
228
+ expected.limitPrice !== undefined &&
229
+ !numericValuesMatch(order.price ?? order.limitPx ?? order.px, expected.limitPrice)
230
+ ) {
231
+ return false;
232
+ }
233
+ if (
234
+ expected.size !== undefined &&
235
+ !numericValuesMatch(order.size ?? order.sz ?? order.szi, expected.size)
236
+ ) {
237
+ return false;
238
+ }
239
+ return Boolean(order.reduceOnly) === Boolean(expected.reduceOnly);
240
+ }
241
+
242
+ async function waitForPlacedOrder(
243
+ controller,
244
+ accountAddress,
245
+ input,
246
+ expected,
247
+ timeoutMs,
248
+ ) {
249
+ const deadline = Date.now() + Math.max(0, timeoutMs);
250
+ let matching = [];
251
+ for (;;) {
252
+ const openOrders = await controller.getOpenOrders({
253
+ standalone: true,
254
+ userAddress: accountAddress,
255
+ });
256
+ matching = selectedItems(input, openOrders).filter((order) =>
257
+ orderMatchesPlacement(order, expected),
258
+ );
259
+ if (matching.length > 0 || Date.now() >= deadline) return matching;
260
+ await new Promise((resolve) =>
261
+ setTimeout(resolve, Math.min(500, Math.max(1, deadline - Date.now()))),
262
+ );
263
+ }
264
+ }
265
+
83
266
  export async function placeOrder(input) {
84
267
  const symbol = resolveSymbol(input);
85
268
  if (input.node?.side == null) throw new Error('metamask.perps.place_order requires side=long or side=short.');
@@ -95,6 +278,34 @@ export async function placeOrder(input) {
95
278
  const maxSlippageBps = Number(
96
279
  input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300,
97
280
  );
281
+ const postOnly = input.node?.post_only ?? input.node?.postOnly;
282
+ const requestedTimeInForce =
283
+ input.node?.time_in_force ?? input.node?.timeInForce;
284
+ if (
285
+ orderType !== 'limit' &&
286
+ (postOnly !== undefined || requestedTimeInForce !== undefined)
287
+ ) {
288
+ throw new Error(
289
+ 'metamask.perps.place_order supports time_in_force/post_only only for order_type=limit.',
290
+ );
291
+ }
292
+ const timeInForce =
293
+ orderType === 'limit'
294
+ ? String(requestedTimeInForce ?? (postOnly ? 'ALO' : 'GTC')).toUpperCase()
295
+ : null;
296
+ if (timeInForce !== null && !['GTC', 'ALO'].includes(timeInForce)) {
297
+ throw new Error(
298
+ `metamask.perps.place_order received unsupported time_in_force: ${timeInForce}. Supported: GTC | ALO.`,
299
+ );
300
+ }
301
+ if (
302
+ postOnly !== undefined &&
303
+ Boolean(postOnly) !== (timeInForce === 'ALO')
304
+ ) {
305
+ throw new Error(
306
+ 'metamask.perps.place_order received conflicting post_only and time_in_force values.',
307
+ );
308
+ }
98
309
 
99
310
  const usdNumeric = Number(usdAmount);
100
311
  if (!Number.isFinite(usdNumeric) || usdNumeric <= 0) {
@@ -106,9 +317,38 @@ export async function placeOrder(input) {
106
317
 
107
318
  const currentPrice = await currentMarketPrice(controller, symbol);
108
319
 
320
+ const reduceOnly =
321
+ input.node?.reduce_only ?? input.node?.reduceOnly ?? undefined;
322
+ const attachedTpsl = resolveAttachedTpsl(input);
323
+
109
324
  let orderParams;
110
325
  let limitPrice = null;
111
- if (orderType === 'limit') {
326
+ let triggerPrice = null;
327
+ if (isTriggerOrderType(orderType)) {
328
+ triggerPrice = resolveTriggerPrice(input, orderType, isBuy, currentPrice);
329
+ if (isLimitExecution(orderType)) {
330
+ limitPrice = resolveTriggerLimitPrice(input, isBuy, triggerPrice);
331
+ }
332
+ // A trigger placement rests off-book and executes near the trigger, so size
333
+ // from the trigger price (or the execution limit price) to clear HL's ~$10
334
+ // minimum; omit usdAmount so the controller keeps the explicit size.
335
+ const sizeAnchor = limitPrice ?? triggerPrice;
336
+ const size = ((usdNumeric * leverage) / sizeAnchor).toString();
337
+ orderParams = {
338
+ symbol,
339
+ isBuy,
340
+ size,
341
+ orderType,
342
+ triggerPrice: String(triggerPrice),
343
+ ...(limitPrice === null ? {} : { price: String(limitPrice) }),
344
+ leverage,
345
+ currentPrice,
346
+ priceAtCalculation: currentPrice,
347
+ maxSlippageBps,
348
+ ...(reduceOnly === undefined ? {} : { reduceOnly: Boolean(reduceOnly) }),
349
+ ...attachedTpsl,
350
+ };
351
+ } else if (orderType === 'limit') {
112
352
  limitPrice = resolveLimitPrice(input, isBuy, currentPrice);
113
353
  // Size from the LIMIT price so size * limitPrice clears HL's ~$10 minimum;
114
354
  // omit usdAmount so the controller does NOT recompute size from mid.
@@ -119,11 +359,13 @@ export async function placeOrder(input) {
119
359
  size,
120
360
  orderType: 'limit',
121
361
  price: String(limitPrice),
122
- timeInForce: 'GTC',
362
+ timeInForce,
123
363
  leverage,
124
364
  currentPrice,
125
365
  priceAtCalculation: currentPrice,
126
366
  maxSlippageBps,
367
+ ...(reduceOnly === undefined ? {} : { reduceOnly: Boolean(reduceOnly) }),
368
+ ...attachedTpsl,
127
369
  };
128
370
  } else {
129
371
  const size = ((usdNumeric * leverage) / currentPrice).toString();
@@ -137,6 +379,8 @@ export async function placeOrder(input) {
137
379
  currentPrice,
138
380
  priceAtCalculation: currentPrice,
139
381
  maxSlippageBps,
382
+ ...(reduceOnly === undefined ? {} : { reduceOnly: Boolean(reduceOnly) }),
383
+ ...attachedTpsl,
140
384
  };
141
385
  }
142
386
 
@@ -150,28 +394,38 @@ export async function placeOrder(input) {
150
394
  // For limit orders, confirm a RESTING open order exists (not a filled position);
151
395
  // for market orders, confirm the position opened. Both read the real
152
396
  // provider/exchange state (not the submit ack) before reporting success.
153
- let openOrders = [];
397
+ const restsOnBook = orderType === 'limit' || isTriggerOrderType(orderType);
398
+ let matchingOrders = [];
154
399
  let positions = [];
155
- if (orderType === 'limit') {
156
- openOrders = await controller.getOpenOrders({
157
- standalone: true,
158
- userAddress: accountAddress,
159
- });
400
+ if (restsOnBook) {
401
+ matchingOrders = await waitForPlacedOrder(
402
+ controller,
403
+ accountAddress,
404
+ input,
405
+ {
406
+ orderId: result.orderId,
407
+ triggerOrderType: isTriggerOrderType(orderType) ? orderType : null,
408
+ triggerPrice,
409
+ limitPrice,
410
+ size: result.submittedSize ?? orderParams.size,
411
+ reduceOnly: orderParams.reduceOnly ?? false,
412
+ },
413
+ Number(input.node?.timeout_ms ?? 30000),
414
+ );
160
415
  } else {
161
416
  positions = await controller.getPositions({
162
417
  standalone: true,
163
418
  userAddress: accountAddress,
164
419
  });
165
420
  }
166
- const matchingOrders = selectedItems(input, openOrders);
167
421
  const matchingPositions = selectedItems(input, positions);
168
422
 
169
- if (orderType === 'limit' && matchingOrders.length === 0) {
423
+ if (restsOnBook && matchingOrders.length === 0) {
170
424
  throw new Error(
171
- `core placeOrder (limit) for ${symbol} reported success but no resting open order is visible (orderId=${result.orderId ?? 'null'}, limitPrice=${limitPrice}).`,
425
+ `core placeOrder (${orderType}) for ${symbol} reported success but no resting open order is visible (orderId=${result.orderId ?? 'null'}, limitPrice=${limitPrice}, triggerPrice=${triggerPrice}).`,
172
426
  );
173
427
  }
174
- if (orderType === 'market' && matchingPositions.length === 0) {
428
+ if (!restsOnBook && matchingPositions.length === 0) {
175
429
  throw new Error(
176
430
  `core placeOrder (market) for ${symbol} reported success but no matching position is visible (orderId=${result.orderId ?? 'null'}).`,
177
431
  );
@@ -187,14 +441,18 @@ export async function placeOrder(input) {
187
441
  orderType,
188
442
  notional: usdAmount,
189
443
  leverage,
444
+ timeInForce,
190
445
  size: orderParams.size,
191
446
  currentPrice,
192
447
  limitPrice,
448
+ triggerPrice,
449
+ reduceOnly: orderParams.reduceOnly ?? false,
450
+ attachedTpsl: Object.keys(attachedTpsl).length === 0 ? null : attachedTpsl,
193
451
  submitted: true,
194
452
  orderId: result.orderId ?? null,
195
453
  filledSize: result.filledSize ?? null,
196
454
  averagePrice: result.averagePrice ?? null,
197
- matchingCount: orderType === 'limit' ? matchingOrders.length : matchingPositions.length,
455
+ matchingCount: restsOnBook ? matchingOrders.length : matchingPositions.length,
198
456
  orders: matchingOrders.map(redactOrder),
199
457
  positions: matchingPositions.map(redactPosition),
200
458
  order: result,
@@ -0,0 +1,185 @@
1
+ import {
2
+ configuredSymbols,
3
+ getCoreControllerWithSigner,
4
+ isDirectRun,
5
+ redactOrder,
6
+ requireExplicitSelection,
7
+ runAdapter,
8
+ selectedItems,
9
+ } from './_controller.mjs';
10
+
11
+ // core Set or replace the TP/SL attached to an existing Perps POSITION by driving
12
+ // the headless PerpsController.updatePositionTPSL() through the full
13
+ // signing/provider path. This is the position-bound half of the advanced-order
14
+ // contract (TAT-3511): it covers both whole-position TP/SL and PARTIAL
15
+ // (quantity-scoped) TP/SL, which the provider must express as standalone
16
+ // reduce-only trigger orders because a position-bound TP/SL always closes the
17
+ // whole position.
18
+ //
19
+ // Params use the controller's own provider-agnostic names: take_profit_price /
20
+ // take_profit_size / stop_loss_price / stop_loss_size (camelCase aliases
21
+ // accepted). Omitting a price removes that side; omitting a size covers the
22
+ // whole position. After submitting we re-read live open orders so the resulting
23
+ // trigger orders — including their partial sizes — are visible as evidence.
24
+
25
+ function optionalString(node, snake, camel) {
26
+ const value = node?.[snake] ?? node?.[camel];
27
+ return value === undefined || value === null ? undefined : String(value);
28
+ }
29
+
30
+ function numericValuesMatch(actual, expected) {
31
+ const actualNumber = Number(actual);
32
+ const expectedNumber = Number(expected);
33
+ if (!Number.isFinite(actualNumber) || !Number.isFinite(expectedNumber)) {
34
+ return String(actual) === String(expected);
35
+ }
36
+ const tolerance = Math.max(1e-12, Math.abs(expectedNumber) * 1e-4);
37
+ return Math.abs(actualNumber - expectedNumber) <= tolerance;
38
+ }
39
+
40
+ export function orderMatchesTpslRequest(order, request) {
41
+ if (order.isTrigger !== true || order.reduceOnly !== true) return false;
42
+ if (order.triggerOrderType !== request.triggerOrderType) return false;
43
+ if (
44
+ !numericValuesMatch(
45
+ order.triggerPrice ?? order.triggerPx,
46
+ request.triggerPrice,
47
+ )
48
+ ) {
49
+ return false;
50
+ }
51
+ return (
52
+ request.size === undefined ||
53
+ numericValuesMatch(order.size ?? order.sz ?? order.szi, request.size)
54
+ );
55
+ }
56
+
57
+ async function waitForRequestedTpsl(
58
+ controller,
59
+ accountAddress,
60
+ input,
61
+ requests,
62
+ timeoutMs,
63
+ ) {
64
+ const deadline = Date.now() + Math.max(0, timeoutMs);
65
+ let matching = [];
66
+ for (;;) {
67
+ const openOrders = await controller.getOpenOrders({
68
+ standalone: true,
69
+ userAddress: accountAddress,
70
+ });
71
+ const selected = selectedItems(input, openOrders);
72
+ matching = requests
73
+ .map((request) =>
74
+ selected.find((order) => orderMatchesTpslRequest(order, request)),
75
+ )
76
+ .filter(Boolean);
77
+ if (matching.length === requests.length || Date.now() >= deadline) {
78
+ return matching;
79
+ }
80
+ await new Promise((resolve) =>
81
+ setTimeout(resolve, Math.min(500, Math.max(1, deadline - Date.now()))),
82
+ );
83
+ }
84
+ }
85
+
86
+ export async function updatePositionTpsl(input) {
87
+ requireExplicitSelection(input);
88
+ const symbols = configuredSymbols(input, []);
89
+ if (symbols.length !== 1) {
90
+ throw new Error(
91
+ `metamask.perps.update_position_tpsl requires exactly one market; got ${JSON.stringify(symbols)}.`,
92
+ );
93
+ }
94
+ const symbol = symbols[0];
95
+
96
+ const node = input.node ?? {};
97
+ const takeProfitPrice = optionalString(node, 'take_profit_price', 'takeProfitPrice');
98
+ const stopLossPrice = optionalString(node, 'stop_loss_price', 'stopLossPrice');
99
+ const takeProfitSize = optionalString(node, 'take_profit_size', 'takeProfitSize');
100
+ const stopLossSize = optionalString(node, 'stop_loss_size', 'stopLossSize');
101
+
102
+ if (takeProfitPrice === undefined && stopLossPrice === undefined) {
103
+ throw new Error(
104
+ 'metamask.perps.update_position_tpsl requires take_profit_price and/or stop_loss_price (omit both only to clear, which is not supported here).',
105
+ );
106
+ }
107
+
108
+ const { controller, accountAddress, network } = await getCoreControllerWithSigner(input);
109
+
110
+ // The controller resolves the position itself when none is passed; read it
111
+ // first so the proof records what the TP/SL was attached to.
112
+ const positions = await controller.getPositions({
113
+ standalone: true,
114
+ userAddress: accountAddress,
115
+ });
116
+ const position = positions.find((item) => (item.symbol ?? item.coin) === symbol);
117
+ if (!position) {
118
+ throw new Error(
119
+ `metamask.perps.update_position_tpsl found no open ${symbol} position to attach TP/SL to.`,
120
+ );
121
+ }
122
+
123
+ const params = {
124
+ symbol,
125
+ ...(takeProfitPrice === undefined ? {} : { takeProfitPrice }),
126
+ ...(stopLossPrice === undefined ? {} : { stopLossPrice }),
127
+ ...(takeProfitSize === undefined ? {} : { takeProfitSize }),
128
+ ...(stopLossSize === undefined ? {} : { stopLossSize }),
129
+ };
130
+
131
+ const result = await controller.updatePositionTPSL(params);
132
+ if (!result || result.success !== true) {
133
+ throw new Error(
134
+ `core updatePositionTPSL failed for ${symbol}: ${result?.error ?? 'unknown error'}.`,
135
+ );
136
+ }
137
+
138
+ const requests = [
139
+ ...(takeProfitPrice === undefined
140
+ ? []
141
+ : [{
142
+ triggerOrderType: 'take_profit_limit',
143
+ triggerPrice: takeProfitPrice,
144
+ size: takeProfitSize,
145
+ }]),
146
+ ...(stopLossPrice === undefined
147
+ ? []
148
+ : [{
149
+ triggerOrderType: 'stop_market',
150
+ triggerPrice: stopLossPrice,
151
+ size: stopLossSize,
152
+ }]),
153
+ ];
154
+ const triggerOrders = await waitForRequestedTpsl(
155
+ controller,
156
+ accountAddress,
157
+ input,
158
+ requests,
159
+ Number(input.node?.timeout_ms ?? 30000),
160
+ );
161
+
162
+ if (triggerOrders.length !== requests.length) {
163
+ throw new Error(
164
+ `core updatePositionTPSL for ${symbol} reported success but only ${triggerOrders.length}/${requests.length} requested trigger orders are visible.`,
165
+ );
166
+ }
167
+
168
+ return {
169
+ action: input.action,
170
+ source: 'perps-controller-updatePositionTPSL',
171
+ network,
172
+ account: accountAddress,
173
+ market: symbol,
174
+ positionSize: position.size ?? null,
175
+ requested: params,
176
+ isPartial: takeProfitSize !== undefined || stopLossSize !== undefined,
177
+ submitted: true,
178
+ triggerOrderCount: triggerOrders.length,
179
+ orders: triggerOrders.map(redactOrder),
180
+ result,
181
+ proofPath: 'perps-controller-updatePositionTPSL',
182
+ };
183
+ }
184
+
185
+ if (isDirectRun(import.meta.url)) runAdapter(updatePositionTpsl);
@@ -707,6 +707,14 @@
707
707
  },
708
708
  "timeout_ms": {
709
709
  "type": "number"
710
+ },
711
+ "network": {
712
+ "type": "string",
713
+ "enum": [
714
+ "testnet",
715
+ "mainnet"
716
+ ],
717
+ "description": "Venue to act on. Defaults to testnet. Mainnet uses REAL funds: reads work with network=mainnet alone, while signing additionally requires CORE_PERPS_ALLOW_MAINNET_WRITES=1 in the environment."
710
718
  }
711
719
  },
712
720
  "additionalProperties": false
@@ -721,7 +729,7 @@
721
729
  "execution_capabilities": []
722
730
  },
723
731
  "metamask.perps.place_order": {
724
- "description": "core Place a real Perps order on HyperLiquid testnet by driving the headless perps controller placeOrder() through the full signing/provider path. Testnet only Supports market (default) and resting limit orders (order_type=limit with price/offset_pct).",
732
+ "description": "core Place a real Perps order on HyperLiquid testnet by driving the headless perps controller placeOrder() through the full signing/provider path. Supports market (default), resting limit orders (order_type=limit with price/offset_pct), and resting trigger placements (stop_market | stop_limit | take_profit_market | take_profit_limit with trigger_price/trigger_offset_pct), plus reduce_only and attached/partial TP/SL (take_profit_price/take_profit_size, stop_loss_price/stop_loss_size, tpsl_linkage). Venue is selected with network (testnet default; mainnet signs with REAL funds and also requires CORE_PERPS_ALLOW_MAINNET_WRITES=1). Plain limit orders accept time_in_force GTC | ALO (post-only).",
725
733
  "schema": {
726
734
  "type": "object",
727
735
  "properties": {
@@ -797,17 +805,45 @@
797
805
  "type": "string",
798
806
  "enum": [
799
807
  "market",
800
- "limit"
808
+ "limit",
809
+ "stop_market",
810
+ "stop_limit",
811
+ "take_profit_market",
812
+ "take_profit_limit"
813
+ ],
814
+ "description": "market (default) fills immediately; limit places a RESTING order at price/offset_pct that does not fill; stop_market | stop_limit | take_profit_market | take_profit_limit place a RESTING TRIGGER order that activates at trigger_price and then executes as a market or limit order per the suffix. Alias: orderType."
815
+ },
816
+ "orderType": {
817
+ "type": "string",
818
+ "enum": [
819
+ "market",
820
+ "limit",
821
+ "stop_market",
822
+ "stop_limit",
823
+ "take_profit_market",
824
+ "take_profit_limit"
801
825
  ],
802
- "description": "market (default) fills immediately; limit places a RESTING order at price/offset_pct that does not fill. Alias: orderType."
826
+ "description": "Alias for order_type."
803
827
  },
804
828
  "limit_price": {
805
829
  "type": "string",
806
830
  "description": "Absolute resting limit price (limit orders). Takes precedence over offset_pct. Alias: price/limitPrice."
807
831
  },
832
+ "limitPrice": {
833
+ "type": "string",
834
+ "description": "Alias for limit_price."
835
+ },
836
+ "price": {
837
+ "type": "string",
838
+ "description": "Alias for limit_price."
839
+ },
808
840
  "offset_pct": {
809
841
  "type": "number",
810
- "description": "Resting limit price as a percent offset from live mid for limit orders (e.g. -30 = 30%% below mid for a non-filling BUY; default -30 buy / +30 sell). Alias: offsetPct."
842
+ "description": "Limit price as a percent offset from live mid for plain limits, or from the trigger for *_limit orders. Plain limits default to -30 buy / +30 sell; trigger limits default to +1 buy / -1 sell so they are executable after activation. Alias: offsetPct."
843
+ },
844
+ "offsetPct": {
845
+ "type": "number",
846
+ "description": "Alias for offset_pct."
811
847
  },
812
848
  "amount": {
813
849
  "type": [
@@ -833,6 +869,120 @@
833
869
  },
834
870
  "timeout_ms": {
835
871
  "type": "number"
872
+ },
873
+ "trigger_price": {
874
+ "type": "string",
875
+ "description": "Absolute trigger price for stop_*/take_profit_* placements. Takes precedence over trigger_offset_pct. Alias: triggerPrice."
876
+ },
877
+ "triggerPrice": {
878
+ "type": "string",
879
+ "description": "Alias for trigger_price."
880
+ },
881
+ "trigger_offset_pct": {
882
+ "type": "number",
883
+ "description": "Trigger price as a percent offset from live mid (default keeps the trigger ~30% away so it does not fire during a proof). Alias: triggerOffsetPct."
884
+ },
885
+ "triggerOffsetPct": {
886
+ "type": "number",
887
+ "description": "Alias for trigger_offset_pct."
888
+ },
889
+ "reduce_only": {
890
+ "type": "boolean",
891
+ "description": "Place as reduce-only, so the order can only close an existing position. Alias: reduceOnly."
892
+ },
893
+ "reduceOnly": {
894
+ "type": "boolean",
895
+ "description": "Alias for reduce_only."
896
+ },
897
+ "take_profit_price": {
898
+ "type": "string",
899
+ "description": "Attached take profit price. Alias: takeProfitPrice."
900
+ },
901
+ "takeProfitPrice": {
902
+ "type": "string",
903
+ "description": "Alias for take_profit_price."
904
+ },
905
+ "take_profit_size": {
906
+ "type": "string",
907
+ "description": "Quantity covered by the attached take profit (partial TP). Omit to cover the whole order. Alias: takeProfitSize."
908
+ },
909
+ "takeProfitSize": {
910
+ "type": "string",
911
+ "description": "Alias for take_profit_size."
912
+ },
913
+ "stop_loss_price": {
914
+ "type": "string",
915
+ "description": "Attached stop loss price. Alias: stopLossPrice."
916
+ },
917
+ "stopLossPrice": {
918
+ "type": "string",
919
+ "description": "Alias for stop_loss_price."
920
+ },
921
+ "stop_loss_size": {
922
+ "type": "string",
923
+ "description": "Quantity covered by the attached stop loss (partial SL). Omit to cover the whole order. Alias: stopLossSize."
924
+ },
925
+ "stopLossSize": {
926
+ "type": "string",
927
+ "description": "Alias for stop_loss_size."
928
+ },
929
+ "tpsl_linkage": {
930
+ "type": "string",
931
+ "enum": [
932
+ "none",
933
+ "order",
934
+ "position"
935
+ ],
936
+ "description": "How an attached TP/SL is linked: to this order ('order'), to the resulting position ('position'), or absent ('none'). Provider-agnostic replacement for HyperLiquid grouping. Alias: tpslLinkage."
937
+ },
938
+ "tpslLinkage": {
939
+ "type": "string",
940
+ "enum": [
941
+ "none",
942
+ "order",
943
+ "position"
944
+ ],
945
+ "description": "Alias for tpsl_linkage."
946
+ },
947
+ "max_slippage_bps": {
948
+ "type": "number",
949
+ "description": "Slippage tolerance in basis points; also caps the limit price derived from the trigger for *_market trigger placements. Alias: maxSlippageBps."
950
+ },
951
+ "maxSlippageBps": {
952
+ "type": "number",
953
+ "description": "Alias for max_slippage_bps."
954
+ },
955
+ "network": {
956
+ "type": "string",
957
+ "enum": [
958
+ "testnet",
959
+ "mainnet"
960
+ ],
961
+ "description": "Venue to act on. Defaults to testnet. Mainnet uses REAL funds: reads work with network=mainnet alone, while signing additionally requires CORE_PERPS_ALLOW_MAINNET_WRITES=1 in the environment."
962
+ },
963
+ "time_in_force": {
964
+ "type": "string",
965
+ "enum": [
966
+ "GTC",
967
+ "ALO"
968
+ ],
969
+ "description": "Time in force for plain limit orders: GTC (default) or ALO (post-only). Alias: timeInForce."
970
+ },
971
+ "timeInForce": {
972
+ "type": "string",
973
+ "enum": [
974
+ "GTC",
975
+ "ALO"
976
+ ],
977
+ "description": "Alias for time_in_force."
978
+ },
979
+ "post_only": {
980
+ "type": "boolean",
981
+ "description": "Protocol-agnostic post-only flag; maps to the controller time-in-force ALO. Alias: postOnly."
982
+ },
983
+ "postOnly": {
984
+ "type": "boolean",
985
+ "description": "Alias for post_only."
836
986
  }
837
987
  },
838
988
  "additionalProperties": false
@@ -951,6 +1101,14 @@
951
1101
  },
952
1102
  "timeout_ms": {
953
1103
  "type": "number"
1104
+ },
1105
+ "network": {
1106
+ "type": "string",
1107
+ "enum": [
1108
+ "testnet",
1109
+ "mainnet"
1110
+ ],
1111
+ "description": "Venue to act on. Defaults to testnet. Mainnet uses REAL funds: reads work with network=mainnet alone, while signing additionally requires CORE_PERPS_ALLOW_MAINNET_WRITES=1 in the environment."
954
1112
  }
955
1113
  },
956
1114
  "additionalProperties": false
@@ -1208,6 +1366,14 @@
1208
1366
  },
1209
1367
  "timeout_ms": {
1210
1368
  "type": "number"
1369
+ },
1370
+ "network": {
1371
+ "type": "string",
1372
+ "enum": [
1373
+ "testnet",
1374
+ "mainnet"
1375
+ ],
1376
+ "description": "Venue to act on. Defaults to testnet. Mainnet uses REAL funds: reads work with network=mainnet alone, while signing additionally requires CORE_PERPS_ALLOW_MAINNET_WRITES=1 in the environment."
1211
1377
  }
1212
1378
  },
1213
1379
  "required": [
@@ -1321,6 +1487,14 @@
1321
1487
  },
1322
1488
  "timeout_ms": {
1323
1489
  "type": "number"
1490
+ },
1491
+ "network": {
1492
+ "type": "string",
1493
+ "enum": [
1494
+ "testnet",
1495
+ "mainnet"
1496
+ ],
1497
+ "description": "Venue to act on. Defaults to testnet. Mainnet uses REAL funds: reads work with network=mainnet alone, while signing additionally requires CORE_PERPS_ALLOW_MAINNET_WRITES=1 in the environment."
1324
1498
  }
1325
1499
  },
1326
1500
  "additionalProperties": false
@@ -1345,7 +1519,7 @@
1345
1519
  ]
1346
1520
  },
1347
1521
  "metamask.perps.assert_orders": {
1348
- "description": "core Assert live Perps open orders are present or absent for an explicit market selection or mode=all.",
1522
+ "description": "core Assert live Perps open orders are present or absent for an explicit market selection or mode=all. Optional expect_* fields assert the trigger data of the matching orders (placement type, trigger price, execution mode, reduce-only flag, size).",
1349
1523
  "schema": {
1350
1524
  "type": "object",
1351
1525
  "properties": {
@@ -1447,6 +1621,40 @@
1447
1621
  "present"
1448
1622
  ],
1449
1623
  "description": "Desired/expected selected collection state."
1624
+ },
1625
+ "expect_trigger_order_type": {
1626
+ "type": "string",
1627
+ "enum": [
1628
+ "stop_market",
1629
+ "stop_limit",
1630
+ "take_profit_market",
1631
+ "take_profit_limit"
1632
+ ],
1633
+ "description": "Require every matching order to be this trigger placement type (read back from the exchange)."
1634
+ },
1635
+ "expect_trigger_price": {
1636
+ "type": "string",
1637
+ "description": "Require every matching order to carry this trigger price."
1638
+ },
1639
+ "expect_execution": {
1640
+ "type": "string",
1641
+ "enum": [
1642
+ "market",
1643
+ "limit"
1644
+ ],
1645
+ "description": "Require the execution mode a matching trigger order runs as once it fires."
1646
+ },
1647
+ "expect_reduce_only": {
1648
+ "type": "boolean",
1649
+ "description": "Require the reduce-only flag on every matching order."
1650
+ },
1651
+ "expect_size": {
1652
+ "type": "string",
1653
+ "description": "Require this size on every matching order (proves a partial TP/SL quantity)."
1654
+ },
1655
+ "only_trigger_orders": {
1656
+ "type": "boolean",
1657
+ "description": "Consider only trigger orders (stop / take profit) on the selected market, so expectations are not applied to a plain parent order resting alongside them. Alias: onlyTriggerOrders."
1450
1658
  }
1451
1659
  },
1452
1660
  "required": [
@@ -1577,6 +1785,14 @@
1577
1785
  "present"
1578
1786
  ],
1579
1787
  "description": "Desired/expected selected collection state."
1788
+ },
1789
+ "network": {
1790
+ "type": "string",
1791
+ "enum": [
1792
+ "testnet",
1793
+ "mainnet"
1794
+ ],
1795
+ "description": "Venue to act on. Defaults to testnet. Mainnet uses REAL funds: reads work with network=mainnet alone, while signing additionally requires CORE_PERPS_ALLOW_MAINNET_WRITES=1 in the environment."
1580
1796
  }
1581
1797
  },
1582
1798
  "required": [
@@ -2239,6 +2455,109 @@
2239
2455
  "app-mutation",
2240
2456
  "external-mutation"
2241
2457
  ]
2458
+ },
2459
+ "metamask.perps.update_position_tpsl": {
2460
+ "description": "core Set or replace the TP/SL attached to an existing Perps POSITION by driving the headless perps controller updatePositionTPSL() through the full signing/provider path. Covers whole-position TP/SL and PARTIAL (quantity-scoped) TP/SL via take_profit_size / stop_loss_size, which the provider expresses as standalone reduce-only trigger orders because a position-bound TP/SL always closes the whole position. Venue is selected with network (testnet default; mainnet signs with REAL funds and also requires CORE_PERPS_ALLOW_MAINNET_WRITES=1).",
2461
+ "schema": {
2462
+ "type": "object",
2463
+ "properties": {
2464
+ "market": {
2465
+ "type": "string",
2466
+ "description": "Single market symbol whose position the TP/SL attaches to. Alias: symbol."
2467
+ },
2468
+ "symbol": {
2469
+ "type": "string",
2470
+ "description": "Alias for market."
2471
+ },
2472
+ "markets": {
2473
+ "type": "array",
2474
+ "items": {
2475
+ "type": "string"
2476
+ },
2477
+ "description": "Explicit market list (requires exactly one)."
2478
+ },
2479
+ "symbols": {
2480
+ "type": "array",
2481
+ "items": {
2482
+ "type": "string"
2483
+ },
2484
+ "description": "Alias for markets."
2485
+ },
2486
+ "selector": {
2487
+ "type": "object",
2488
+ "description": "Optional structured selector with mode and symbols/markets."
2489
+ },
2490
+ "take_profit_price": {
2491
+ "type": "string",
2492
+ "description": "Take profit trigger price. Omit to leave the take profit unset. Alias: takeProfitPrice."
2493
+ },
2494
+ "takeProfitPrice": {
2495
+ "type": "string",
2496
+ "description": "Alias for take_profit_price."
2497
+ },
2498
+ "take_profit_size": {
2499
+ "type": "string",
2500
+ "description": "Quantity the take profit covers (partial TP). Omit to cover the whole position. Alias: takeProfitSize."
2501
+ },
2502
+ "takeProfitSize": {
2503
+ "type": "string",
2504
+ "description": "Alias for take_profit_size."
2505
+ },
2506
+ "stop_loss_price": {
2507
+ "type": "string",
2508
+ "description": "Stop loss trigger price. Omit to leave the stop loss unset. Alias: stopLossPrice."
2509
+ },
2510
+ "stopLossPrice": {
2511
+ "type": "string",
2512
+ "description": "Alias for stop_loss_price."
2513
+ },
2514
+ "stop_loss_size": {
2515
+ "type": "string",
2516
+ "description": "Quantity the stop loss covers (partial SL). Omit to cover the whole position. Alias: stopLossSize."
2517
+ },
2518
+ "stopLossSize": {
2519
+ "type": "string",
2520
+ "description": "Alias for stop_loss_size."
2521
+ },
2522
+ "network": {
2523
+ "type": "string",
2524
+ "enum": [
2525
+ "testnet",
2526
+ "mainnet"
2527
+ ],
2528
+ "description": "Venue to act on. Defaults to testnet. Mainnet uses REAL funds and additionally requires CORE_PERPS_ALLOW_MAINNET_WRITES=1."
2529
+ },
2530
+ "account": {
2531
+ "type": "string",
2532
+ "description": "EVM address to act for; defaults to MM_TEST_ACCOUNT_ADDRESS."
2533
+ },
2534
+ "timeout_ms": {
2535
+ "type": "number"
2536
+ }
2537
+ },
2538
+ "additionalProperties": false
2539
+ },
2540
+ "examples": [
2541
+ {
2542
+ "action": "metamask.perps.update_position_tpsl",
2543
+ "market": "BTC",
2544
+ "take_profit_price": "120000",
2545
+ "intent": "Attach a whole-position take profit to the open BTC position",
2546
+ "next": "done"
2547
+ },
2548
+ {
2549
+ "action": "metamask.perps.update_position_tpsl",
2550
+ "market": "BTC",
2551
+ "take_profit_price": "120000",
2552
+ "take_profit_size": "0.0002",
2553
+ "intent": "Attach a partial (quantity-scoped) take profit to the open BTC position",
2554
+ "next": "done"
2555
+ }
2556
+ ],
2557
+ "execution_capabilities": [
2558
+ "app-mutation",
2559
+ "external-mutation"
2560
+ ]
2242
2561
  }
2243
2562
  }
2244
2563
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"