@reyaxyz/sdk 0.151.4 → 0.152.1

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.
Files changed (31) hide show
  1. package/README.md +1 -1
  2. package/dist/services/orders/index.js +1 -1
  3. package/dist/services/orders/index.js.map +1 -1
  4. package/dist/services/orders/nonce.js +37 -0
  5. package/dist/services/orders/nonce.js.map +1 -0
  6. package/dist/services/orders/orderV2.js +416 -89
  7. package/dist/services/orders/orderV2.js.map +1 -1
  8. package/dist/services/orders/triggerSettlementHeadroom.js +51 -0
  9. package/dist/services/orders/triggerSettlementHeadroom.js.map +1 -0
  10. package/dist/services/orders/types.js.map +1 -1
  11. package/dist/types/services/orders/index.d.ts +1 -1
  12. package/dist/types/services/orders/index.d.ts.map +1 -1
  13. package/dist/types/services/orders/nonce.d.ts +2 -0
  14. package/dist/types/services/orders/nonce.d.ts.map +1 -0
  15. package/dist/types/services/orders/orderV2.d.ts +194 -14
  16. package/dist/types/services/orders/orderV2.d.ts.map +1 -1
  17. package/dist/types/services/orders/triggerSettlementHeadroom.d.ts +33 -0
  18. package/dist/types/services/orders/triggerSettlementHeadroom.d.ts.map +1 -0
  19. package/dist/types/services/orders/types.d.ts +1 -11
  20. package/dist/types/services/orders/types.d.ts.map +1 -1
  21. package/package.json +8 -7
  22. package/src/services/orders/index.ts +1 -1
  23. package/src/services/orders/nonce.ts +34 -0
  24. package/src/services/orders/orderV2.ts +731 -122
  25. package/src/services/orders/triggerSettlementHeadroom.ts +56 -0
  26. package/src/services/orders/types.ts +0 -15
  27. package/dist/services/orders/order.js +0 -231
  28. package/dist/services/orders/order.js.map +0 -1
  29. package/dist/types/services/orders/order.d.ts +0 -4
  30. package/dist/types/services/orders/order.d.ts.map +0 -1
  31. package/src/services/orders/order.ts +0 -214
@@ -1,100 +1,292 @@
1
- import { Signer, JsonRpcSigner, AbiCoder } from 'ethers';
1
+ import { Signer, JsonRpcSigner, parseUnits } from 'ethers';
2
2
  import {
3
3
  OrderEntryApi,
4
4
  Configuration,
5
- CancelOrderResponse,
6
5
  CreateOrderResponse,
7
- MassCancelResponse,
8
6
  OrderType,
9
7
  TimeInForce,
8
+ CancelOrderResponse,
9
+ MassCancelResponse,
10
+ CancelAllAfterResponse,
11
+ ModifyOrderResponse,
12
+ ModifyOrderRequest,
10
13
  } from '@reyaxyz/api-v2-sdk';
11
14
  import {
12
- signOrdersGatewayOrder,
13
- signOrderCancel,
14
- signMassCancel,
15
- OrdersGatewayOrderType,
16
- scale,
17
- CONDITIONAL_ORDER_SIG_DEADLINE,
15
+ signOrder,
16
+ signMEOrderCancel,
17
+ signMEMassCancel,
18
+ signMECancelAllAfter,
19
+ OrderType as OnChainOrderType,
20
+ fullPositionStopQuantity,
18
21
  } from '@reyaxyz/common';
19
22
  import { getReyaNetwork } from '../../utils/network';
20
23
  import { getSdkConfig } from '../../config';
24
+ import { nextNonce } from './nonce';
25
+ import { assertSettlementHeadroom } from './triggerSettlementHeadroom';
26
+
27
+ // A provided, non-zero order id. '' / whitespace / '0' / all-zeros are the
28
+ // engine's "not provided" sentinels; normalize them consistently across cancel
29
+ // + modify so a caller passing orderId:'0' (or '') can't sign/target the
30
+ // sentinel or slip past the "requires an id" guard.
31
+ const isProvidedNonZeroId = (value: string | undefined): value is string => {
32
+ const trimmed = value?.trim();
33
+ return !!trimmed && !/^0+$/.test(trimmed);
34
+ };
21
35
 
22
- const DEFAULT_CANCEL_DEADLINE_SECONDS = 60;
36
+ // Maps the REST-surface `OrderType` (string: 'LIMIT' / 'STOP_LOSS' / 'TAKE_PROFIT',
37
+ // from @reyaxyz/api-v2-sdk) to the on-chain numeric enum signed into
38
+ // `OrderDetails.orderType` (0 / 1 / 2, from @reyaxyz/common).
39
+ export const toOnChainOrderType = (orderType: OrderType): OnChainOrderType => {
40
+ switch (orderType) {
41
+ case 'LIMIT':
42
+ return OnChainOrderType.Limit;
43
+ case 'STOP_LOSS':
44
+ return OnChainOrderType.StopLoss;
45
+ case 'TAKE_PROFIT':
46
+ return OnChainOrderType.TakeProfit;
47
+ default:
48
+ throw new Error(`Unsupported orderType: ${orderType}`);
49
+ }
50
+ };
23
51
 
24
- export { TimeInForce };
52
+ // Maps the REST-surface `TimeInForce` to the numeric value signed into
53
+ // `OrderDetails.timeInForce`, matching the off-chain verifier's
54
+ // REST_TIME_IN_FORCE_TO_NUMERIC (common-backend signature-validation):
55
+ // GTC=0, IOC=1, GTT=2.
56
+ export const toOnChainTimeInForce = (timeInForce: TimeInForce): number => {
57
+ switch (timeInForce) {
58
+ case 'GTC':
59
+ return 0;
60
+ case 'IOC':
61
+ return 1;
62
+ case 'GTT':
63
+ return 2;
64
+ default:
65
+ throw new Error(`Unsupported timeInForce: ${timeInForce}`);
66
+ }
67
+ };
25
68
 
