@deeeed/metamask-harness 0.23.1 → 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.
@@ -0,0 +1,331 @@
1
+ import {
2
+ configuredSymbols,
3
+ controllerRejection,
4
+ currentMarketPrice,
5
+ getCoreControllerWithSigner,
6
+ isDirectRun,
7
+ optionalBooleanParam,
8
+ optionalParam,
9
+ redactOrder,
10
+ requireExplicitSelection,
11
+ runAdapter,
12
+ selectedItems,
13
+ symbolForItem,
14
+ } from './_controller.mjs';
15
+
16
+ // core Modify a RESTING Perps open order by driving the headless
17
+ // PerpsController.editOrder() through the full signing/provider path.
18
+ //
19
+ // HyperLiquid's `modify` rebuilds the order as a plain limit/market order, so a
20
+ // trigger on either side of the edit would be silently dropped: editing a
21
+ // resting stop into a plain order removes the protection while reporting
22
+ // success. The controller therefore refuses both directions, and refuses
23
+ // outright when it cannot establish what the resting order is. Those refusals
24
+ // are the interesting half of this action's contract — pair it with
25
+ // `expect_error` to prove them.
26
+ //
27
+ // The order to edit is selected by `order_id` (preferred) or, when the market
28
+ // holds exactly one open order, by `market` alone. `new_order` fields use the
29
+ // controller's own provider-agnostic names, so a recipe reads the same way as
30
+ // place_order: side, size, price, order_type, time_in_force, reduce_only.
31
+
32
+ /**
33
+ * Resolve the new limit price, absolute or offset from live mid.
34
+ *
35
+ * Mirrors place_order's precedence so a recipe reads the same either way: an
36
+ * explicit `price` wins, otherwise `offset_pct` is applied to mid. Returns
37
+ * undefined when neither is given, which is correct for a market order.
38
+ *
39
+ * @param input - Adapter input (node.price / node.offset_pct).
40
+ * @param controller - Headless perps controller, used to read live mid.
41
+ * @param symbol - Market the order belongs to.
42
+ * @returns The price as a string, or undefined when the node sets none.
43
+ */
44
+ async function resolvePrice(input, controller, symbol) {
45
+ const node = input.node ?? {};
46
+ const absolute = optionalParam(node, 'price', 'price');
47
+ if (absolute !== undefined && absolute.length > 0) {
48
+ return absolute;
49
+ }
50
+
51
+ const rawOffset = optionalParam(node, 'offset_pct', 'offsetPct');
52
+ if (rawOffset === undefined) {
53
+ return undefined;
54
+ }
55
+
56
+ const offsetPct = Number(rawOffset);
57
+ if (!Number.isFinite(offsetPct)) {
58
+ throw new Error(
59
+ `metamask.perps.edit_order received invalid offset_pct: ${rawOffset}.`,
60
+ );
61
+ }
62
+
63
+ const mid = await currentMarketPrice(controller, symbol);
64
+ const price = mid * (1 + offsetPct / 100);
65
+ if (!Number.isFinite(price) || price <= 0) {
66
+ throw new Error(
67
+ `metamask.perps.edit_order computed a non-positive price (${price}) from offset_pct=${offsetPct}.`,
68
+ );
69
+ }
70
+ return String(price);
71
+ }
72
+
73
+ /**
74
+ * Resolve the resting order this node edits.
75
+ *
76
+ * @param input - Adapter input carrying order_id and/or market selection.
77
+ * @param openOrders - Live open orders for the account.
78
+ * @returns The single matching order.
79
+ */
80
+ export function resolveTargetOrder(input, openOrders, symbol) {
81
+ const node = input.node ?? {};
82
+ const orderId = optionalParam(node, 'order_id', 'orderId');
83
+ const selected = openOrders.filter((order) => symbolForItem(order) === symbol);
84
+
85
+ // An explicit id is what the caller asked to edit, so it is authoritative
86
+ // whatever the account currently holds — the controller is the thing entitled
87
+ // to decide that an order cannot be verified. The lookup is therefore
88
+ // non-throwing and only enriches the evidence; it must never gate the request,
89
+ // or what gets submitted would depend on local state.
90
+ if (orderId !== undefined) {
91
+ return {
92
+ orderId,
93
+ order:
94
+ selected.find(
95
+ (order) => String(order.orderId ?? order.oid) === orderId,
96
+ ) ?? null,
97
+ };
98
+ }
99
+
100
+ if (selected.length !== 1) {
101
+ throw new Error(
102
+ `metamask.perps.edit_order requires order_id when the selection does not resolve to exactly one open order; got ${selected.length}.`,
103
+ );
104
+ }
105
+ return {
106
+ orderId: String(selected[0].orderId ?? selected[0].oid),
107
+ order: selected[0],
108
+ };
109
+ }
110
+
111
+ // The controller normalizes side to 'buy' | 'sell' (Order.side); there is no
112
+ // isBuy field and never a raw 'B'. Exported so the vocabulary is pinned by a
113
+ // test: comparing against the venue's form instead matched nothing at all, and
114
+ // a matcher that matches nothing makes a "stricter" check prove less than the
115
+ // loose one it replaced.
116
+ export function isLongOrder(order) {
117
+ if (order.isBuy !== undefined) return Boolean(order.isBuy);
118
+ const raw = order.side ?? order.dir;
119
+ if (raw === undefined) return undefined;
120
+ const value = String(raw).toLowerCase();
121
+ if (value === 'buy' || value === 'b' || value.includes('long')) return true;
122
+ if (value === 'sell' || value === 'a' || value.includes('short')) return false;
123
+ return undefined;
124
+ }
125
+
126
+ export async function editOrder(input) {
127
+ requireExplicitSelection(input);
128
+ const symbols = configuredSymbols(input, []);
129
+ if (symbols.length !== 1) {
130
+ throw new Error(
131
+ `metamask.perps.edit_order requires exactly one market; got ${JSON.stringify(symbols)}.`,
132
+ );
133
+ }
134
+ const symbol = symbols[0];
135
+
136
+ const node = input.node ?? {};
137
+ const requestedSize = optionalParam(node, 'size', 'size');
138
+ const side = optionalParam(node, 'side', 'side')?.toLowerCase();
139
+ if (side !== 'long' && side !== 'short') {
140
+ throw new Error(
141
+ 'metamask.perps.edit_order requires side=long or side=short.',
142
+ );
143
+ }
144
+
145
+ const orderType = optionalParam(node, 'order_type', 'orderType') ?? 'limit';
146
+ const triggerPrice = optionalParam(node, 'trigger_price', 'triggerPrice');
147
+ const requestedTimeInForce = optionalParam(
148
+ node,
149
+ 'time_in_force',
150
+ 'timeInForce',
151
+ );
152
+ const timeInForce = requestedTimeInForce?.toUpperCase();
153
+ if (timeInForce !== undefined && !['GTC', 'ALO'].includes(timeInForce)) {
154
+ throw new Error(
155
+ `metamask.perps.edit_order received unsupported time_in_force: ${timeInForce}. Supported: GTC | ALO.`,
156
+ );
157
+ }
158
+ const reduceOnly = optionalBooleanParam(
159
+ node,
160
+ 'reduce_only',
161
+ 'reduceOnly',
162
+ );
163
+
164
+ const { controller, accountAddress, network } =
165
+ await getCoreControllerWithSigner(input);
166
+
167
+ // Same precedence as place_order: an absolute price wins, otherwise the price
168
+ // is derived from live mid by offset_pct, so a recipe can reprice far from mid
169
+ // without hard-coding a number that only suits one market.
170
+ const price = await resolvePrice(input, controller, symbol);
171
+
172
+ const openOrdersBefore = await controller.getOpenOrders({
173
+ standalone: true,
174
+ userAddress: accountAddress,
175
+ });
176
+ const resolved = resolveTargetOrder(input, openOrdersBefore, symbol);
177
+ const targetOrderId = resolved.orderId;
178
+ const target = resolved.order;
179
+
180
+ // Repricing should not have to restate the size. Falling back to the resting
181
+ // order's own size keeps a recipe free of an absolute that only suits one
182
+ // market — the caller still overrides it to prove a resize.
183
+ // Prefer the caller's size, then the resting order's own, then a size derived
184
+ // from a USD notional. The last case matters for a refusal proof against an
185
+ // order the account does not hold: there is nothing to read a size from, and
186
+ // editOrder validates size before it ever reports that the order could not be
187
+ // verified, so without one the proof never reaches the refusal it is testing.
188
+ let size = requestedSize ?? (target ? String(target.size ?? target.sz) : undefined);
189
+ if (size === undefined) {
190
+ const notional = optionalParam(node, 'notional', 'notional');
191
+ if (notional === undefined) {
192
+ throw new Error(
193
+ 'metamask.perps.edit_order requires size=<contracts> or notional=<usd> when the resting order cannot be read.',
194
+ );
195
+ }
196
+ const mid = await currentMarketPrice(controller, symbol);
197
+ size = String(Number(notional) / mid);
198
+ }
199
+
200
+ const newOrder = {
201
+ symbol,
202
+ isBuy: side === 'long',
203
+ size,
204
+ orderType,
205
+ ...(price === undefined ? {} : { price }),
206
+ ...(triggerPrice === undefined ? {} : { triggerPrice }),
207
+ ...(timeInForce === undefined ? {} : { timeInForce }),
208
+ ...(reduceOnly === undefined ? {} : { reduceOnly: Boolean(reduceOnly) }),
209
+ };
210
+
211
+ const result = await controller.editOrder({
212
+ orderId: targetOrderId,
213
+ newOrder,
214
+ });
215
+ if (!result || result.success !== true) {
216
+ throw controllerRejection({
217
+ action: 'core editOrder',
218
+ detail: `${symbol} order ${targetOrderId}`,
219
+ code: result?.error ?? 'unknown error',
220
+ });
221
+ }
222
+ if (target === null) {
223
+ throw new Error(
224
+ `core editOrder for ${symbol} reported success for order ${targetOrderId}, but that order was not present before the edit, so no replacement can be proven.`,
225
+ );
226
+ }
227
+
228
+ // An acknowledgement is not proof, and neither is "some order is still open".
229
+ // Two weaker anchors were tried and both fail:
230
+ // - attributes alone: an unrelated order sharing size and price satisfies it;
231
+ // - the order id: a venue modify REPLACES the order, resting it under a new
232
+ // id while the controller still reports the old one (observed on testnet,
233
+ // target 57185095325 became 57185125383), so no id we hold is ever present.
234
+ // Excluding only the target id is still not enough — a lookalike that was
235
+ // already resting before the edit passes. So anchor on NOVELTY against a
236
+ // complete pre-edit snapshot: exactly one id that did not exist before, and it
237
+ // must carry every attribute requested, not just size and price.
238
+ const preEditIds = new Set(
239
+ openOrdersBefore.map((order) => String(order.orderId ?? order.oid)),
240
+ );
241
+ const sameNumber = (left, right) => {
242
+ const a = Number(left);
243
+ const b = Number(right);
244
+ return Number.isFinite(a) && Number.isFinite(b)
245
+ ? Math.abs(a - b) <= Math.max(1e-9, Math.abs(b) * 1e-4)
246
+ : String(left) === String(right);
247
+ };
248
+ const sameFlag = (actual, expected) =>
249
+ expected === undefined ||
250
+ (actual !== undefined && Boolean(actual) === Boolean(expected));
251
+ // The controller normalizes side to 'buy' | 'sell' (Order.side) — there is no
252
+ // isBuy field and never a raw 'B'. Comparing against the venue's vocabulary
253
+ // instead of the controller's matched nothing at all, which is how a stricter
254
+ // check can end up proving less than the loose one it replaced. The raw forms
255
+ // stay as a fallback for an unadapted payload.
256
+ const orderTypeOf = (order) => order.orderType ?? order.orderTypeLabel;
257
+ const matchesRequest = (order) =>
258
+ (order.symbol ?? order.coin) === symbol &&
259
+ sameNumber(order.size ?? order.sz, size) &&
260
+ (price === undefined || sameNumber(order.price ?? order.limitPx, price)) &&
261
+ sameFlag(isLongOrder(order), side === 'long') &&
262
+ sameFlag(order.reduceOnly ?? order.reduce_only, reduceOnly) &&
263
+ (orderType === undefined ||
264
+ (orderTypeOf(order) !== undefined &&
265
+ String(orderTypeOf(order)).toLowerCase() === String(orderType).toLowerCase()));
266
+
267
+ const deadline =
268
+ Date.now() +
269
+ Math.min(
270
+ Number(optionalParam(node, 'timeout_ms', 'timeoutMs') ?? 30000) * 0.6,
271
+ 60000,
272
+ );
273
+ let openOrdersAfter = [];
274
+ let replacements = [];
275
+ let targetStillResting = true;
276
+ for (;;) {
277
+ openOrdersAfter = await controller.getOpenOrders({
278
+ standalone: true,
279
+ userAddress: accountAddress,
280
+ });
281
+ targetStillResting = openOrdersAfter.some(
282
+ (order) => String(order.orderId ?? order.oid) === targetOrderId,
283
+ );
284
+ replacements = openOrdersAfter.filter(
285
+ (order) =>
286
+ !preEditIds.has(String(order.orderId ?? order.oid)) && matchesRequest(order),
287
+ );
288
+ if ((!targetStillResting && replacements.length === 1) || Date.now() >= deadline) {
289
+ break;
290
+ }
291
+ await new Promise((resolve) => setTimeout(resolve, 500));
292
+ }
293
+
294
+ const resting = openOrdersAfter
295
+ .filter((order) => (order.symbol ?? order.coin) === symbol)
296
+ .map(
297
+ (order) =>
298
+ `${order.orderId ?? order.oid}@${order.price ?? order.limitPx}x${order.size ?? order.sz}${preEditIds.has(String(order.orderId ?? order.oid)) ? ' (pre-existing)' : ' (new)'}`,
299
+ );
300
+ if (targetStillResting) {
301
+ throw new Error(
302
+ `core editOrder for ${symbol} reported success but the edited order ${targetOrderId} is still resting, so nothing was replaced. Resting now: ${resting.join(', ') || 'none'}.`,
303
+ );
304
+ }
305
+ if (replacements.length !== 1) {
306
+ throw new Error(
307
+ `core editOrder for ${symbol} reported success but ${replacements.length} newly-created orders match the request (size=${size}${price === undefined ? '' : `, price=${price}`}, side=${side}${reduceOnly === undefined ? '' : `, reduceOnly=${Boolean(reduceOnly)}`}) — expected exactly one. Resting now: ${resting.join(', ') || 'none'}.`,
308
+ );
309
+ }
310
+ const edited = replacements[0];
311
+
312
+ return {
313
+ action: input.action,
314
+ source: 'perps-controller-editOrder',
315
+ network,
316
+ account: accountAddress,
317
+ market: symbol,
318
+ orderId: targetOrderId,
319
+ editedOrderId: String(edited.orderId ?? edited.oid),
320
+ replacedOrderId: targetOrderId,
321
+ before: target ? redactOrder(target) : null,
322
+ requested: newOrder,
323
+ submitted: true,
324
+ verified: true,
325
+ orders: selectedItems(input, openOrdersAfter).map(redactOrder),
326
+ result,
327
+ proofPath: 'perps-controller-editOrder',
328
+ };
329
+ }
330
+
331
+ if (isDirectRun(import.meta.url)) runAdapter(editOrder);
@@ -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 = String(
68
- input.node?.order_type ?? input.node?.orderType ?? 'market',
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 = input.node?.trigger_price ?? input.node?.triggerPrice;
95
- if (absolute !== undefined && absolute !== null && String(absolute).length > 0) {
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 = input.node?.trigger_offset_pct ?? input.node?.triggerOffsetPct;
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
- const value = node[snake] ?? node[camel];
134
- return value === undefined || value === null ? undefined : String(value);
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: pick('take_profit_price', '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: pick('stop_loss_price', '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.tpsl_linkage ?? node.tpslLinkage;
143
- if (linkage !== undefined && linkage !== null) {
144
- attached.tpslLinkage = String(linkage);
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 absolute = input.node?.limit_price ?? input.node?.limitPrice ?? input.node?.price;
163
- if (absolute !== undefined && absolute !== null && String(absolute).length > 0) {
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?.offset_pct ?? input.node?.offsetPct;
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.limit_price !== undefined ||
187
- node.limitPrice !== undefined ||
188
- node.price !== undefined;
274
+ optionalParam(node, 'limit_price', 'limitPrice') !== undefined ||
275
+ optionalParam(node, 'price') !== undefined;
189
276
  const hasExplicitOffset =
190
- node.offset_pct !== undefined || node.offsetPct !== undefined;
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
- if (input.node?.side == null) throw new Error('metamask.perps.place_order requires side=long or side=short.');
269
- if (input.node?.amount == null && input.node?.notional == null) {
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
- const side = String(input.node.side).toLowerCase();
274
- const isBuy = side !== 'short';
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 usdAmount = String(input.node.amount ?? input.node.notional);
277
- const leverage = Number(input.node?.leverage ?? 3);
377
+ const leverage = Number(optionalParam(input.node ?? {}, 'leverage') ?? 3);
278
378
  const maxSlippageBps = Number(
279
- input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300,
379
+ optionalParam(
380
+ input.node ?? {},
381
+ 'max_slippage_bps',
382
+ 'maxSlippageBps',
383
+ ) ?? 300,
280
384
  );
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
- ) {
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 time_in_force/post_only only for order_type=limit.',
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?.reduce_only ?? input.node?.reduceOnly ?? undefined;
322
- const attachedTpsl = resolveAttachedTpsl(input);
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 new Error(
390
- `core placeOrder failed for ${symbol} (${orderType}): ${result?.error ?? 'unknown error'} (currentPrice=${currentPrice}${limitPrice ? `, limitPrice=${limitPrice}` : ''}).`,
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?.timeout_ms ?? 30000),
562
+ Number(optionalParam(input.node ?? {}, 'timeout_ms', 'timeoutMs') ?? 30000),
414
563
  );
415
564
  } else {
416
565
  positions = await controller.getPositions({