@deeeed/metamask-harness 0.21.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 +23 -0
- package/dist/adapters/extension/ensure-ready.js +44 -15
- package/dist/cli-commands.js +1 -0
- package/dist/cli.js +2 -0
- package/dist/command-contract.js +57 -0
- package/dist/commands/execution-template.js +28 -0
- package/dist/commands/run-engine.js +17 -8
- package/dist/library-provenance.js +33 -0
- package/dist/mm-harness-cli.js +35 -1
- 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/extension/ui/navigate.mjs +16 -6
- package/library/manifests/core.action-manifest.json +324 -5
- package/package.json +3 -3
- package/scripts/completions.sh +2 -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);
|
|
@@ -69,12 +69,17 @@ async function activateSemanticControl(page, selector) {
|
|
|
69
69
|
export async function openHome(page, timeoutMs) {
|
|
70
70
|
const home = dataTestId('bottom-nav-home');
|
|
71
71
|
const selectedHome = `${home}[aria-current="page"]`;
|
|
72
|
+
const legacyHome = dataTestId('account-overview__asset-tab');
|
|
73
|
+
const selectedLegacyHome = `${legacyHome}[aria-selected="true"]`;
|
|
72
74
|
const deadline = Date.now() + timeoutMs;
|
|
73
75
|
const steps = [];
|
|
74
76
|
let pendingActivation;
|
|
75
77
|
|
|
76
78
|
while (Date.now() < deadline) {
|
|
77
|
-
if (
|
|
79
|
+
if (
|
|
80
|
+
(await hasVisibleSelector(page, selectedHome)) ||
|
|
81
|
+
(await hasVisibleSelector(page, selectedLegacyHome))
|
|
82
|
+
) {
|
|
78
83
|
return { method: 'visible-ui', href: await currentHref(page), steps };
|
|
79
84
|
}
|
|
80
85
|
const href = await currentHref(page);
|
|
@@ -83,12 +88,17 @@ export async function openHome(page, timeoutMs) {
|
|
|
83
88
|
await pauseForUi(page);
|
|
84
89
|
continue;
|
|
85
90
|
}
|
|
86
|
-
|
|
87
|
-
|
|
91
|
+
const homeControl = (await hasVisibleSelector(page, home))
|
|
92
|
+
? home
|
|
93
|
+
: (await hasVisibleSelector(page, legacyHome))
|
|
94
|
+
? legacyHome
|
|
95
|
+
: undefined;
|
|
96
|
+
if (homeControl) {
|
|
97
|
+
if (pendingActivation?.selector !== homeControl) pendingActivation = undefined;
|
|
88
98
|
if (!pendingActivation) {
|
|
89
|
-
await activateSemanticControl(page,
|
|
90
|
-
steps.push(
|
|
91
|
-
pendingActivation = { selector:
|
|
99
|
+
await activateSemanticControl(page, homeControl);
|
|
100
|
+
steps.push(homeControl);
|
|
101
|
+
pendingActivation = { selector: homeControl, href };
|
|
92
102
|
}
|
|
93
103
|
await pauseForUi(page);
|
|
94
104
|
continue;
|