@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.
- package/CHANGELOG.md +17 -0
- package/adapters/manifest.json +8 -0
- package/adapters/mobile/metro-config.cjs +93 -0
- package/adapters/mobile/start-metro.sh +11 -0
- package/dist/recipe-security.js +2 -0
- package/library/actions/core/perps/_controller.mjs +7 -0
- package/library/actions/core/perps/assert_orders.mjs +92 -0
- package/library/actions/core/perps/place_order.mjs +273 -15
- package/library/actions/core/perps/update_position_tpsl.mjs +185 -0
- package/library/actions/mobile/platform/bridge.mjs +22 -16
- package/library/manifests/core.action-manifest.json +324 -5
- package/package.json +1 -1
|
@@ -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
|
|
46
|
-
throw new Error(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
397
|
+
const restsOnBook = orderType === 'limit' || isTriggerOrderType(orderType);
|
|
398
|
+
let matchingOrders = [];
|
|
154
399
|
let positions = [];
|
|
155
|
-
if (
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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 (
|
|
423
|
+
if (restsOnBook && matchingOrders.length === 0) {
|
|
170
424
|
throw new Error(
|
|
171
|
-
`core placeOrder (
|
|
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 (
|
|
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:
|
|
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(
|
|
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,
|
|
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(
|
|
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,
|
|
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(
|
|
676
|
-
|
|
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,
|
|
710
|
-
const publication = path.join(
|
|
711
|
-
|
|
712
|
-
|
|
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
|
|
728
|
+
await handle?.close();
|
|
729
|
+
await rm(publication, { force: true });
|
|
724
730
|
}
|
|
725
731
|
}
|
|
726
732
|
|