@deeeed/metamask-harness 0.22.0 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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);
@@ -1,8 +1,9 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { constants as fsConstants } from 'node:fs';
3
- import { mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { mkdir, mkdtemp, open, readFile, rename, rm, writeFile } from 'node:fs/promises';
4
4
  import { execFile, spawn } from 'node:child_process';
5
5
  import { promisify } from 'node:util';
6
+ import os from 'node:os';
6
7
  import path from 'node:path';
7
8
  import { fileURLToPath } from 'node:url';
8
9
  import bridgeErrors from '../../../../adapters/mobile/bridge-runtime/lib/bridge-errors.cjs';
@@ -420,7 +421,7 @@ export async function simulatorScreenshot(input, relPath) {
420
421
  await rm(absolute, { force: true });
421
422
  let temporaryDirectory;
422
423
  try {
423
- temporaryDirectory = await createScreenshotTemporaryDirectory(absolute);
424
+ temporaryDirectory = await createScreenshotTemporaryDirectory();
424
425
  const captured = path.join(temporaryDirectory, 'captured.png');
425
426
  await createPrivateScreenshotFile(captured);
426
427
  let result;
@@ -446,7 +447,7 @@ export async function simulatorScreenshot(input, relPath) {
446
447
  ` Next: mm-harness doctor --adapter mobile --device ${target} --json`,
447
448
  );
448
449
  }
449
- await publishPrivateScreenshot(png, temporaryDirectory, absolute);
450
+ await publishPrivateScreenshot(png, absolute);
450
451
  await rm(temporaryDirectory, { recursive: true, force: true });
451
452
  } catch (error) {
452
453
  await removeScreenshotResidue(temporaryDirectory, absolute);
@@ -477,7 +478,7 @@ async function androidScreenshot(input, relPath) {
477
478
  await rm(absolute, { force: true });
478
479
  let temporaryDirectory;
479
480
  try {
480
- temporaryDirectory = await createScreenshotTemporaryDirectory(absolute);
481
+ temporaryDirectory = await createScreenshotTemporaryDirectory();
481
482
  const adbSerial = await resolveAndroidScreenshotSerial(input);
482
483
  let result;
483
484
  try {
@@ -501,7 +502,7 @@ async function androidScreenshot(input, relPath) {
501
502
  ` Next: mm-harness doctor --adapter mobile --device ${adbSerial} --json`,
502
503
  );
503
504
  }
504
- await publishPrivateScreenshot(result.stdout, temporaryDirectory, absolute);
505
+ await publishPrivateScreenshot(result.stdout, absolute);
505
506
  await rm(temporaryDirectory, { recursive: true, force: true });
506
507
  return {
507
508
  path: relative,
@@ -672,10 +673,8 @@ function pngCrc32(bytes, start, end) {
672
673
  return (crc ^ 0xffffffff) >>> 0;
673
674
  }
674
675
 
675
- async function createScreenshotTemporaryDirectory(absolute) {
676
- const temporary = path.join(path.dirname(absolute), `.${path.basename(absolute)}.${process.pid}.${randomUUID()}.tmp`);
677
- await mkdir(temporary, { mode: 0o700 });
678
- return temporary;
676
+ async function createScreenshotTemporaryDirectory() {
677
+ return mkdtemp(path.join(os.tmpdir(), 'mm-harness-screenshot-'));
679
678
  }
680
679
 
681
680
  async function createPrivateScreenshotFile(file) {
@@ -706,21 +705,28 @@ async function readPrivateScreenshotFile(file) {
706
705
  }
707
706
  }
708
707
 
709
- async function publishPrivateScreenshot(png, temporaryDirectory, absolute) {
710
- const publication = path.join(temporaryDirectory, 'validated.png');
711
- const handle = await open(
712
- publication,
713
- fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW,
714
- 0o600,
708
+ async function publishPrivateScreenshot(png, absolute) {
709
+ const publication = path.join(
710
+ path.dirname(absolute),
711
+ `.${path.basename(absolute)}.${process.pid}.${randomUUID()}.tmp`,
715
712
  );
713
+ let handle;
716
714
  try {
715
+ handle = await open(
716
+ publication,
717
+ fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW,
718
+ 0o600,
719
+ );
717
720
  const stats = await handle.stat();
718
721
  if (!stats.isFile() || stats.nlink !== 1) throw new Error('validated screenshot is not a private regular file');
719
722
  await handle.writeFile(png);
720
723
  await handle.sync();
724
+ await handle.close();
725
+ handle = undefined;
721
726
  await rename(publication, absolute);
722
727
  } finally {
723
- await handle.close();
728
+ await handle?.close();
729
+ await rm(publication, { force: true });
724
730
  }
725
731
  }
726
732