@deeeed/metamask-harness 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +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/manifests/core.action-manifest.json +324 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.23.0 - 2026-07-28
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Core Perps actions support trigger orders, attached and partial TP/SL, and position TP/SL updates.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Core advanced-order mutations verify the submitted order fields, use executable trigger-limit defaults, honor bounded evidence polling, and classify mutation capabilities correctly.
|
|
14
|
+
- Core advanced-order camelCase aliases validate consistently with their documented snake_case forms.
|
|
15
|
+
|
|
5
16
|
## 0.22.0 - 2026-07-26
|
|
6
17
|
|
|
7
18
|
### Added
|
package/dist/recipe-security.js
CHANGED
|
@@ -18,6 +18,7 @@ const APP_MUTATION_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
|
|
|
18
18
|
"metamask.perps.close_positions",
|
|
19
19
|
"metamask.perps.close_orders",
|
|
20
20
|
"metamask.perps.place_order",
|
|
21
|
+
"metamask.perps.update_position_tpsl",
|
|
21
22
|
"metamask.perps.ensure_positions",
|
|
22
23
|
"metamask.perps.ensure_orders",
|
|
23
24
|
"metamask.perps.start_state",
|
|
@@ -27,6 +28,7 @@ const EXTERNAL_MUTATION_CUSTOM_ACTIONS = /* @__PURE__ */ new Set([
|
|
|
27
28
|
"metamask.perps.close_positions",
|
|
28
29
|
"metamask.perps.close_orders",
|
|
29
30
|
"metamask.perps.place_order",
|
|
31
|
+
"metamask.perps.update_position_tpsl",
|
|
30
32
|
"metamask.perps.ensure_positions",
|
|
31
33
|
"metamask.perps.ensure_orders",
|
|
32
34
|
"metamask.perps.start_state",
|
|
@@ -625,6 +625,13 @@ export function redactOrder(order) {
|
|
|
625
625
|
size: order.size ?? order.sz ?? order.szi ?? null,
|
|
626
626
|
price: order.price ?? order.limitPx ?? order.px ?? null,
|
|
627
627
|
type: order.orderType ?? order.type ?? null,
|
|
628
|
+
// Trigger data, so evidence shows what a stop / take-profit placement
|
|
629
|
+
// actually round-tripped from the exchange.
|
|
630
|
+
triggerOrderType: order.triggerOrderType ?? null,
|
|
631
|
+
triggerPrice: order.triggerPrice ?? order.triggerPx ?? null,
|
|
632
|
+
detailedOrderType: order.detailedOrderType ?? null,
|
|
633
|
+
isTrigger: order.isTrigger ?? null,
|
|
634
|
+
reduceOnly: order.reduceOnly ?? null,
|
|
628
635
|
};
|
|
629
636
|
}
|
|
630
637
|
|
|
@@ -11,6 +11,13 @@ import {
|
|
|
11
11
|
// over the controller's standalone getOpenOrders path (no signer / provider init
|
|
12
12
|
// needed) — throws on mismatch so the recipe fails loudly. Mirrors
|
|
13
13
|
// assert_positions.mjs.
|
|
14
|
+
//
|
|
15
|
+
// Optional expect_* fields additionally assert the TRIGGER DATA the exchange
|
|
16
|
+
// round-tripped for every matching order: expect_trigger_order_type (the
|
|
17
|
+
// normalized placement type, e.g. stop_market), expect_trigger_price,
|
|
18
|
+
// expect_execution (market | limit once triggered), expect_reduce_only, and
|
|
19
|
+
// expect_size (proves a partial TP/SL quantity). They use the controller's own
|
|
20
|
+
// field names (triggerOrderType, triggerPrice, orderType, reduceOnly, size).
|
|
14
21
|
|
|
15
22
|
export function expectedOpen(input) {
|
|
16
23
|
if (input.node?.state == null) throw new Error('metamask.perps.assert_orders requires state=open or state=none.');
|
|
@@ -20,6 +27,79 @@ export function expectedOpen(input) {
|
|
|
20
27
|
throw new Error(`metamask.perps.assert_orders received unsupported state: ${state}`);
|
|
21
28
|
}
|
|
22
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Collect the optional trigger-data expectations from the node.
|
|
32
|
+
*
|
|
33
|
+
* @param input - Adapter input.
|
|
34
|
+
* @returns The expectations the node set, keyed by controller field name.
|
|
35
|
+
*/
|
|
36
|
+
/**
|
|
37
|
+
* Whether the node narrowed matching to trigger orders only.
|
|
38
|
+
*
|
|
39
|
+
* @param input - Adapter input.
|
|
40
|
+
* @returns True when only trigger orders should be considered.
|
|
41
|
+
*/
|
|
42
|
+
export function onlyTriggerOrders(input) {
|
|
43
|
+
const value = input.node?.only_trigger_orders ?? input.node?.onlyTriggerOrders;
|
|
44
|
+
return value === true || String(value).toLowerCase() === 'true';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function expectedTriggerData(input) {
|
|
48
|
+
const node = input.node ?? {};
|
|
49
|
+
const expectations = {
|
|
50
|
+
triggerOrderType:
|
|
51
|
+
node.expect_trigger_order_type ?? node.expectTriggerOrderType,
|
|
52
|
+
triggerPrice: node.expect_trigger_price ?? node.expectTriggerPrice,
|
|
53
|
+
orderType: node.expect_execution ?? node.expectExecution,
|
|
54
|
+
reduceOnly: node.expect_reduce_only ?? node.expectReduceOnly,
|
|
55
|
+
size: node.expect_size ?? node.expectSize,
|
|
56
|
+
};
|
|
57
|
+
return Object.fromEntries(
|
|
58
|
+
Object.entries(expectations).filter(
|
|
59
|
+
([, value]) => value !== undefined && value !== null,
|
|
60
|
+
),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Assert every matching order carries the expected trigger data.
|
|
66
|
+
*
|
|
67
|
+
* @param orders - Matching open orders.
|
|
68
|
+
* @param expectations - Expectations from `expectedTriggerData`.
|
|
69
|
+
*/
|
|
70
|
+
export function assertTriggerData(orders, expectations) {
|
|
71
|
+
const fields = Object.keys(expectations);
|
|
72
|
+
if (fields.length === 0) return;
|
|
73
|
+
|
|
74
|
+
for (const order of orders) {
|
|
75
|
+
for (const field of fields) {
|
|
76
|
+
const expected = expectations[field];
|
|
77
|
+
const actual = order[field];
|
|
78
|
+
const expectedNumber = Number(expected);
|
|
79
|
+
const actualNumber = Number(actual);
|
|
80
|
+
const numeric =
|
|
81
|
+
typeof expected !== 'boolean' &&
|
|
82
|
+
expected !== '' &&
|
|
83
|
+
actual !== null &&
|
|
84
|
+
actual !== undefined &&
|
|
85
|
+
Number.isFinite(expectedNumber) &&
|
|
86
|
+
Number.isFinite(actualNumber);
|
|
87
|
+
const matches =
|
|
88
|
+
typeof expected === 'boolean'
|
|
89
|
+
? Boolean(actual) === expected
|
|
90
|
+
: // Prices and sizes round-trip with exchange formatting
|
|
91
|
+
// ('44000' -> '44000.0'), so compare them numerically.
|
|
92
|
+
(numeric && expectedNumber === actualNumber) ||
|
|
93
|
+
String(actual) === String(expected);
|
|
94
|
+
if (!matches) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`Open Perps order ${order.orderId ?? '?'} (${order.symbol ?? '?'}) has ${field}=${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}.`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
23
103
|
export async function assertOrders(input, expectOpen = expectedOpen(input)) {
|
|
24
104
|
requireExplicitSelection(input);
|
|
25
105
|
const { controller, accountAddress, network } = await getCoreController(input);
|
|
@@ -33,6 +113,11 @@ export async function assertOrders(input, expectOpen = expectedOpen(input)) {
|
|
|
33
113
|
userAddress: accountAddress,
|
|
34
114
|
});
|
|
35
115
|
matching = selectedItems(input, orders);
|
|
116
|
+
if (onlyTriggerOrders(input)) {
|
|
117
|
+
// Narrow to the trigger orders on the market so expectations are not
|
|
118
|
+
// applied to an unrelated parent order resting alongside them.
|
|
119
|
+
matching = matching.filter((order) => order.isTrigger === true);
|
|
120
|
+
}
|
|
36
121
|
if (expectOpen ? matching.length > 0 : matching.length === 0) break;
|
|
37
122
|
if (Date.now() >= deadline) break;
|
|
38
123
|
await new Promise((resolve) =>
|
|
@@ -44,6 +129,11 @@ export async function assertOrders(input, expectOpen = expectedOpen(input)) {
|
|
|
44
129
|
if (expectOpen && !hasOrder) {
|
|
45
130
|
throw new Error('Expected at least one matching open Perps order, but found none.');
|
|
46
131
|
}
|
|
132
|
+
|
|
133
|
+
const triggerExpectations = expectedTriggerData(input);
|
|
134
|
+
if (expectOpen) {
|
|
135
|
+
assertTriggerData(matching, triggerExpectations);
|
|
136
|
+
}
|
|
47
137
|
if (!expectOpen && hasOrder) {
|
|
48
138
|
throw new Error(`Expected no matching open Perps orders, but found ${matching.length}.`);
|
|
49
139
|
}
|
|
@@ -54,6 +144,8 @@ export async function assertOrders(input, expectOpen = expectedOpen(input)) {
|
|
|
54
144
|
network,
|
|
55
145
|
account: accountAddress,
|
|
56
146
|
expectedOpen: expectOpen,
|
|
147
|
+
expectedTrigger:
|
|
148
|
+
Object.keys(triggerExpectations).length === 0 ? null : triggerExpectations,
|
|
57
149
|
matchingCount: matching.length,
|
|
58
150
|
orders: matching.map(redactOrder),
|
|
59
151
|
proofPath: 'perps-controller-getOpenOrders',
|
|
@@ -27,6 +27,17 @@ import {
|
|
|
27
27
|
// on-exchange notional (size * limitPrice) clears HyperLiquid's ~$10 minimum even
|
|
28
28
|
// though the limit sits far from mid. After placing we verify the resting open
|
|
29
29
|
// order exists (not a filled position).
|
|
30
|
+
//
|
|
31
|
+
// TRIGGER placements (order_type=stop_market | stop_limit | take_profit_market |
|
|
32
|
+
// take_profit_limit) rest off-book until `trigger_price` is reached, then execute
|
|
33
|
+
// as a market or limit order per the suffix. `trigger_price` is absolute, or
|
|
34
|
+
// derived from mid via `trigger_offset_pct`; `*_limit` types also take the
|
|
35
|
+
// execution limit price (`limit_price` / `offset_pct`). Like limit orders they are
|
|
36
|
+
// verified as RESTING open orders. `reduce_only` is passed through as a
|
|
37
|
+
// first-class placement flag, and `take_profit_price`/`take_profit_size` +
|
|
38
|
+
// `stop_loss_price`/`stop_loss_size` (with `tpsl_linkage`) exercise attached and
|
|
39
|
+
// partial TP/SL. Controller param names are used verbatim (triggerPrice,
|
|
40
|
+
// reduceOnly, takeProfitSize, stopLossSize, tpslLinkage).
|
|
30
41
|
|
|
31
42
|
function resolveSymbol(input) {
|
|
32
43
|
const symbols = configuredSymbols(input, []);
|
|
@@ -38,16 +49,105 @@ function resolveSymbol(input) {
|
|
|
38
49
|
return symbols[0];
|
|
39
50
|
}
|
|
40
51
|
|
|
52
|
+
const ORDER_TYPES = [
|
|
53
|
+
'market',
|
|
54
|
+
'limit',
|
|
55
|
+
'stop_market',
|
|
56
|
+
'stop_limit',
|
|
57
|
+
'take_profit_market',
|
|
58
|
+
'take_profit_limit',
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const TRIGGER_ORDER_TYPES = ORDER_TYPES.filter((type) => type.includes('_'));
|
|
62
|
+
|
|
63
|
+
// Order types whose price field is a real limit price the exchange must honour.
|
|
64
|
+
const LIMIT_EXECUTION_ORDER_TYPES = ['limit', 'stop_limit', 'take_profit_limit'];
|
|
65
|
+
|
|
41
66
|
function resolveOrderType(input) {
|
|
42
67
|
const raw = String(
|
|
43
68
|
input.node?.order_type ?? input.node?.orderType ?? 'market',
|
|
44
69
|
).toLowerCase();
|
|
45
|
-
if (raw
|
|
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);
|
|
@@ -707,6 +707,14 @@
|
|
|
707
707
|
},
|
|
708
708
|
"timeout_ms": {
|
|
709
709
|
"type": "number"
|
|
710
|
+
},
|
|
711
|
+
"network": {
|
|
712
|
+
"type": "string",
|
|
713
|
+
"enum": [
|
|
714
|
+
"testnet",
|
|
715
|
+
"mainnet"
|
|
716
|
+
],
|
|
717
|
+
"description": "Venue to act on. Defaults to testnet. Mainnet uses REAL funds: reads work with network=mainnet alone, while signing additionally requires CORE_PERPS_ALLOW_MAINNET_WRITES=1 in the environment."
|
|
710
718
|
}
|
|
711
719
|
},
|
|
712
720
|
"additionalProperties": false
|
|
@@ -721,7 +729,7 @@
|
|
|
721
729
|
"execution_capabilities": []
|
|
722
730
|
},
|
|
723
731
|
"metamask.perps.place_order": {
|
|
724
|
-
"description": "core Place a real Perps order on HyperLiquid testnet by driving the headless perps controller placeOrder() through the full signing/provider path.
|
|
732
|
+
"description": "core Place a real Perps order on HyperLiquid testnet by driving the headless perps controller placeOrder() through the full signing/provider path. Supports market (default), resting limit orders (order_type=limit with price/offset_pct), and resting trigger placements (stop_market | stop_limit | take_profit_market | take_profit_limit with trigger_price/trigger_offset_pct), plus reduce_only and attached/partial TP/SL (take_profit_price/take_profit_size, stop_loss_price/stop_loss_size, tpsl_linkage). Venue is selected with network (testnet default; mainnet signs with REAL funds and also requires CORE_PERPS_ALLOW_MAINNET_WRITES=1). Plain limit orders accept time_in_force GTC | ALO (post-only).",
|
|
725
733
|
"schema": {
|
|
726
734
|
"type": "object",
|
|
727
735
|
"properties": {
|
|
@@ -797,17 +805,45 @@
|
|
|
797
805
|
"type": "string",
|
|
798
806
|
"enum": [
|
|
799
807
|
"market",
|
|
800
|
-
"limit"
|
|
808
|
+
"limit",
|
|
809
|
+
"stop_market",
|
|
810
|
+
"stop_limit",
|
|
811
|
+
"take_profit_market",
|
|
812
|
+
"take_profit_limit"
|
|
813
|
+
],
|
|
814
|
+
"description": "market (default) fills immediately; limit places a RESTING order at price/offset_pct that does not fill; stop_market | stop_limit | take_profit_market | take_profit_limit place a RESTING TRIGGER order that activates at trigger_price and then executes as a market or limit order per the suffix. Alias: orderType."
|
|
815
|
+
},
|
|
816
|
+
"orderType": {
|
|
817
|
+
"type": "string",
|
|
818
|
+
"enum": [
|
|
819
|
+
"market",
|
|
820
|
+
"limit",
|
|
821
|
+
"stop_market",
|
|
822
|
+
"stop_limit",
|
|
823
|
+
"take_profit_market",
|
|
824
|
+
"take_profit_limit"
|
|
801
825
|
],
|
|
802
|
-
"description": "
|
|
826
|
+
"description": "Alias for order_type."
|
|
803
827
|
},
|
|
804
828
|
"limit_price": {
|
|
805
829
|
"type": "string",
|
|
806
830
|
"description": "Absolute resting limit price (limit orders). Takes precedence over offset_pct. Alias: price/limitPrice."
|
|
807
831
|
},
|
|
832
|
+
"limitPrice": {
|
|
833
|
+
"type": "string",
|
|
834
|
+
"description": "Alias for limit_price."
|
|
835
|
+
},
|
|
836
|
+
"price": {
|
|
837
|
+
"type": "string",
|
|
838
|
+
"description": "Alias for limit_price."
|
|
839
|
+
},
|
|
808
840
|
"offset_pct": {
|
|
809
841
|
"type": "number",
|
|
810
|
-
"description": "
|
|
842
|
+
"description": "Limit price as a percent offset from live mid for plain limits, or from the trigger for *_limit orders. Plain limits default to -30 buy / +30 sell; trigger limits default to +1 buy / -1 sell so they are executable after activation. Alias: offsetPct."
|
|
843
|
+
},
|
|
844
|
+
"offsetPct": {
|
|
845
|
+
"type": "number",
|
|
846
|
+
"description": "Alias for offset_pct."
|
|
811
847
|
},
|
|
812
848
|
"amount": {
|
|
813
849
|
"type": [
|
|
@@ -833,6 +869,120 @@
|
|
|
833
869
|
},
|
|
834
870
|
"timeout_ms": {
|
|
835
871
|
"type": "number"
|
|
872
|
+
},
|
|
873
|
+
"trigger_price": {
|
|
874
|
+
"type": "string",
|
|
875
|
+
"description": "Absolute trigger price for stop_*/take_profit_* placements. Takes precedence over trigger_offset_pct. Alias: triggerPrice."
|
|
876
|
+
},
|
|
877
|
+
"triggerPrice": {
|
|
878
|
+
"type": "string",
|
|
879
|
+
"description": "Alias for trigger_price."
|
|
880
|
+
},
|
|
881
|
+
"trigger_offset_pct": {
|
|
882
|
+
"type": "number",
|
|
883
|
+
"description": "Trigger price as a percent offset from live mid (default keeps the trigger ~30% away so it does not fire during a proof). Alias: triggerOffsetPct."
|
|
884
|
+
},
|
|
885
|
+
"triggerOffsetPct": {
|
|
886
|
+
"type": "number",
|
|
887
|
+
"description": "Alias for trigger_offset_pct."
|
|
888
|
+
},
|
|
889
|
+
"reduce_only": {
|
|
890
|
+
"type": "boolean",
|
|
891
|
+
"description": "Place as reduce-only, so the order can only close an existing position. Alias: reduceOnly."
|
|
892
|
+
},
|
|
893
|
+
"reduceOnly": {
|
|
894
|
+
"type": "boolean",
|
|
895
|
+
"description": "Alias for reduce_only."
|
|
896
|
+
},
|
|
897
|
+
"take_profit_price": {
|
|
898
|
+
"type": "string",
|
|
899
|
+
"description": "Attached take profit price. Alias: takeProfitPrice."
|
|
900
|
+
},
|
|
901
|
+
"takeProfitPrice": {
|
|
902
|
+
"type": "string",
|
|
903
|
+
"description": "Alias for take_profit_price."
|
|
904
|
+
},
|
|
905
|
+
"take_profit_size": {
|
|
906
|
+
"type": "string",
|
|
907
|
+
"description": "Quantity covered by the attached take profit (partial TP). Omit to cover the whole order. Alias: takeProfitSize."
|
|
908
|
+
},
|
|
909
|
+
"takeProfitSize": {
|
|
910
|
+
"type": "string",
|
|
911
|
+
"description": "Alias for take_profit_size."
|
|
912
|
+
},
|
|
913
|
+
"stop_loss_price": {
|
|
914
|
+
"type": "string",
|
|
915
|
+
"description": "Attached stop loss price. Alias: stopLossPrice."
|
|
916
|
+
},
|
|
917
|
+
"stopLossPrice": {
|
|
918
|
+
"type": "string",
|
|
919
|
+
"description": "Alias for stop_loss_price."
|
|
920
|
+
},
|
|
921
|
+
"stop_loss_size": {
|
|
922
|
+
"type": "string",
|
|
923
|
+
"description": "Quantity covered by the attached stop loss (partial SL). Omit to cover the whole order. Alias: stopLossSize."
|
|
924
|
+
},
|
|
925
|
+
"stopLossSize": {
|
|
926
|
+
"type": "string",
|
|
927
|
+
"description": "Alias for stop_loss_size."
|
|
928
|
+
},
|
|
929
|
+
"tpsl_linkage": {
|
|
930
|
+
"type": "string",
|
|
931
|
+
"enum": [
|
|
932
|
+
"none",
|
|
933
|
+
"order",
|
|
934
|
+
"position"
|
|
935
|
+
],
|
|
936
|
+
"description": "How an attached TP/SL is linked: to this order ('order'), to the resulting position ('position'), or absent ('none'). Provider-agnostic replacement for HyperLiquid grouping. Alias: tpslLinkage."
|
|
937
|
+
},
|
|
938
|
+
"tpslLinkage": {
|
|
939
|
+
"type": "string",
|
|
940
|
+
"enum": [
|
|
941
|
+
"none",
|
|
942
|
+
"order",
|
|
943
|
+
"position"
|
|
944
|
+
],
|
|
945
|
+
"description": "Alias for tpsl_linkage."
|
|
946
|
+
},
|
|
947
|
+
"max_slippage_bps": {
|
|
948
|
+
"type": "number",
|
|
949
|
+
"description": "Slippage tolerance in basis points; also caps the limit price derived from the trigger for *_market trigger placements. Alias: maxSlippageBps."
|
|
950
|
+
},
|
|
951
|
+
"maxSlippageBps": {
|
|
952
|
+
"type": "number",
|
|
953
|
+
"description": "Alias for max_slippage_bps."
|
|
954
|
+
},
|
|
955
|
+
"network": {
|
|
956
|
+
"type": "string",
|
|
957
|
+
"enum": [
|
|
958
|
+
"testnet",
|
|
959
|
+
"mainnet"
|
|
960
|
+
],
|
|
961
|
+
"description": "Venue to act on. Defaults to testnet. Mainnet uses REAL funds: reads work with network=mainnet alone, while signing additionally requires CORE_PERPS_ALLOW_MAINNET_WRITES=1 in the environment."
|
|
962
|
+
},
|
|
963
|
+
"time_in_force": {
|
|
964
|
+
"type": "string",
|
|
965
|
+
"enum": [
|
|
966
|
+
"GTC",
|
|
967
|
+
"ALO"
|
|
968
|
+
],
|
|
969
|
+
"description": "Time in force for plain limit orders: GTC (default) or ALO (post-only). Alias: timeInForce."
|
|
970
|
+
},
|
|
971
|
+
"timeInForce": {
|
|
972
|
+
"type": "string",
|
|
973
|
+
"enum": [
|
|
974
|
+
"GTC",
|
|
975
|
+
"ALO"
|
|
976
|
+
],
|
|
977
|
+
"description": "Alias for time_in_force."
|
|
978
|
+
},
|
|
979
|
+
"post_only": {
|
|
980
|
+
"type": "boolean",
|
|
981
|
+
"description": "Protocol-agnostic post-only flag; maps to the controller time-in-force ALO. Alias: postOnly."
|
|
982
|
+
},
|
|
983
|
+
"postOnly": {
|
|
984
|
+
"type": "boolean",
|
|
985
|
+
"description": "Alias for post_only."
|
|
836
986
|
}
|
|
837
987
|
},
|
|
838
988
|
"additionalProperties": false
|
|
@@ -951,6 +1101,14 @@
|
|
|
951
1101
|
},
|
|
952
1102
|
"timeout_ms": {
|
|
953
1103
|
"type": "number"
|
|
1104
|
+
},
|
|
1105
|
+
"network": {
|
|
1106
|
+
"type": "string",
|
|
1107
|
+
"enum": [
|
|
1108
|
+
"testnet",
|
|
1109
|
+
"mainnet"
|
|
1110
|
+
],
|
|
1111
|
+
"description": "Venue to act on. Defaults to testnet. Mainnet uses REAL funds: reads work with network=mainnet alone, while signing additionally requires CORE_PERPS_ALLOW_MAINNET_WRITES=1 in the environment."
|
|
954
1112
|
}
|
|
955
1113
|
},
|
|
956
1114
|
"additionalProperties": false
|
|
@@ -1208,6 +1366,14 @@
|
|
|
1208
1366
|
},
|
|
1209
1367
|
"timeout_ms": {
|
|
1210
1368
|
"type": "number"
|
|
1369
|
+
},
|
|
1370
|
+
"network": {
|
|
1371
|
+
"type": "string",
|
|
1372
|
+
"enum": [
|
|
1373
|
+
"testnet",
|
|
1374
|
+
"mainnet"
|
|
1375
|
+
],
|
|
1376
|
+
"description": "Venue to act on. Defaults to testnet. Mainnet uses REAL funds: reads work with network=mainnet alone, while signing additionally requires CORE_PERPS_ALLOW_MAINNET_WRITES=1 in the environment."
|
|
1211
1377
|
}
|
|
1212
1378
|
},
|
|
1213
1379
|
"required": [
|
|
@@ -1321,6 +1487,14 @@
|
|
|
1321
1487
|
},
|
|
1322
1488
|
"timeout_ms": {
|
|
1323
1489
|
"type": "number"
|
|
1490
|
+
},
|
|
1491
|
+
"network": {
|
|
1492
|
+
"type": "string",
|
|
1493
|
+
"enum": [
|
|
1494
|
+
"testnet",
|
|
1495
|
+
"mainnet"
|
|
1496
|
+
],
|
|
1497
|
+
"description": "Venue to act on. Defaults to testnet. Mainnet uses REAL funds: reads work with network=mainnet alone, while signing additionally requires CORE_PERPS_ALLOW_MAINNET_WRITES=1 in the environment."
|
|
1324
1498
|
}
|
|
1325
1499
|
},
|
|
1326
1500
|
"additionalProperties": false
|
|
@@ -1345,7 +1519,7 @@
|
|
|
1345
1519
|
]
|
|
1346
1520
|
},
|
|
1347
1521
|
"metamask.perps.assert_orders": {
|
|
1348
|
-
"description": "core Assert live Perps open orders are present or absent for an explicit market selection or mode=all.",
|
|
1522
|
+
"description": "core Assert live Perps open orders are present or absent for an explicit market selection or mode=all. Optional expect_* fields assert the trigger data of the matching orders (placement type, trigger price, execution mode, reduce-only flag, size).",
|
|
1349
1523
|
"schema": {
|
|
1350
1524
|
"type": "object",
|
|
1351
1525
|
"properties": {
|
|
@@ -1447,6 +1621,40 @@
|
|
|
1447
1621
|
"present"
|
|
1448
1622
|
],
|
|
1449
1623
|
"description": "Desired/expected selected collection state."
|
|
1624
|
+
},
|
|
1625
|
+
"expect_trigger_order_type": {
|
|
1626
|
+
"type": "string",
|
|
1627
|
+
"enum": [
|
|
1628
|
+
"stop_market",
|
|
1629
|
+
"stop_limit",
|
|
1630
|
+
"take_profit_market",
|
|
1631
|
+
"take_profit_limit"
|
|
1632
|
+
],
|
|
1633
|
+
"description": "Require every matching order to be this trigger placement type (read back from the exchange)."
|
|
1634
|
+
},
|
|
1635
|
+
"expect_trigger_price": {
|
|
1636
|
+
"type": "string",
|
|
1637
|
+
"description": "Require every matching order to carry this trigger price."
|
|
1638
|
+
},
|
|
1639
|
+
"expect_execution": {
|
|
1640
|
+
"type": "string",
|
|
1641
|
+
"enum": [
|
|
1642
|
+
"market",
|
|
1643
|
+
"limit"
|
|
1644
|
+
],
|
|
1645
|
+
"description": "Require the execution mode a matching trigger order runs as once it fires."
|
|
1646
|
+
},
|
|
1647
|
+
"expect_reduce_only": {
|
|
1648
|
+
"type": "boolean",
|
|
1649
|
+
"description": "Require the reduce-only flag on every matching order."
|
|
1650
|
+
},
|
|
1651
|
+
"expect_size": {
|
|
1652
|
+
"type": "string",
|
|
1653
|
+
"description": "Require this size on every matching order (proves a partial TP/SL quantity)."
|
|
1654
|
+
},
|
|
1655
|
+
"only_trigger_orders": {
|
|
1656
|
+
"type": "boolean",
|
|
1657
|
+
"description": "Consider only trigger orders (stop / take profit) on the selected market, so expectations are not applied to a plain parent order resting alongside them. Alias: onlyTriggerOrders."
|
|
1450
1658
|
}
|
|
1451
1659
|
},
|
|
1452
1660
|
"required": [
|
|
@@ -1577,6 +1785,14 @@
|
|
|
1577
1785
|
"present"
|
|
1578
1786
|
],
|
|
1579
1787
|
"description": "Desired/expected selected collection state."
|
|
1788
|
+
},
|
|
1789
|
+
"network": {
|
|
1790
|
+
"type": "string",
|
|
1791
|
+
"enum": [
|
|
1792
|
+
"testnet",
|
|
1793
|
+
"mainnet"
|
|
1794
|
+
],
|
|
1795
|
+
"description": "Venue to act on. Defaults to testnet. Mainnet uses REAL funds: reads work with network=mainnet alone, while signing additionally requires CORE_PERPS_ALLOW_MAINNET_WRITES=1 in the environment."
|
|
1580
1796
|
}
|
|
1581
1797
|
},
|
|
1582
1798
|
"required": [
|
|
@@ -2239,6 +2455,109 @@
|
|
|
2239
2455
|
"app-mutation",
|
|
2240
2456
|
"external-mutation"
|
|
2241
2457
|
]
|
|
2458
|
+
},
|
|
2459
|
+
"metamask.perps.update_position_tpsl": {
|
|
2460
|
+
"description": "core Set or replace the TP/SL attached to an existing Perps POSITION by driving the headless perps controller updatePositionTPSL() through the full signing/provider path. Covers whole-position TP/SL and PARTIAL (quantity-scoped) TP/SL via take_profit_size / stop_loss_size, which the provider expresses as standalone reduce-only trigger orders because a position-bound TP/SL always closes the whole position. Venue is selected with network (testnet default; mainnet signs with REAL funds and also requires CORE_PERPS_ALLOW_MAINNET_WRITES=1).",
|
|
2461
|
+
"schema": {
|
|
2462
|
+
"type": "object",
|
|
2463
|
+
"properties": {
|
|
2464
|
+
"market": {
|
|
2465
|
+
"type": "string",
|
|
2466
|
+
"description": "Single market symbol whose position the TP/SL attaches to. Alias: symbol."
|
|
2467
|
+
},
|
|
2468
|
+
"symbol": {
|
|
2469
|
+
"type": "string",
|
|
2470
|
+
"description": "Alias for market."
|
|
2471
|
+
},
|
|
2472
|
+
"markets": {
|
|
2473
|
+
"type": "array",
|
|
2474
|
+
"items": {
|
|
2475
|
+
"type": "string"
|
|
2476
|
+
},
|
|
2477
|
+
"description": "Explicit market list (requires exactly one)."
|
|
2478
|
+
},
|
|
2479
|
+
"symbols": {
|
|
2480
|
+
"type": "array",
|
|
2481
|
+
"items": {
|
|
2482
|
+
"type": "string"
|
|
2483
|
+
},
|
|
2484
|
+
"description": "Alias for markets."
|
|
2485
|
+
},
|
|
2486
|
+
"selector": {
|
|
2487
|
+
"type": "object",
|
|
2488
|
+
"description": "Optional structured selector with mode and symbols/markets."
|
|
2489
|
+
},
|
|
2490
|
+
"take_profit_price": {
|
|
2491
|
+
"type": "string",
|
|
2492
|
+
"description": "Take profit trigger price. Omit to leave the take profit unset. Alias: takeProfitPrice."
|
|
2493
|
+
},
|
|
2494
|
+
"takeProfitPrice": {
|
|
2495
|
+
"type": "string",
|
|
2496
|
+
"description": "Alias for take_profit_price."
|
|
2497
|
+
},
|
|
2498
|
+
"take_profit_size": {
|
|
2499
|
+
"type": "string",
|
|
2500
|
+
"description": "Quantity the take profit covers (partial TP). Omit to cover the whole position. Alias: takeProfitSize."
|
|
2501
|
+
},
|
|
2502
|
+
"takeProfitSize": {
|
|
2503
|
+
"type": "string",
|
|
2504
|
+
"description": "Alias for take_profit_size."
|
|
2505
|
+
},
|
|
2506
|
+
"stop_loss_price": {
|
|
2507
|
+
"type": "string",
|
|
2508
|
+
"description": "Stop loss trigger price. Omit to leave the stop loss unset. Alias: stopLossPrice."
|
|
2509
|
+
},
|
|
2510
|
+
"stopLossPrice": {
|
|
2511
|
+
"type": "string",
|
|
2512
|
+
"description": "Alias for stop_loss_price."
|
|
2513
|
+
},
|
|
2514
|
+
"stop_loss_size": {
|
|
2515
|
+
"type": "string",
|
|
2516
|
+
"description": "Quantity the stop loss covers (partial SL). Omit to cover the whole position. Alias: stopLossSize."
|
|
2517
|
+
},
|
|
2518
|
+
"stopLossSize": {
|
|
2519
|
+
"type": "string",
|
|
2520
|
+
"description": "Alias for stop_loss_size."
|
|
2521
|
+
},
|
|
2522
|
+
"network": {
|
|
2523
|
+
"type": "string",
|
|
2524
|
+
"enum": [
|
|
2525
|
+
"testnet",
|
|
2526
|
+
"mainnet"
|
|
2527
|
+
],
|
|
2528
|
+
"description": "Venue to act on. Defaults to testnet. Mainnet uses REAL funds and additionally requires CORE_PERPS_ALLOW_MAINNET_WRITES=1."
|
|
2529
|
+
},
|
|
2530
|
+
"account": {
|
|
2531
|
+
"type": "string",
|
|
2532
|
+
"description": "EVM address to act for; defaults to MM_TEST_ACCOUNT_ADDRESS."
|
|
2533
|
+
},
|
|
2534
|
+
"timeout_ms": {
|
|
2535
|
+
"type": "number"
|
|
2536
|
+
}
|
|
2537
|
+
},
|
|
2538
|
+
"additionalProperties": false
|
|
2539
|
+
},
|
|
2540
|
+
"examples": [
|
|
2541
|
+
{
|
|
2542
|
+
"action": "metamask.perps.update_position_tpsl",
|
|
2543
|
+
"market": "BTC",
|
|
2544
|
+
"take_profit_price": "120000",
|
|
2545
|
+
"intent": "Attach a whole-position take profit to the open BTC position",
|
|
2546
|
+
"next": "done"
|
|
2547
|
+
},
|
|
2548
|
+
{
|
|
2549
|
+
"action": "metamask.perps.update_position_tpsl",
|
|
2550
|
+
"market": "BTC",
|
|
2551
|
+
"take_profit_price": "120000",
|
|
2552
|
+
"take_profit_size": "0.0002",
|
|
2553
|
+
"intent": "Attach a partial (quantity-scoped) take profit to the open BTC position",
|
|
2554
|
+
"next": "done"
|
|
2555
|
+
}
|
|
2556
|
+
],
|
|
2557
|
+
"execution_capabilities": [
|
|
2558
|
+
"app-mutation",
|
|
2559
|
+
"external-mutation"
|
|
2560
|
+
]
|
|
2242
2561
|
}
|
|
2243
2562
|
}
|
|
2244
2563
|
}
|