26
- export type CreateSpotOrderParams = {
69
+ export type CreateOrderParams = {
27
70
  signer: Signer | JsonRpcSigner;
28
71
  accountId: number;
29
72
  exchangeId: number;
73
+ /** Unified market id: core_id for perp, core_id + 1e10 for spot. */
30
74
  marketId: number;
31
75
  isBuy: boolean;
32
76
  limitPx: string;
33
- qty: string;
77
+ /**
78
+ * Order quantity. Type-conditional, mirroring the modify-trigger path:
79
+ * - LIMIT: REQUIRED — signed as ±qty (positive buy, negative sell) and sent
80
+ * on the wire.
81
+ * - STOP_LOSS / TAKE_PROFIT: OMIT — the signed quantity is the ±int256.max
82
+ * full-position sentinel (sign from `isBuy`; protect the whole position)
83
+ * and qty is dropped from the wire payload. Passing a qty on a trigger
84
+ * create throws (the backend rejects it).
85
+ */
86
+ qty?: string;
34
87
  symbol: string;
88
+ orderType: OrderType;
89
+ /**
90
+ * REQUIRED for every order class — signed and sent on the wire. On a
91
+ * STOP_LOSS / TAKE_PROFIT it chooses what the stop becomes when it fires:
92
+ * IOC fills what the book offers and cancels the rest, GTC rests the
93
+ * remainder until cancelled, GTT rests it until one settlement headroom
94
+ * before `expiresAfter`.
95
+ */
35
96
  timeInForce: TimeInForce;
36
- clientOrderId?: number;
37
- };
38
-
39
- const encodeSpotLimitOrderInputs = (
40
- isBuy: boolean,
41
- limitPx: string,
42
- qty: string,
43
- ): string => {
44
- const baseDelta = isBuy
45
- ? scale(18)(parseFloat(qty))
46
- : -scale(18)(parseFloat(qty));
47
- const price = scale(18)(parseFloat(limitPx));
48
- return AbiCoder.defaultAbiCoder().encode(
49
- ['int256', 'uint256'],
50
- [baseDelta, price],
51
- );
97
+ /** Required for STOP_LOSS / TAKE_PROFIT; omit for LIMIT. */
98
+ triggerPx?: string;
99
+ /** Perp only; spot markets must set this to false / omit. */
100
+ reduceOnly?: boolean;
101
+ /**
102
+ * Maker-only intent. Signed into OrderDetails. Valid on GTC/GTT; the API
103
+ * rejects it on IOC + TP/SL. Omit / false for a normal order.
104
+ */
105
+ postOnly?: boolean;
106
+ /** Off-chain correlation id. Signed but not on-chain-enforced. */
107
+ clientOrderId?: string;
108
+ /** Unix seconds. On-chain order lifetime; omit for no-expiry orders. */
109
+ expiresAfter?: number;
110
+ /**
111
+ * Unix seconds. EIP-712 signature deadline (envelope). API-enforced at entry.
112
+ * Defaults to now + 30s when omitted.
113
+ */
114
+ deadline?: number;
115
+ /**
116
+ * Seconds of settlement headroom a GTT `expiresAfter` must outlast. Defaults
117
+ * to the production value; set it only for a deployment running a different
118
+ * one.
119
+ */
120
+ settlementHeadroomSeconds?: number;
121
+ /**
122
+ * Optional explicit nonce. Must be strictly greater than the signer's last
123
+ * accepted nonce in the matching engine. Defaults to a per-signer monotonic
124
+ * µs value.
125
+ */
126
+ nonce?: bigint;
52
127
  };
53
128
 
54
- export const createSpotOrder = async (
55
- params: CreateSpotOrderParams,
129
+ export type CancelMEOrderParams = {
130
+ signer: Signer | JsonRpcSigner;
131
+ accountId: number;
132
+ marketId: number;
133
+ symbol: string;
134
+ orderId?: string;
135
+ clientOrderId?: string;
136
+ /** Unix seconds; EIP-712 signature deadline. Defaults to now + 30s. */
137
+ deadline?: number;
138
+ /** Optional explicit nonce; otherwise per-signer monotonic µs. */
139
+ nonce?: bigint;
140
+ };
141
+
142
+ export type MassCancelMEOrdersParams = {
143
+ signer: Signer | JsonRpcSigner;
144
+ accountId: number;
145
+ marketId?: number;
146
+ symbol?: string;
147
+ /** Unix seconds; EIP-712 signature deadline. Defaults to now + 30s. */
148
+ deadline?: number;
149
+ /** Optional explicit nonce; otherwise per-signer monotonic µs. */
150
+ nonce?: bigint;
151
+ };
152
+
153
+ const DEFAULT_DEADLINE_SECONDS = 30;
154
+
155
+ const isZeroClientOrderId = (clientOrderId: string | undefined): boolean =>
156
+ clientOrderId !== undefined && /^0+$/.test(clientOrderId.trim());
157
+
158
+ const assertNonZeroClientOrderId = (
159
+ clientOrderId: string | undefined,
160
+ ): void => {
161
+ if (isZeroClientOrderId(clientOrderId)) {
162
+ throw new Error('clientOrderId must be omitted rather than set to 0');
163
+ }
164
+ };
165
+
166
+ // Spot unified market ids are offset by 1e10 (see CreateOrderParams.marketId).
167
+ // Spot markets don't support reduceOnly, so reject it at the SDK boundary
168
+ // rather than sign a request the backend will reject.
169
+ const SPOT_MARKET_ID_OFFSET = 10_000_000_000;
170
+
171
+ export const createOrder = async (
172
+ params: CreateOrderParams,
56
173
  ): Promise<CreateOrderResponse> => {
174
+ assertNonZeroClientOrderId(params.clientOrderId);
175
+
57
176
  const reyaChainId = getReyaNetwork();
58
177
  const config = getSdkConfig();
59
178
 
60
- const inputs = encodeSpotLimitOrderInputs(
61
- params.isBuy,
62
- params.limitPx,
63
- params.qty,
64
- );
179
+ const isTrigger =
180
+ params.orderType === OrderType.STOP_LOSS ||
181
+ params.orderType === OrderType.TAKE_PROFIT;
65
182
 
66
- const creationTimestampMs = Date.now();
67
- // IOC orders are capped at 10 minutes by the API; GTC orders use the
68
- // conditional-order sentinel deadline so they rest until cancelled.
69
- const deadline = (() => {
70
- switch (params.timeInForce) {
71
- case TimeInForce.IOC:
72
- return Math.floor(creationTimestampMs / 1000) + 30;
73
- case TimeInForce.GTC:
74
- return CONDITIONAL_ORDER_SIG_DEADLINE;
75
- default:
76
- throw new Error(`Unsupported timeInForce: ${params.timeInForce}`);
77
- }
78
- })();
79
-
80
- // For spot orders, nonce is just the timestamp (uint64)
81
- const spotNonce = BigInt(creationTimestampMs);
82
-
83
- const { serializedSignature, nonce } = await signOrdersGatewayOrder(
84
- params.signer,
85
- reyaChainId,
86
- params.accountId,
87
- params.marketId,
88
- params.exchangeId,
89
- [],
90
- OrdersGatewayOrderType.LIMIT_ORDER_SPOT,
91
- inputs,
92
- deadline,
93
- creationTimestampMs,
94
- spotNonce, // custom nonce for spot orders
95
- );
183
+ if (
184
+ isTrigger &&
185
+ (params.reduceOnly !== undefined || params.postOnly !== undefined)
186
+ ) {
187
+ throw new Error('reduceOnly and postOnly must be omitted for TP/SL orders');
188
+ }
189
+
190
+ if (isTrigger && params.triggerPx === undefined) {
191
+ throw new Error(`triggerPx is required for ${params.orderType} orders`);
192
+ }
193
+
194
+ // qty is type-conditional (mirrors the modify-trigger path):
195
+ // - STOP_LOSS / TAKE_PROFIT: qty MUST be omitted; the signed quantity is
196
+ // the ±int256.max full-position sentinel (sign from `isBuy`) and qty is
197
+ // dropped from the wire below. The backend rejects a trigger create that
198
+ // carries qty.
199
+ // - LIMIT: qty is REQUIRED and signed as ±qty.
200
+ // Validated up front (before a nonce is minted), like the triggerPx guard.
201
+ if (isTrigger && params.qty !== undefined) {
202
+ throw new Error(
203
+ `qty must be omitted for ${params.orderType} orders (the signed quantity is the full-position sentinel)`,
204
+ );
205
+ }
206
+ if (!isTrigger && params.qty === undefined) {
207
+ throw new Error('qty is required for a LIMIT order');
208
+ }
209
+
210
+ // Convert once while validation is still side-effect free. timeInForce is
211
+ // required for every order class — on a trigger it chooses what the fired
212
+ // child becomes, so there is no value the SDK could supply on the caller's
213
+ // behalf. Runtime callers can bypass the TypeScript type; reject a missing or
214
+ // unrecognised value before resolving the signer or advancing its nonce.
215
+ const onChainTimeInForce = toOnChainTimeInForce(params.timeInForce);
216
+
217
+ // Only GTT carries a lifetime, for triggers as for book orders. On a trigger
218
+ // the one timestamp bounds the armed window AND is the fired child's on-chain
219
+ // settlement deadline; GTC and IOC triggers have no lifetime of their own.
220
+ const hasExpiry =
221
+ params.expiresAfter !== undefined && params.expiresAfter !== 0;
222
+ if (params.timeInForce === TimeInForce.GTT && !hasExpiry) {
223
+ throw new Error('expiresAfter is required for GTT orders');
224
+ }
225
+ if (params.timeInForce !== TimeInForce.GTT && hasExpiry) {
226
+ throw new Error(
227
+ 'expiresAfter must be omitted for IOC / GTC orders — only GTT carries a lifetime',
228
+ );
229
+ }
230
+
231
+ if (params.reduceOnly && params.marketId >= SPOT_MARKET_ID_OFFSET) {
232
+ throw new Error('reduceOnly is not supported for spot markets');
233
+ }
234
+
235
+ // Venue admission rules the SDK can evaluate itself, checked while validation
236
+ // is still side-effect free — a nonce burnt on an order the engine refuses
237
+ // has to be resynced before the next one can be signed.
238
+ if (hasExpiry) {
239
+ assertSettlementHeadroom(
240
+ params.expiresAfter!,
241
+ params.settlementHeadroomSeconds,
242
+ );
243
+ }
96
244
 
97
245
  const signerAddress = await params.signer.getAddress();
246
+ const defaultDeadline =
247
+ Math.floor(Date.now() / 1000) + DEFAULT_DEADLINE_SECONDS;
248
+ const deadline = params.deadline ?? defaultDeadline;
249
+ const expiresAfter = params.expiresAfter ?? 0;
250
+ const nonce = params.nonce ?? nextNonce(signerAddress);
251
+
252
+ // On-chain `OrderDetails.quantity` is signed int256 (E18 for real sizes): a
253
+ // trigger signs the ±int256.max full-position sentinel — sign from `isBuy`
254
+ // (−max sells out a long, +max buys back a short; reya-network #738 enforces
255
+ // SL/TP <=> ±sentinel, and quantity 0 now reverts) — while a LIMIT signs ±qty
256
+ // (positive buy, negative sell). The REST surface keeps `isBuy + qty`
257
+ // (unsigned) for symmetry with the rest of the API; reconstruct the signed
258
+ // value here.
259
+ //
260
+ // Use `parseUnits(str, 18)` to match the off-chain verifier exactly
261
+ // (signature-validation/index.ts). The previous `scale(18)(parseFloat(str))`
262
+ // diverged for |x| < 1e-6 or >= 1e21 (parseFloat exponential form) → a
263
+ // signature mismatch on extreme sizes.
264
+ const quantity = isTrigger
265
+ ? fullPositionStopQuantity(params.isBuy)
266
+ : params.isBuy
267
+ ? parseUnits(params.qty!, 18)
268
+ : -parseUnits(params.qty!, 18);
269
+ const limitPrice = parseUnits(params.limitPx, 18);
270
+ const triggerPrice = params.triggerPx
271
+ ? parseUnits(params.triggerPx, 18)
272
+ : BigInt(0);
273
+
274
+ const { serializedSignature } = await signOrder(params.signer, reyaChainId, {
275
+ accountId: params.accountId,
276
+ marketId: params.marketId,
277
+ exchangeId: params.exchangeId,
278
+ orderType: toOnChainOrderType(params.orderType),
279
+ quantity,
280
+ limitPrice,
281
+ triggerPrice,
282
+ timeInForce: onChainTimeInForce,
283
+ clientOrderId: BigInt(params.clientOrderId ?? 0),
284
+ reduceOnly: isTrigger ? false : params.reduceOnly ?? false,
285
+ postOnly: isTrigger ? false : params.postOnly ?? false,
286
+ expiresAfter: BigInt(expiresAfter),
287
+ nonce,
288
+ deadline,
289
+ });
98
290
 
99
291
  const apiConfig = new Configuration({
100
292
  basePath: `${config.apiEndpoint}/v2`,
@@ -109,51 +301,72 @@ export const createSpotOrder = async (
109
301
  accountId: params.accountId,
110
302
  isBuy: params.isBuy,
111
303
  limitPx: params.limitPx,
112
- qty: params.qty,
113
- orderType: OrderType.LIMIT,
304
+ // qty is omitted on a trigger create (guaranteed undefined by the
305
+ // isTrigger guard above); sent for a LIMIT create.
306
+ ...(params.qty !== undefined && { qty: params.qty }),
307
+ orderType: params.orderType,
114
308
  timeInForce: params.timeInForce,
115
- clientOrderId: params.clientOrderId,
309
+ ...(params.triggerPx !== undefined && { triggerPx: params.triggerPx }),
310
+ ...(params.reduceOnly !== undefined && { reduceOnly: params.reduceOnly }),
311
+ ...(params.postOnly !== undefined && { postOnly: params.postOnly }),
312
+ ...(params.clientOrderId !== undefined && {
313
+ clientOrderId: params.clientOrderId,
314
+ }),
116
315
  signature: serializedSignature,
117
316
  nonce: nonce.toString(),
118
317
  signerWallet: signerAddress,
119
- expiresAfter: deadline,
318
+ deadline,
319
+ ...(params.expiresAfter !== undefined &&
320
+ params.expiresAfter !== 0 && { expiresAfter }),
120
321
  },
121
322
  });
122
323
 
123
324
  return response;
124
325
  };
125
326
 
126
- export type CancelSpotOrderParams = {
127
- signer: Signer | JsonRpcSigner;
128
- accountId: number;
129
- marketId: number;
130
- symbol: string;
131
- orderId?: string;
132
- clientOrderId?: number;
133
- expiresAfterSeconds?: number;
134
- };
135
-
136
- export const cancelSpotOrder = async (
137
- params: CancelSpotOrderParams,
327
+ export const cancelMEOrder = async (
328
+ params: CancelMEOrderParams,
138
329
  ): Promise<CancelOrderResponse> => {
139
- if (!params.orderId && params.clientOrderId === undefined) {
140
- throw new Error('Either orderId or clientOrderId must be provided');
141
- }
330
+ assertNonZeroClientOrderId(params.clientOrderId);
142
331
 
143
332
  const reyaChainId = getReyaNetwork();
144
333
  const config = getSdkConfig();
145
334
 
146
- const nowMs = Date.now();
147
- const expiresAfter =
148
- Math.floor(nowMs / 1000) +
149
- (params.expiresAfterSeconds ?? DEFAULT_CANCEL_DEADLINE_SECONDS);
150
- const nonce = BigInt(nowMs);
335
+ // Validation: at least one of orderId or clientOrderId must be provided.
336
+ // Without this check the SDK would sign for `cancel(orderId=0, clOrdId=0)`
337
+ // and the API would either reject with a cryptic signature-mismatch error
338
+ // or — worse — accept and look up a sentinel "order zero" that doesn't
339
+ // exist. Better to surface the misuse at the SDK boundary. (PRO-81 C.2.26)
340
+ // Treat '0' as "not provided" for both ids — '0' is the engine's
341
+ // not-provided sentinel, so a caller passing clientOrderId:'0' (or
342
+ // orderId:'0') must NOT slip past this guard and sign the sentinel
343
+ // cancel(0,0) it is meant to block. (PRO-81 C.2.26)
344
+ const orderIdProvided = isProvidedNonZeroId(params.orderId);
345
+ const clientOrderIdProvided = isProvidedNonZeroId(params.clientOrderId);
346
+ if (!orderIdProvided && !clientOrderIdProvided) {
347
+ throw new Error(
348
+ 'cancelMEOrder requires either `orderId` or `clientOrderId`',
349
+ );
350
+ }
151
351
 
152
- const useOrderId = !!params.orderId;
153
- const signedOrderId = useOrderId ? BigInt(params.orderId!) : BigInt(0);
154
- const signedClOrdId = useOrderId ? BigInt(0) : BigInt(params.clientOrderId!);
352
+ // When orderId is set, it is the canonical handle — drop clientOrderId
353
+ // from both the signature and the request body. Forwarding both creates
354
+ // ambiguity in server logging and would let a malicious / buggy caller
355
+ // sign one order id while requesting cancellation by a different
356
+ // (client-supplied) id. (PRO-81 C.2.26)
357
+ const signedOrderId = orderIdProvided ? BigInt(params.orderId!) : BigInt(0);
358
+ const signedClOrdId =
359
+ !orderIdProvided && clientOrderIdProvided
360
+ ? BigInt(params.clientOrderId!)
361
+ : BigInt(0);
155
362
 
156
- const { serializedSignature } = await signOrderCancel(
363
+ const signerAddress = await params.signer.getAddress();
364
+ const defaultDeadline =
365
+ Math.floor(Date.now() / 1000) + DEFAULT_DEADLINE_SECONDS;
366
+ const deadline = params.deadline ?? defaultDeadline;
367
+ const nonce = params.nonce ?? nextNonce(signerAddress);
368
+
369
+ const signature = await signMEOrderCancel(
157
370
  params.signer,
158
371
  reyaChainId,
159
372
  params.accountId,
@@ -161,7 +374,7 @@ export const cancelSpotOrder = async (
161
374
  signedOrderId,
162
375
  signedClOrdId,
163
376
  nonce,
164
- expiresAfter,
377
+ deadline,
165
378
  );
166
379
 
167
380
  const apiConfig = new Configuration({
@@ -172,54 +385,54 @@ export const cancelSpotOrder = async (
172
385
 
173
386
  const response = await orderEntryApi.cancelOrder({
174
387
  cancelOrderRequest: {
175
- symbol: params.symbol,
176
388
  accountId: params.accountId,
177
- orderId: useOrderId ? params.orderId : undefined,
178
- clientOrderId: useOrderId ? undefined : params.clientOrderId,
179
- signature: serializedSignature,
389
+ symbol: params.symbol,
390
+ orderId: orderIdProvided ? params.orderId!.trim() : undefined,
391
+ // See validation block above: clientOrderId is suppressed in the body
392
+ // whenever orderId is the canonical handle.
393
+ clientOrderId: orderIdProvided ? undefined : params.clientOrderId?.trim(),
394
+ signature: signature,
180
395
  nonce: nonce.toString(),
181
- expiresAfter,
396
+ deadline,
182
397
  },
183
398
  });
184
399
 
185
400
  return response;
186
401
  };
187
402
 
188
- export type MassCancelSpotOrdersParams = {
189
- signer: Signer | JsonRpcSigner;
190
- accountId: number;
191
- symbol?: string;
192
- marketId?: number;
193
- expiresAfterSeconds?: number;
194
- };
195
-
196
- export const massCancelSpotOrders = async (
197
- params: MassCancelSpotOrdersParams,
403
+ export const massCancelMEOrders = async (
404
+ params: MassCancelMEOrdersParams,
198
405
  ): Promise<MassCancelResponse> => {
406
+ const reyaChainId = getReyaNetwork();
407
+ const config = getSdkConfig();
408
+
409
+ // Validation: symbol and marketId must be provided together or not at all.
410
+ // The API resolves a symbol → marketId server-side; the signature locks
411
+ // marketId at sign time. If only one is provided we'd ship a body whose
412
+ // server-derived marketId doesn't match the signed marketId (=> opaque
413
+ // signature-validation failure), or — worse — a body that silently
414
+ // cancels the wrong market. (PRO-81 C.2.26)
199
415
  const hasSymbol = params.symbol !== undefined;
200
416
  const hasMarketId = params.marketId !== undefined;
201
417
  if (hasSymbol !== hasMarketId) {
202
418
  throw new Error(
203
- 'symbol and marketId must be provided together (or both omitted to cancel across all markets)',
419
+ 'massCancelMEOrders requires both `symbol` and `marketId` together, or neither (to mass-cancel across all markets)',
204
420
  );
205
421
  }
206
422
 
207
- const reyaChainId = getReyaNetwork();
208
- const config = getSdkConfig();
209
-
210
- const nowMs = Date.now();
211
- const expiresAfter =
212
- Math.floor(nowMs / 1000) +
213
- (params.expiresAfterSeconds ?? DEFAULT_CANCEL_DEADLINE_SECONDS);
214
- const nonce = BigInt(nowMs);
423
+ const signerAddress = await params.signer.getAddress();
424
+ const defaultDeadline =
425
+ Math.floor(Date.now() / 1000) + DEFAULT_DEADLINE_SECONDS;
426
+ const deadline = params.deadline ?? defaultDeadline;
427
+ const nonce = params.nonce ?? nextNonce(signerAddress);
215
428
 
216
- const { serializedSignature } = await signMassCancel(
429
+ const signature = await signMEMassCancel(
217
430
  params.signer,
218
431
  reyaChainId,
219
432
  params.accountId,
220
433
  params.marketId ?? 0,
221
434
  nonce,
222
- expiresAfter,
435
+ deadline,
223
436
  );
224
437
 
225
438
  const apiConfig = new Configuration({
@@ -230,11 +443,407 @@ export const massCancelSpotOrders = async (
230
443
 
231
444
  const response = await orderEntryApi.cancelAll({
232
445
  massCancelRequest: {
446
+ accountId: params.accountId,
447
+ symbol: params.symbol,
448
+ signature: signature,
449
+ nonce: nonce.toString(),
450
+ deadline,
451
+ },
452
+ });
453
+
454
+ return response;
455
+ };
456
+
457
+ // Convenience wrapper: LIMIT order with IOC timeInForce. UI teams may call
458
+ // createOrder directly with `timeInForce: TimeInForce.IOC` instead.
459
+ export const createIOCOrder = async (
460
+ params: Omit<CreateOrderParams, 'timeInForce'>,
461
+ ): Promise<CreateOrderResponse> => {
462
+ return createOrder({
463
+ ...params,
464
+ timeInForce: TimeInForce.IOC,
465
+ });
466
+ };
467
+
468
+ export type ClosePositionParams = {
469
+ signer: Signer | JsonRpcSigner;
470
+ accountId: number;
471
+ exchangeId: number;
472
+ marketId: number;
473
+ /** Closing direction — opposite of the current position side. */
474
+ isBuy: boolean;
475
+ /** Slippage gate. */
476
+ limitPx: string;
477
+ /** Position size to close. */
478
+ qty: string;
479
+ symbol: string;
480
+ clientOrderId?: string;
481
+ /** Unix seconds; EIP-712 signature deadline. Defaults to now + 30s. */
482
+ deadline?: number;
483
+ /** Optional explicit nonce override; otherwise generated per-signer. */
484
+ nonce?: bigint;
485
+ };
486
+
487
+ // Convenience wrapper: close a perp position by submitting a reduce-only LIMIT
488
+ // IOC. The ME will bust fills that overflow the position at the clearing layer.
489
+ export const closePosition = async (
490
+ params: ClosePositionParams,
491
+ ): Promise<CreateOrderResponse> => {
492
+ return createOrder({
493
+ signer: params.signer,
494
+ accountId: params.accountId,
495
+ exchangeId: params.exchangeId,
496
+ marketId: params.marketId,
497
+ isBuy: params.isBuy,
498
+ limitPx: params.limitPx,
499
+ qty: params.qty,
500
+ symbol: params.symbol,
501
+ orderType: OrderType.LIMIT,
502
+ timeInForce: TimeInForce.IOC,
503
+ reduceOnly: true,
504
+ clientOrderId: params.clientOrderId,
505
+ deadline: params.deadline,
506
+ nonce: params.nonce,
507
+ });
508
+ };
509
+
510
+ export type CancelAllAfterParams = {
511
+ signer: Signer | JsonRpcSigner;
512
+ accountId: number;
513
+ /**
514
+ * Countdown duration in milliseconds. `0` disarms; any non-zero value must
515
+ * be within [5000, 60000] and arms a fresh countdown of that duration,
516
+ * replacing any previously armed one (re-arming = the refresh/heartbeat).
517
+ * Recommended pattern: arm with 30000 and refresh every ~10-15s.
518
+ */
519
+ timeoutMs: number;
520
+ deadline?: number;
521
+ /**
522
+ * Optional explicit nonce; otherwise a per-signer monotonic µs value. Must be
523
+ * strictly greater than the signer's last accepted nonce in the matching
524
+ * engine. See `cancelAllAfter` below for retry semantics; the default
525
+ * `nextNonce` advances automatically.
526
+ */
527
+ nonce?: bigint;
528
+ };
529
+
530
+ const CANCEL_ALL_AFTER_TIMEOUT_MIN_MS = 5000;
531
+ const CANCEL_ALL_AFTER_TIMEOUT_MAX_MS = 60000;
532
+
533
+ /**
534
+ * Arm, refresh, or disarm the account-scoped cancel-on-disconnect
535
+ * dead-man's-switch. The countdown lives in the matching engine; when it
536
+ * elapses, all of the account's open orders (all markets) are mass-cancelled.
537
+ *
538
+ * The matching engine owns nonce replay protection. A request rejected by
539
+ * local validation before it is forwarded does not consume its nonce. Once a
540
+ * request is sent, a transport failure may be outcome-unknown, and a request
541
+ * that reaches the matching engine may record its nonce in the WAL. Retry with
542
+ * a strictly higher nonce after an ME response or transport uncertainty.
543
+ * Reusing an explicit nonce is safe only after a definite local pre-forward
544
+ * rejection. When `nonce` is omitted, `nextNonce` advances automatically.
545
+ */
546
+ export const cancelAllAfter = async (
547
+ params: CancelAllAfterParams,
548
+ ): Promise<CancelAllAfterResponse> => {
549
+ const reyaChainId = getReyaNetwork();
550
+ const config = getSdkConfig();
551
+
552
+ // Fail fast before signing or sending a request the server will reject with
553
+ // INPUT_VALIDATION_ERROR anyway.
554
+ if (
555
+ !Number.isInteger(params.timeoutMs) ||
556
+ params.timeoutMs < 0 ||
557
+ (params.timeoutMs !== 0 &&
558
+ (params.timeoutMs < CANCEL_ALL_AFTER_TIMEOUT_MIN_MS ||
559
+ params.timeoutMs > CANCEL_ALL_AFTER_TIMEOUT_MAX_MS))
560
+ ) {
561
+ throw new Error(
562
+ `cancelAllAfter timeoutMs must be 0 (disarm) or between ${CANCEL_ALL_AFTER_TIMEOUT_MIN_MS} and ${CANCEL_ALL_AFTER_TIMEOUT_MAX_MS} milliseconds`,
563
+ );
564
+ }
565
+
566
+ const signerAddress = await params.signer.getAddress();
567
+ const defaultDeadline =
568
+ Math.floor(Date.now() / 1000) + DEFAULT_DEADLINE_SECONDS;
569
+ const deadline = params.deadline ?? defaultDeadline;
570
+ const nonce = params.nonce ?? nextNonce(signerAddress);
571
+
572
+ const signature = await signMECancelAllAfter(
573
+ params.signer,
574
+ reyaChainId,
575
+ params.accountId,
576
+ params.timeoutMs,
577
+ nonce,
578
+ deadline,
579
+ );
580
+
581
+ const apiConfig = new Configuration({
582
+ basePath: `${config.apiEndpoint}/v2`,
583
+ });
584
+
585
+ const orderEntryApi = new OrderEntryApi(apiConfig);
586
+
587
+ const response = await orderEntryApi.cancelAllAfter({
588
+ cancelAllAfterRequest: {
589
+ accountId: params.accountId,
590
+ timeoutMs: params.timeoutMs,
591
+ signature: signature,
592
+ nonce: nonce.toString(),
593
+ signerWallet: signerAddress,
594
+ deadline,
595
+ },
596
+ });
597
+
598
+ return response;
599
+ };
600
+
601
+ type ModifyMEOrderCommonParams = {
602
+ signer: Signer | JsonRpcSigner;
603
+ accountId: number;
604
+ exchangeId: number;
605
+ /** Unified market id: core_id for perp, core_id + 1e10 for spot. */
606
+ marketId: number;
607
+ symbol: string;
608
+ /** Targeting: provide `orderId`, or a non-zero `clientOrderId`; both may be provided. */
609
+ orderId?: string;
610
+ clientOrderId?: string;
611
+ /**
612
+ * Full post-modify order state. The SDK does not fetch the resting order —
613
+ * the caller supplies every field, and the EIP-712 signature commits to
614
+ * all of them. Modifiable fields are limitPx, qty, postOnly, expiresAfter,
615
+ * and triggerPx. There is no omitted-means-inherited shorthand, so unchanged
616
+ * values must be restated explicitly; LIMIT orders omit triggerPx and sign
617
+ * triggerPrice = 0. Non-modifiable fields (isBuy, orderType, timeInForce,
618
+ * reduceOnly) must match the resting order's values.
619
+ */
620
+ isBuy: boolean;
621
+ limitPx: string;
622
+ /**
623
+ * TOTAL order quantity (not remaining). Type-conditional, mirroring the
624
+ * create-trigger path:
625
+ * - LIMIT: REQUIRED — must exceed the order's cumQty; signed as the total
626
+ * post-modify quantity.
627
+ * - STOP_LOSS / TAKE_PROFIT: OMIT — the signed quantity restates the
628
+ * ±int256.max full-position sentinel (sign from `isBuy`; protect the whole
629
+ * position) and qty is dropped from the wire payload. Passing a qty on a
630
+ * trigger modify throws (the backend rejects it).
631
+ */
632
+ qty?: string;
633
+ /** Immutable; restate the resting order's value. */
634
+ timeInForce: TimeInForce;
635
+ triggerPx?: string;
636
+ /** Unix seconds. Post-modify on-chain order lifetime; omit for no-expiry orders. */
637
+ expiresAfter?: number;
638
+ /**
639
+ * Resting order's clientOrderId, signed into OrderDetails.clientOrderId.
640
+ * If orderId is absent this is also the lookup target and must be non-zero.
641
+ * If orderId is present, orderId targets and clientOrderId only restates the
642
+ * immutable; omit it when the resting order has no client id.
643
+ */
644
+ /** Unix seconds; EIP-712 signature deadline. Defaults to now + 30s. */
645
+ deadline?: number;
646
+ /**
647
+ * Seconds of settlement headroom a GTT `expiresAfter` must outlast. Defaults
648
+ * to the production value; set it only for a deployment running a different
649
+ * one.
650
+ */
651
+ settlementHeadroomSeconds?: number;
652
+ /** Optional explicit nonce; otherwise per-signer monotonic µs. */
653
+ nonce?: bigint;
654
+ };
655
+
656
+ // Keep the helper's discriminant/flag rules tied to the generated wire types.
657
+ // LIMIT reduceOnly retains the helper's false default; raw requests require it.
658
+ type LimitModifyMEOrderFields = Pick<
659
+ Extract<ModifyOrderRequest, { orderType: 'LIMIT' }>,
660
+ 'orderType' | 'postOnly'
661
+ > & { reduceOnly?: boolean };
662
+ type TriggerModifyMEOrderFields = Pick<
663
+ Extract<ModifyOrderRequest, { orderType: 'STOP_LOSS' | 'TAKE_PROFIT' }>,
664
+ 'orderType' | 'postOnly' | 'reduceOnly'
665
+ >;
666
+
667
+ export type ModifyMEOrderParams = ModifyMEOrderCommonParams &
668
+ (LimitModifyMEOrderFields | TriggerModifyMEOrderFields);
669
+
670
+ /**
671
+ * Modify a resting order in place. The order keeps its orderId and
672
+ * clientOrderId; queue priority is preserved only for a qty decrease at an
673
+ * unchanged limitPx. The params carry the COMPLETE post-modify state; a request
674
+ * restating the resting order's exact current state is rejected with
675
+ * EMPTY_MODIFY_ERROR.
676
+ */
677
+ export const modifyMEOrder = async (
678
+ params: ModifyMEOrderParams,
679
+ ): Promise<ModifyOrderResponse> => {
680
+ assertNonZeroClientOrderId(params.clientOrderId);
681
+
682
+ const reyaChainId = getReyaNetwork();
683
+ const config = getSdkConfig();
684
+
685
+ // Validate targeting before signing so misuse doesn't burn a nonce. `orderId`
686
+ // wins when both are present; `clientOrderId` still rides along as the
687
+ // restated signed immutable.
688
+ const hasOrderId = isProvidedNonZeroId(params.orderId);
689
+ const hasClientOrderIdTarget = isProvidedNonZeroId(params.clientOrderId);
690
+ if (!hasOrderId && !hasClientOrderIdTarget) {
691
+ throw new Error(
692
+ 'modifyMEOrder requires `orderId` or a non-zero `clientOrderId`',
693
+ );
694
+ }
695
+
696
+ const isTrigger =
697
+ params.orderType === OrderType.STOP_LOSS ||
698
+ params.orderType === OrderType.TAKE_PROFIT;
699
+
700
+ if (
701
+ isTrigger &&
702
+ (params.reduceOnly !== undefined || params.postOnly !== undefined)
703
+ ) {
704
+ throw new Error('reduceOnly and postOnly must be omitted for TP/SL orders');
705
+ }
706
+
707
+ if (!isTrigger && typeof params.postOnly !== 'boolean') {
708
+ throw new Error('postOnly is required for LIMIT modifications');
709
+ }
710
+
711
+ // Convert once while validation is still side-effect free. Runtime callers
712
+ // can bypass the TypeScript enum; reject those values before resolving the
713
+ // signer or advancing its nonce.
714
+ const onChainTimeInForce = toOnChainTimeInForce(params.timeInForce);
715
+
716
+ // triggerPx is required for trigger orders — without it we would silently
717
+ // sign triggerPrice=0. Fail at the SDK boundary (mirrors the cancel/mass-
718
+ // cancel SDK-boundary validation) instead of signing a 0-trigger order.
719
+ if (isTrigger && params.triggerPx === undefined) {
720
+ throw new Error(`triggerPx is required for ${params.orderType} orders`);
721
+ }
722
+
723
+ // qty is type-conditional (mirrors the create-trigger path):
724
+ // - STOP_LOSS / TAKE_PROFIT: qty MUST be omitted; the signed quantity is
725
+ // the ±int256.max full-position sentinel (sign from `isBuy`) and qty is
726
+ // dropped from the wire below. The backend rejects a trigger modify that
727
+ // carries qty.
728
+ // - LIMIT: qty is REQUIRED and signed as the total post-modify quantity.
729
+ // Validated up front (before a nonce is minted), like the triggerPx guard.
730
+ if (isTrigger && params.qty !== undefined) {
731
+ throw new Error(
732
+ `qty must be omitted for ${params.orderType} orders (the signed quantity is the full-position sentinel)`,
733
+ );
734
+ }
735
+ if (!isTrigger && params.qty === undefined) {
736
+ throw new Error('qty is required when modifying a LIMIT order');
737
+ }
738
+
739
+ // Only GTT carries a lifetime, for triggers as for book orders. The modify
740
+ // restates that pair immutably, so an inconsistent one can only be refused by
741
+ // the backend — catch it here, before a nonce is minted and burnt.
742
+ const hasExpiry =
743
+ params.expiresAfter !== undefined && params.expiresAfter !== 0;
744
+ if (params.timeInForce === TimeInForce.GTT && !hasExpiry) {
745
+ throw new Error('expiresAfter is required for GTT orders');
746
+ }
747
+ if (params.timeInForce !== TimeInForce.GTT && hasExpiry) {
748
+ throw new Error(
749
+ 'expiresAfter must be omitted for IOC / GTC orders — only GTT carries a lifetime',
750
+ );
751
+ }
752
+
753
+ if (params.reduceOnly && params.marketId >= SPOT_MARKET_ID_OFFSET) {
754
+ throw new Error('reduceOnly is not supported for spot markets');
755
+ }
756
+
757
+ // Same venue admission rule as the create path: a restated GTT lifetime must
758
+ // still outlast the settlement headroom.
759
+ if (hasExpiry) {
760
+ assertSettlementHeadroom(
761
+ params.expiresAfter!,
762
+ params.settlementHeadroomSeconds,
763
+ );
764
+ }
765
+
766
+ const signerAddress = await params.signer.getAddress();
767
+ const defaultDeadline =
768
+ Math.floor(Date.now() / 1000) + DEFAULT_DEADLINE_SECONDS;
769
+ const deadline = params.deadline ?? defaultDeadline;
770
+ const nonce = params.nonce ?? nextNonce(signerAddress);
771
+
772
+ // Signed quantity: a trigger restates the ±int256.max full-position sentinel
773
+ // (sign from `isBuy`; reya-network #738); a LIMIT signs ±qty. Sign over the
774
+ // exact integers the off-chain verifier reconstructs
775
+ // with `parseUnits(str, 18)` (signature-validation/index.ts). The previous
776
+ // `scale(18)(parseFloat(str))` diverged for |x| < 1e-6 or >= 1e21 (parseFloat
777
+ // renders those in exponential form, which `scale` mishandles) → a signature
778
+ // mismatch on extreme sizes. `parseUnits` is exact.
779
+ const quantity = isTrigger
780
+ ? fullPositionStopQuantity(params.isBuy)
781
+ : params.isBuy
782
+ ? parseUnits(params.qty!, 18)
783
+ : -parseUnits(params.qty!, 18);
784
+ const limitPrice = parseUnits(params.limitPx, 18);
785
+ const triggerPrice = params.triggerPx
786
+ ? parseUnits(params.triggerPx, 18)
787
+ : BigInt(0);
788
+
789
+ const { serializedSignature } = await signOrder(params.signer, reyaChainId, {
790
+ accountId: params.accountId,
791
+ marketId: params.marketId,
792
+ exchangeId: params.exchangeId,
793
+ orderType: toOnChainOrderType(params.orderType),
794
+ quantity,
795
+ limitPrice,
796
+ triggerPrice,
797
+ timeInForce: onChainTimeInForce,
798
+ clientOrderId: BigInt(params.clientOrderId ?? '0'),
799
+ reduceOnly: isTrigger ? false : params.reduceOnly ?? false,
800
+ postOnly: isTrigger ? false : params.postOnly!,
801
+ expiresAfter: BigInt(params.expiresAfter ?? 0),
802
+ nonce,
803
+ deadline,
804
+ });
805
+
806
+ const apiConfig = new Configuration({
807
+ basePath: `${config.apiEndpoint}/v2`,
808
+ });
809
+
810
+ const orderEntryApi = new OrderEntryApi(apiConfig);
811
+
812
+ // Full restate: the body must carry the COMPLETE signed OrderDetails so it
813
+ // reproduces the signed digest exactly. The off-chain verifier
814
+ // `isModifyOrderSignatureValid` reconstructs from these fields with
815
+ // `timeInForce` required and never defaulted, so an omitted one cannot
816
+ // verify at all. The immutables are restated at the resting order's values;
817
+ // the ME rejects a mismatch with MODIFY_IMMUTABLE_MISMATCH.
818
+ const response = await orderEntryApi.modifyOrder({
819
+ modifyOrderRequest: {
820
+ ...(hasOrderId ? { orderId: params.orderId!.trim() } : {}),
821
+ ...(hasClientOrderIdTarget
822
+ ? { clientOrderId: params.clientOrderId!.trim() }
823
+ : {}),
233
824
  symbol: params.symbol,
234
825
  accountId: params.accountId,
826
+ exchangeId: params.exchangeId,
827
+ isBuy: params.isBuy,
828
+ ...(params.orderType === OrderType.LIMIT
829
+ ? {
830
+ orderType: params.orderType,
831
+ reduceOnly: params.reduceOnly ?? false,
832
+ postOnly: params.postOnly,
833
+ }
834
+ : { orderType: params.orderType }),
835
+ timeInForce: params.timeInForce,
836
+ ...(params.triggerPx !== undefined && { triggerPx: params.triggerPx }),
837
+ limitPx: params.limitPx,
838
+ // qty is omitted on a trigger modify (guaranteed undefined by the
839
+ // isTrigger guard above); sent for a LIMIT modify.
840
+ ...(params.qty !== undefined && { qty: params.qty }),
841
+ ...(params.expiresAfter !== undefined &&
842
+ params.expiresAfter !== 0 && { expiresAfter: params.expiresAfter }),
235
843
  signature: serializedSignature,
236
844
  nonce: nonce.toString(),
237
- expiresAfter,
845
+ signerWallet: signerAddress,
846
+ deadline,
238
847
  },
239
848
  });
240
849