@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.
@@ -1,4 +1,5 @@
1
1
  import {
2
+ controllerRejection,
2
3
  getCoreControllerWithSigner,
3
4
  isDirectRun,
4
5
  redactOrder,
@@ -6,6 +7,7 @@ import {
6
7
  runAdapter,
7
8
  selectedItems,
8
9
  symbolForItem,
10
+ optionalParam,
9
11
  } from './_controller.mjs';
10
12
 
11
13
  // core Cancel selected live Perps open orders on HyperLiquid testnet by driving
@@ -37,10 +39,24 @@ async function waitForOrdersAbsent(controller, accountAddress, symbols, timeoutM
37
39
  }
38
40
  }
39
41
 
42
+ export function cancelOrdersFailure(result) {
43
+ if (!result) return 'unknown error';
44
+ const failed = Array.isArray(result.results)
45
+ ? result.results.find((entry) => entry?.success !== true)
46
+ : undefined;
47
+ const hasFailure =
48
+ result.success !== true ||
49
+ Number(result.failureCount ?? 0) > 0 ||
50
+ failed !== undefined;
51
+ return hasFailure ? failed?.error ?? 'unknown error' : undefined;
52
+ }
53
+
40
54
  export async function closeOrders(input) {
41
55
  requireExplicitSelection(input);
42
56
  const { controller, accountAddress, network } = await getCoreControllerWithSigner(input);
43
- const timeoutMs = Number(input.node?.timeout_ms ?? 30000);
57
+ const timeoutMs = Number(
58
+ optionalParam(input.node ?? {}, 'timeout_ms', 'timeoutMs') ?? 30000,
59
+ );
44
60
 
45
61
  const orders = await controller.getOpenOrders({
46
62
  standalone: true,
@@ -66,10 +82,13 @@ export async function closeOrders(input) {
66
82
  // Cancel by the selected symbols. cancelOrders() filters the active provider's
67
83
  // open orders to these symbols and batch-cancels them on the exchange.
68
84
  const result = await controller.cancelOrders({ symbols });
69
- if (!result || result.success !== true) {
70
- throw new Error(
71
- `core cancelOrders failed for ${JSON.stringify(symbols)}: ${JSON.stringify(result)}`,
72
- );
85
+ const failure = cancelOrdersFailure(result);
86
+ if (failure !== undefined) {
87
+ throw controllerRejection({
88
+ action: 'core cancelOrders',
89
+ detail: `${JSON.stringify(symbols)}: ${JSON.stringify(result)}`,
90
+ code: failure,
91
+ });
73
92
  }
74
93
 
75
94
  const after = await waitForOrdersAbsent(controller, accountAddress, symbols, timeoutMs);
@@ -1,4 +1,5 @@
1
1
  import {
2
+ controllerRejection,
2
3
  currentMarketPrice,
3
4
  getCoreControllerWithSigner,
4
5
  isDirectRun,
@@ -7,6 +8,7 @@ import {
7
8
  runAdapter,
8
9
  selectedItems,
9
10
  symbolForItem,
11
+ optionalParam,
10
12
  } from './_controller.mjs';
11
13
 
12
14
  // core Close selected live Perps positions on HyperLiquid testnet by driving the
@@ -36,12 +38,23 @@ async function waitForPositionsAbsent(controller, accountAddress, symbols, timeo
36
38
  }
37
39
  }
38
40
 
41
+ export function closePositionsFailure(results) {
42
+ const failed = results.find((entry) => entry?.success !== true);
43
+ return failed ? failed.result?.error ?? 'unknown error' : undefined;
44
+ }
45
+
39
46
  export async function closePositions(input) {
40
47
  requireExplicitSelection(input);
41
48
  const { controller, accountAddress, network } = await getCoreControllerWithSigner(input);
42
- const timeoutMs = Number(input.node?.timeout_ms ?? 30000);
49
+ const timeoutMs = Number(
50
+ optionalParam(input.node ?? {}, 'timeout_ms', 'timeoutMs') ?? 30000,
51
+ );
43
52
  const maxSlippageBps = Number(
44
- input.node?.max_slippage_bps ?? input.node?.maxSlippageBps ?? 300,
53
+ optionalParam(
54
+ input.node ?? {},
55
+ 'max_slippage_bps',
56
+ 'maxSlippageBps',
57
+ ) ?? 300,
45
58
  );
46
59
 
47
60
  const positions = await controller.getPositions({
@@ -86,12 +99,13 @@ export async function closePositions(input) {
86
99
  if (success) successCount += 1;
87
100
  }
88
101
 
89
- // A wholesale close failure must surface even if a read race shows the
90
- // positions absent mirrors close_orders, which throws on success !== true.
91
- if (matching.length > 0 && successCount === 0) {
92
- throw new Error(
93
- `All ${matching.length} closePosition call(s) reported failure (successCount=0): ${JSON.stringify(results)}`,
94
- );
102
+ const failure = closePositionsFailure(results);
103
+ if (failure !== undefined) {
104
+ throw controllerRejection({
105
+ action: 'core closePosition',
106
+ detail: `${matching.length} call(s): ${JSON.stringify(results)}`,
107
+ code: failure,
108
+ });
95
109
  }
96
110
 
97
111
  const after = await waitForPositionsAbsent(controller, accountAddress, symbols, timeoutMs);
@@ -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);