@deeeed/metamask-harness 0.23.0 → 0.24.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 +25 -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 +13 -2
- package/library/actions/core/perps/_controller.mjs +138 -5
- package/library/actions/core/perps/assert_orders.mjs +174 -10
- package/library/actions/core/perps/assert_positions.mjs +20 -2
- package/library/actions/core/perps/close_orders.mjs +24 -5
- package/library/actions/core/perps/close_positions.mjs +22 -8
- package/library/actions/core/perps/edit_order.mjs +331 -0
- package/library/actions/core/perps/place_order.mjs +192 -43
- package/library/actions/core/perps/update_position_tpsl.mjs +121 -15
- package/library/actions/mobile/platform/bridge.mjs +22 -16
- package/library/manifests/core.action-manifest.json +468 -27
- package/package.json +1 -1
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
configuredSymbols,
|
|
3
|
+
controllerRejection,
|
|
3
4
|
currentMarketPrice,
|
|
4
5
|
getCoreControllerWithSigner,
|
|
5
6
|
isDirectRun,
|
|
7
|
+
optionalBooleanParam,
|
|
8
|
+
optionalParam,
|
|
6
9
|
redactOrder,
|
|
7
10
|
redactPosition,
|
|
8
11
|
runAdapter,
|
|
@@ -64,8 +67,8 @@ const TRIGGER_ORDER_TYPES = ORDER_TYPES.filter((type) => type.includes('_'));
|
|
|
64
67
|
const LIMIT_EXECUTION_ORDER_TYPES = ['limit', 'stop_limit', 'take_profit_limit'];
|
|
65
68
|
|
|
66
69
|
function resolveOrderType(input) {
|
|
67
|
-
const raw =
|
|
68
|
-
input.node
|
|
70
|
+
const raw = (
|
|
71
|
+
optionalParam(input.node ?? {}, 'order_type', 'orderType') ?? 'market'
|
|
69
72
|
).toLowerCase();
|
|
70
73
|
if (!ORDER_TYPES.includes(raw)) {
|
|
71
74
|
throw new Error(
|
|
@@ -91,8 +94,12 @@ const isLimitExecution = (orderType) => LIMIT_EXECUTION_ORDER_TYPES.includes(ord
|
|
|
91
94
|
* @param mid - Current mid price.
|
|
92
95
|
*/
|
|
93
96
|
function resolveTriggerPrice(input, orderType, isBuy, mid) {
|
|
94
|
-
const absolute =
|
|
95
|
-
|
|
97
|
+
const absolute = optionalParam(
|
|
98
|
+
input.node ?? {},
|
|
99
|
+
'trigger_price',
|
|
100
|
+
'triggerPrice',
|
|
101
|
+
);
|
|
102
|
+
if (absolute !== undefined) {
|
|
96
103
|
const numeric = Number(absolute);
|
|
97
104
|
if (!Number.isFinite(numeric) || numeric <= 0) {
|
|
98
105
|
throw new Error(
|
|
@@ -101,7 +108,11 @@ function resolveTriggerPrice(input, orderType, isBuy, mid) {
|
|
|
101
108
|
}
|
|
102
109
|
return numeric;
|
|
103
110
|
}
|
|
104
|
-
const rawOffset =
|
|
111
|
+
const rawOffset = optionalParam(
|
|
112
|
+
input.node ?? {},
|
|
113
|
+
'trigger_offset_pct',
|
|
114
|
+
'triggerOffsetPct',
|
|
115
|
+
);
|
|
105
116
|
const isStop = orderType.startsWith('stop');
|
|
106
117
|
// A stop fires against the position: below mid when selling to close a long.
|
|
107
118
|
const defaultOffset = isStop === isBuy ? 30 : -30;
|
|
@@ -121,27 +132,96 @@ function resolveTriggerPrice(input, orderType, isBuy, mid) {
|
|
|
121
132
|
return price;
|
|
122
133
|
}
|
|
123
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Resolve a trigger price set on a placement that is not a trigger type.
|
|
137
|
+
*
|
|
138
|
+
* Such a value is a caller mistake, which the controller reports as
|
|
139
|
+
* ORDER_TRIGGER_PRICE_NOT_SUPPORTED. It has to reach the controller in either
|
|
140
|
+
* spelling to be reported: accepting only the absolute form meant an offset was
|
|
141
|
+
* silently dropped and the order placed for real instead of being refused.
|
|
142
|
+
*
|
|
143
|
+
* @param input - Adapter input (node.trigger_price / node.trigger_offset_pct).
|
|
144
|
+
* @param mid - Current mid price, for the offset spelling.
|
|
145
|
+
* @returns The stray trigger price, or undefined when the node set none.
|
|
146
|
+
*/
|
|
147
|
+
function resolveStrayTriggerPrice(input, mid) {
|
|
148
|
+
const node = input.node ?? {};
|
|
149
|
+
const absolute = optionalParam(node, 'trigger_price', 'triggerPrice');
|
|
150
|
+
if (absolute !== undefined) {
|
|
151
|
+
return absolute;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const rawOffset = optionalParam(
|
|
155
|
+
node,
|
|
156
|
+
'trigger_offset_pct',
|
|
157
|
+
'triggerOffsetPct',
|
|
158
|
+
);
|
|
159
|
+
if (rawOffset === undefined) {
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const offsetPct = Number(rawOffset);
|
|
164
|
+
if (!Number.isFinite(offsetPct)) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`metamask.perps.place_order received invalid trigger_offset_pct: ${rawOffset}.`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return String(mid * (1 + offsetPct / 100));
|
|
170
|
+
}
|
|
171
|
+
|
|
124
172
|
/**
|
|
125
173
|
* Collect the attached TP/SL fields, using the controller's own param names.
|
|
126
174
|
*
|
|
127
175
|
* @param input - Adapter input.
|
|
128
176
|
* @returns Attached TP/SL params, omitting anything the node did not set.
|
|
129
177
|
*/
|
|
130
|
-
function resolveAttachedTpsl(input) {
|
|
178
|
+
function resolveAttachedTpsl(input, mid) {
|
|
131
179
|
const node = input.node ?? {};
|
|
132
|
-
const pick = (snake, camel) =>
|
|
133
|
-
|
|
134
|
-
|
|
180
|
+
const pick = (snake, camel) => optionalParam(node, snake, camel);
|
|
181
|
+
|
|
182
|
+
// An absolute price only holds for one market at one moment, so offsets from
|
|
183
|
+
// live mid are the preferred spelling; an explicit price still wins.
|
|
184
|
+
const fromOffset = (snake, camel, name) => {
|
|
185
|
+
const raw = pick(snake, camel);
|
|
186
|
+
if (raw === undefined) {
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
const pct = Number(raw);
|
|
190
|
+
if (!Number.isFinite(pct)) {
|
|
191
|
+
throw new Error(
|
|
192
|
+
`metamask.perps.place_order received invalid ${name}: ${raw}.`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
const price = mid * (1 + pct / 100);
|
|
196
|
+
if (!Number.isFinite(price) || price <= 0) {
|
|
197
|
+
throw new Error(
|
|
198
|
+
`metamask.perps.place_order computed a non-positive price from ${name}=${raw}.`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
return String(price);
|
|
135
202
|
};
|
|
203
|
+
|
|
136
204
|
const attached = {
|
|
137
|
-
takeProfitPrice:
|
|
205
|
+
takeProfitPrice:
|
|
206
|
+
pick('take_profit_price', 'takeProfitPrice') ??
|
|
207
|
+
fromOffset(
|
|
208
|
+
'take_profit_offset_pct',
|
|
209
|
+
'takeProfitOffsetPct',
|
|
210
|
+
'take_profit_offset_pct',
|
|
211
|
+
),
|
|
138
212
|
takeProfitSize: pick('take_profit_size', 'takeProfitSize'),
|
|
139
|
-
stopLossPrice:
|
|
213
|
+
stopLossPrice:
|
|
214
|
+
pick('stop_loss_price', 'stopLossPrice') ??
|
|
215
|
+
fromOffset(
|
|
216
|
+
'stop_loss_offset_pct',
|
|
217
|
+
'stopLossOffsetPct',
|
|
218
|
+
'stop_loss_offset_pct',
|
|
219
|
+
),
|
|
140
220
|
stopLossSize: pick('stop_loss_size', 'stopLossSize'),
|
|
141
221
|
};
|
|
142
|
-
const linkage = node
|
|
143
|
-
if (linkage !== undefined
|
|
144
|
-
attached.tpslLinkage =
|
|
222
|
+
const linkage = optionalParam(node, 'tpsl_linkage', 'tpslLinkage');
|
|
223
|
+
if (linkage !== undefined) {
|
|
224
|
+
attached.tpslLinkage = linkage;
|
|
145
225
|
}
|
|
146
226
|
return Object.fromEntries(
|
|
147
227
|
Object.entries(attached).filter(([, value]) => value !== undefined),
|
|
@@ -159,15 +239,18 @@ function resolveAttachedTpsl(input) {
|
|
|
159
239
|
* @param mid - Current mid price.
|
|
160
240
|
*/
|
|
161
241
|
function resolveLimitPrice(input, isBuy, mid) {
|
|
162
|
-
const
|
|
163
|
-
|
|
242
|
+
const node = input.node ?? {};
|
|
243
|
+
const absolute =
|
|
244
|
+
optionalParam(node, 'limit_price', 'limitPrice') ??
|
|
245
|
+
optionalParam(node, 'price');
|
|
246
|
+
if (absolute !== undefined) {
|
|
164
247
|
const numeric = Number(absolute);
|
|
165
248
|
if (!Number.isFinite(numeric) || numeric <= 0) {
|
|
166
249
|
throw new Error(`metamask.perps.place_order received invalid limit_price: ${absolute}.`);
|
|
167
250
|
}
|
|
168
251
|
return numeric;
|
|
169
252
|
}
|
|
170
|
-
const rawOffset = input.node
|
|
253
|
+
const rawOffset = optionalParam(input.node ?? {}, 'offset_pct', 'offsetPct');
|
|
171
254
|
// Default far-from-mid offset so the resting order does not fill.
|
|
172
255
|
const offsetPct = rawOffset === undefined || rawOffset === null ? (isBuy ? -30 : 30) : Number(rawOffset);
|
|
173
256
|
if (!Number.isFinite(offsetPct)) {
|
|
@@ -182,12 +265,16 @@ function resolveLimitPrice(input, isBuy, mid) {
|
|
|
182
265
|
|
|
183
266
|
export function resolveTriggerLimitPrice(input, isBuy, triggerPrice) {
|
|
184
267
|
const node = input.node ?? {};
|
|
268
|
+
// Presence must be tested on the NORMALIZED value. A recipe cannot omit a
|
|
269
|
+
// key, so an unused param arrives as '' — and `'' !== undefined` is true, so
|
|
270
|
+
// a raw presence test reads a blank as explicit and then falls through to the
|
|
271
|
+
// far-from-mid default. For a buy trigger at 100 that silently turns 101 into
|
|
272
|
+
// 70: an unrelated price, on the wrong side, from a param nobody set.
|
|
185
273
|
const hasExplicitPrice =
|
|
186
|
-
node
|
|
187
|
-
node
|
|
188
|
-
node.price !== undefined;
|
|
274
|
+
optionalParam(node, 'limit_price', 'limitPrice') !== undefined ||
|
|
275
|
+
optionalParam(node, 'price') !== undefined;
|
|
189
276
|
const hasExplicitOffset =
|
|
190
|
-
node
|
|
277
|
+
optionalParam(node, 'offset_pct', 'offsetPct') !== undefined;
|
|
191
278
|
if (hasExplicitPrice || hasExplicitOffset) {
|
|
192
279
|
return resolveLimitPrice(input, isBuy, triggerPrice);
|
|
193
280
|
}
|
|
@@ -265,28 +352,54 @@ async function waitForPlacedOrder(
|
|
|
265
352
|
|
|
266
353
|
export async function placeOrder(input) {
|
|
267
354
|
const symbol = resolveSymbol(input);
|
|
268
|
-
|
|
269
|
-
|
|
355
|
+
// Blank counts as absent, not as a default direction: treating '' as long
|
|
356
|
+
// would let an unset template place a real order on the wrong side.
|
|
357
|
+
const side = optionalParam(input.node ?? {}, 'side', 'side')?.toLowerCase();
|
|
358
|
+
if (side === undefined) {
|
|
359
|
+
throw new Error(
|
|
360
|
+
'metamask.perps.place_order requires side=long or side=short.',
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
const usdAmount =
|
|
364
|
+
optionalParam(input.node ?? {}, 'amount') ??
|
|
365
|
+
optionalParam(input.node ?? {}, 'notional');
|
|
366
|
+
if (usdAmount === undefined) {
|
|
270
367
|
throw new Error('metamask.perps.place_order requires amount=<usd> or notional=<usd>.');
|
|
271
368
|
}
|
|
272
369
|
const { controller, accountAddress, network } = await getCoreControllerWithSigner(input);
|
|
273
|
-
|
|
274
|
-
|
|
370
|
+
if (side !== 'long' && side !== 'short') {
|
|
371
|
+
throw new Error(
|
|
372
|
+
`metamask.perps.place_order received invalid side: ${side} (expected long or short).`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
const isBuy = side === 'long';
|
|
275
376
|
const orderType = resolveOrderType(input);
|
|
276
|
-
const
|
|
277
|
-
const leverage = Number(input.node?.leverage ?? 3);
|
|
377
|
+
const leverage = Number(optionalParam(input.node ?? {}, 'leverage') ?? 3);
|
|
278
378
|
const maxSlippageBps = Number(
|
|
279
|
-
|
|
379
|
+
optionalParam(
|
|
380
|
+
input.node ?? {},
|
|
381
|
+
'max_slippage_bps',
|
|
382
|
+
'maxSlippageBps',
|
|
383
|
+
) ?? 300,
|
|
280
384
|
);
|
|
281
|
-
const postOnly =
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
385
|
+
const postOnly = optionalBooleanParam(
|
|
386
|
+
input.node ?? {},
|
|
387
|
+
'post_only',
|
|
388
|
+
'postOnly',
|
|
389
|
+
);
|
|
390
|
+
const requestedTimeInForce = optionalParam(
|
|
391
|
+
input.node ?? {},
|
|
392
|
+
'time_in_force',
|
|
393
|
+
'timeInForce',
|
|
394
|
+
);
|
|
395
|
+
// The controller rejects this combination itself, with
|
|
396
|
+
// ORDER_TIME_IN_FORCE_NOT_SUPPORTED. Duplicating the check here only made the
|
|
397
|
+
// controller's own refusal unreachable, so the fields are forwarded and the
|
|
398
|
+
// controller decides. post_only has no controller equivalent, so it keeps a
|
|
399
|
+
// local guard.
|
|
400
|
+
if (orderType !== 'limit' && postOnly !== undefined) {
|
|
288
401
|
throw new Error(
|
|
289
|
-
'metamask.perps.place_order supports
|
|
402
|
+
'metamask.perps.place_order supports post_only only for order_type=limit.',
|
|
290
403
|
);
|
|
291
404
|
}
|
|
292
405
|
const timeInForce =
|
|
@@ -317,9 +430,40 @@ export async function placeOrder(input) {
|
|
|
317
430
|
|
|
318
431
|
const currentPrice = await currentMarketPrice(controller, symbol);
|
|
319
432
|
|
|
320
|
-
const reduceOnly =
|
|
321
|
-
input.node
|
|
322
|
-
|
|
433
|
+
const reduceOnly = optionalBooleanParam(
|
|
434
|
+
input.node ?? {},
|
|
435
|
+
'reduce_only',
|
|
436
|
+
'reduceOnly',
|
|
437
|
+
);
|
|
438
|
+
const attachedTpsl = resolveAttachedTpsl(input, currentPrice);
|
|
439
|
+
|
|
440
|
+
// A non-trigger placement used to drop a stray trigger price or time in
|
|
441
|
+
// force. Silently discarding a field the caller set is wrong on its own
|
|
442
|
+
// terms, and it also hid the controller's refusal of it. Both are forwarded
|
|
443
|
+
// verbatim now, for every request, so what is submitted never depends on what
|
|
444
|
+
// the caller expects to happen. Trigger placements resolve their own trigger
|
|
445
|
+
// price below, so this only carries the stray case.
|
|
446
|
+
// Each branch below builds its own params, so a field only that branch
|
|
447
|
+
// considers stray has to be added to all of them — forwarding on the market
|
|
448
|
+
// branch alone left a limit order silently dropping a trigger price and a
|
|
449
|
+
// trigger order silently dropping a time in force, either of which then
|
|
450
|
+
// succeeded instead of reaching the controller's refusal.
|
|
451
|
+
const isTrigger = isTriggerOrderType(orderType);
|
|
452
|
+
const strayTriggerPrice = isTrigger
|
|
453
|
+
? undefined
|
|
454
|
+
: resolveStrayTriggerPrice(input, currentPrice);
|
|
455
|
+
// A limit order legitimately carries a time in force; every other shape does
|
|
456
|
+
// not, and the controller refuses it.
|
|
457
|
+
const strayTimeInForce =
|
|
458
|
+
orderType === 'limit' ? undefined : requestedTimeInForce;
|
|
459
|
+
const forwardedFields = {
|
|
460
|
+
...(strayTriggerPrice === undefined
|
|
461
|
+
? {}
|
|
462
|
+
: { triggerPrice: strayTriggerPrice }),
|
|
463
|
+
...(strayTimeInForce === undefined
|
|
464
|
+
? {}
|
|
465
|
+
: { timeInForce: strayTimeInForce }),
|
|
466
|
+
};
|
|
323
467
|
|
|
324
468
|
let orderParams;
|
|
325
469
|
let limitPrice = null;
|
|
@@ -339,6 +483,7 @@ export async function placeOrder(input) {
|
|
|
339
483
|
isBuy,
|
|
340
484
|
size,
|
|
341
485
|
orderType,
|
|
486
|
+
...forwardedFields,
|
|
342
487
|
triggerPrice: String(triggerPrice),
|
|
343
488
|
...(limitPrice === null ? {} : { price: String(limitPrice) }),
|
|
344
489
|
leverage,
|
|
@@ -358,6 +503,7 @@ export async function placeOrder(input) {
|
|
|
358
503
|
isBuy,
|
|
359
504
|
size,
|
|
360
505
|
orderType: 'limit',
|
|
506
|
+
...forwardedFields,
|
|
361
507
|
price: String(limitPrice),
|
|
362
508
|
timeInForce,
|
|
363
509
|
leverage,
|
|
@@ -374,6 +520,7 @@ export async function placeOrder(input) {
|
|
|
374
520
|
isBuy,
|
|
375
521
|
size,
|
|
376
522
|
orderType: 'market',
|
|
523
|
+
...forwardedFields,
|
|
377
524
|
leverage,
|
|
378
525
|
usdAmount,
|
|
379
526
|
currentPrice,
|
|
@@ -386,9 +533,11 @@ export async function placeOrder(input) {
|
|
|
386
533
|
|
|
387
534
|
const result = await controller.placeOrder(orderParams);
|
|
388
535
|
if (!result || result.success !== true) {
|
|
389
|
-
throw
|
|
390
|
-
|
|
391
|
-
|
|
536
|
+
throw controllerRejection({
|
|
537
|
+
action: 'core placeOrder',
|
|
538
|
+
detail: `${symbol} (${orderType}), currentPrice=${currentPrice}${limitPrice ? `, limitPrice=${limitPrice}` : ''}`,
|
|
539
|
+
code: result?.error ?? 'unknown error',
|
|
540
|
+
});
|
|
392
541
|
}
|
|
393
542
|
|
|
394
543
|
// For limit orders, confirm a RESTING open order exists (not a filled position);
|
|
@@ -410,7 +559,7 @@ export async function placeOrder(input) {
|
|
|
410
559
|
size: result.submittedSize ?? orderParams.size,
|
|
411
560
|
reduceOnly: orderParams.reduceOnly ?? false,
|
|
412
561
|
},
|
|
413
|
-
Number(input.node
|
|
562
|
+
Number(optionalParam(input.node ?? {}, 'timeout_ms', 'timeoutMs') ?? 30000),
|
|
414
563
|
);
|
|
415
564
|
} else {
|
|
416
565
|
positions = await controller.getPositions({
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
configuredSymbols,
|
|
3
|
+
controllerRejection,
|
|
3
4
|
getCoreControllerWithSigner,
|
|
4
5
|
isDirectRun,
|
|
6
|
+
optionalParam,
|
|
5
7
|
redactOrder,
|
|
6
8
|
requireExplicitSelection,
|
|
7
9
|
runAdapter,
|
|
@@ -22,11 +24,6 @@ import {
|
|
|
22
24
|
// whole position. After submitting we re-read live open orders so the resulting
|
|
23
25
|
// trigger orders — including their partial sizes — are visible as evidence.
|
|
24
26
|
|
|
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
27
|
function numericValuesMatch(actual, expected) {
|
|
31
28
|
const actualNumber = Number(actual);
|
|
32
29
|
const expectedNumber = Number(expected);
|
|
@@ -94,12 +91,21 @@ export async function updatePositionTpsl(input) {
|
|
|
94
91
|
const symbol = symbols[0];
|
|
95
92
|
|
|
96
93
|
const node = input.node ?? {};
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
94
|
+
let takeProfitPrice = optionalParam(node, 'take_profit_price', 'takeProfitPrice');
|
|
95
|
+
let stopLossPrice = optionalParam(node, 'stop_loss_price', 'stopLossPrice');
|
|
96
|
+
let takeProfitSize = optionalParam(node, 'take_profit_size', 'takeProfitSize');
|
|
97
|
+
let stopLossSize = optionalParam(node, 'stop_loss_size', 'stopLossSize');
|
|
98
|
+
const takeProfitOffsetPct = optionalParam(node, 'take_profit_offset_pct', 'takeProfitOffsetPct');
|
|
99
|
+
const stopLossOffsetPct = optionalParam(node, 'stop_loss_offset_pct', 'stopLossOffsetPct');
|
|
100
|
+
const takeProfitFraction = optionalParam(node, 'take_profit_fraction', 'takeProfitFraction');
|
|
101
|
+
const stopLossFraction = optionalParam(node, 'stop_loss_fraction', 'stopLossFraction');
|
|
101
102
|
|
|
102
|
-
if (
|
|
103
|
+
if (
|
|
104
|
+
takeProfitPrice === undefined &&
|
|
105
|
+
stopLossPrice === undefined &&
|
|
106
|
+
takeProfitOffsetPct === undefined &&
|
|
107
|
+
stopLossOffsetPct === undefined
|
|
108
|
+
) {
|
|
103
109
|
throw new Error(
|
|
104
110
|
'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
111
|
);
|
|
@@ -120,6 +126,93 @@ export async function updatePositionTpsl(input) {
|
|
|
120
126
|
);
|
|
121
127
|
}
|
|
122
128
|
|
|
129
|
+
// Prices and sizes are resolved from live mid and the actual position, so a
|
|
130
|
+
// recipe never has to name a number that only holds for one market at one
|
|
131
|
+
// moment. An absolute value still wins when the caller gives one.
|
|
132
|
+
// Both derivations below need the market, and each used to fetch it
|
|
133
|
+
// separately. getMarketDataWithPrices reads every market, so doing it twice in
|
|
134
|
+
// one adapter is what pushed this past the node timeout.
|
|
135
|
+
let market;
|
|
136
|
+
const readMarket = async () => {
|
|
137
|
+
if (market === undefined) {
|
|
138
|
+
const markets = await controller.getMarketDataWithPrices({
|
|
139
|
+
standalone: true,
|
|
140
|
+
});
|
|
141
|
+
market =
|
|
142
|
+
(Array.isArray(markets) ? markets : []).find(
|
|
143
|
+
(item) => (item?.symbol ?? '').toUpperCase() === symbol.toUpperCase(),
|
|
144
|
+
) ?? null;
|
|
145
|
+
}
|
|
146
|
+
return market;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
if (takeProfitOffsetPct !== undefined || stopLossOffsetPct !== undefined) {
|
|
150
|
+
// PerpsMarketData.price is a formatted string like '$103,245.00'.
|
|
151
|
+
const mid = Number(
|
|
152
|
+
String((await readMarket())?.price ?? '').replace(/[$,\s]/gu, ''),
|
|
153
|
+
);
|
|
154
|
+
if (!Number.isFinite(mid) || mid <= 0) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`metamask.perps.update_position_tpsl found no usable mid for ${symbol}.`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
const fromOffset = (raw, name) => {
|
|
160
|
+
const pct = Number(raw);
|
|
161
|
+
if (!Number.isFinite(pct)) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`metamask.perps.update_position_tpsl received invalid ${name}: ${raw}.`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
const price = mid * (1 + pct / 100);
|
|
167
|
+
if (!Number.isFinite(price) || price <= 0) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
`metamask.perps.update_position_tpsl computed a non-positive price from ${name}=${raw}.`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
return String(price);
|
|
173
|
+
};
|
|
174
|
+
if (takeProfitPrice === undefined && takeProfitOffsetPct !== undefined) {
|
|
175
|
+
takeProfitPrice = fromOffset(takeProfitOffsetPct, 'take_profit_offset_pct');
|
|
176
|
+
}
|
|
177
|
+
if (stopLossPrice === undefined && stopLossOffsetPct !== undefined) {
|
|
178
|
+
stopLossPrice = fromOffset(stopLossOffsetPct, 'stop_loss_offset_pct');
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (takeProfitFraction !== undefined || stopLossFraction !== undefined) {
|
|
183
|
+
const absolutePositionSize = Math.abs(Number(position.size ?? position.szi ?? 0));
|
|
184
|
+
// Round to the market's own size precision before submitting. The venue
|
|
185
|
+
// rounds anyway, and the read-back below matches on size — so an unrounded
|
|
186
|
+
// fraction asks for a size that can never come back, and the poll spins
|
|
187
|
+
// until it times out.
|
|
188
|
+
const szDecimals = Number((await readMarket())?.szDecimals);
|
|
189
|
+
const fromFraction = (raw, name) => {
|
|
190
|
+
const fraction = Number(raw);
|
|
191
|
+
if (!Number.isFinite(fraction) || fraction <= 0 || fraction > 1) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`metamask.perps.update_position_tpsl received invalid ${name}: ${raw} (expected a fraction in (0, 1]).`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
const exact = absolutePositionSize * fraction;
|
|
197
|
+
if (!Number.isFinite(szDecimals)) {
|
|
198
|
+
return String(exact);
|
|
199
|
+
}
|
|
200
|
+
const rounded = Number(exact.toFixed(szDecimals));
|
|
201
|
+
if (rounded <= 0) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
`metamask.perps.update_position_tpsl: ${name}=${raw} of a ${absolutePositionSize} position rounds to zero at ${szDecimals} size decimals; use a larger position or a larger fraction.`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
return String(rounded);
|
|
207
|
+
};
|
|
208
|
+
if (takeProfitSize === undefined && takeProfitFraction !== undefined) {
|
|
209
|
+
takeProfitSize = fromFraction(takeProfitFraction, 'take_profit_fraction');
|
|
210
|
+
}
|
|
211
|
+
if (stopLossSize === undefined && stopLossFraction !== undefined) {
|
|
212
|
+
stopLossSize = fromFraction(stopLossFraction, 'stop_loss_fraction');
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
123
216
|
const params = {
|
|
124
217
|
symbol,
|
|
125
218
|
...(takeProfitPrice === undefined ? {} : { takeProfitPrice }),
|
|
@@ -130,9 +223,11 @@ export async function updatePositionTpsl(input) {
|
|
|
130
223
|
|
|
131
224
|
const result = await controller.updatePositionTPSL(params);
|
|
132
225
|
if (!result || result.success !== true) {
|
|
133
|
-
throw
|
|
134
|
-
|
|
135
|
-
|
|
226
|
+
throw controllerRejection({
|
|
227
|
+
action: 'core updatePositionTPSL',
|
|
228
|
+
detail: symbol,
|
|
229
|
+
code: result?.error ?? 'unknown error',
|
|
230
|
+
});
|
|
136
231
|
}
|
|
137
232
|
|
|
138
233
|
const requests = [
|
|
@@ -156,12 +251,23 @@ export async function updatePositionTpsl(input) {
|
|
|
156
251
|
accountAddress,
|
|
157
252
|
input,
|
|
158
253
|
requests,
|
|
159
|
-
|
|
254
|
+
// Poll for only part of the node's budget. Spending all of it means the
|
|
255
|
+
// runner kills this adapter before the mismatch below can be reported, so a
|
|
256
|
+
// read-back that never matches surfaces as an opaque timeout instead of
|
|
257
|
+
// saying which trigger order was missing.
|
|
258
|
+
Math.min(
|
|
259
|
+
Number(
|
|
260
|
+
optionalParam(input.node ?? {}, 'timeout_ms', 'timeoutMs') ?? 30000,
|
|
261
|
+
) * 0.6,
|
|
262
|
+
60000,
|
|
263
|
+
),
|
|
160
264
|
);
|
|
161
265
|
|
|
162
266
|
if (triggerOrders.length !== requests.length) {
|
|
163
267
|
throw new Error(
|
|
164
|
-
`core updatePositionTPSL for ${symbol} reported success but only ${triggerOrders.length}/${requests.length} requested trigger orders are visible
|
|
268
|
+
`core updatePositionTPSL for ${symbol} reported success but only ${triggerOrders.length}/${requests.length} requested trigger orders are visible. Requested: ${JSON.stringify(
|
|
269
|
+
requests,
|
|
270
|
+
)}.`,
|
|
165
271
|
);
|
|
166
272
|
}
|
|
167
273
|
|
|
@@ -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
|
|