@0xmonaco/core 1.0.65 → 1.0.67

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.
@@ -22,6 +22,7 @@
22
22
  */
23
23
  import type { RequestSigning, SessionCredentials } from "@0xmonaco/types";
24
24
  import { type SessionKeypair } from "../crypto/session";
25
+ import { ServerClock } from "../utils/server-clock";
25
26
  export interface RetryOptions {
26
27
  maxRetries?: number;
27
28
  baseDelayMs?: number;
@@ -33,9 +34,13 @@ export declare abstract class BaseAPI {
33
34
  /** Application secret key (`sk_...`) sent in the `x-server-key` header for backend-authenticated requests. */
34
35
  protected serverKey?: string;
35
36
  private requestSigning;
37
+ /** Server time learned from responses; signing timestamps are read from it. */
38
+ protected serverClock: ServerClock;
36
39
  /** Opt in only after the target server supports RFC 9421. No automatic downgrade. */
37
40
  setRequestSigning(protocol: RequestSigning): void;
38
41
  protected retryOptions: Required<RetryOptions>;
42
+ /** Share one server clock across API instances so any response calibrates all of them. */
43
+ setServerClock(clock: ServerClock): void;
39
44
  /**
40
45
  * Creates a new BaseAPI instance.
41
46
  *
package/dist/api/base.js CHANGED
@@ -25,6 +25,8 @@ import { StatusCodes } from "http-status-codes";
25
25
  import { buildHttpSignatureHeaders } from "../crypto/http-signatures";
26
26
  import { composeSigningString, keypairFromHex, sha256Hex, signMessage } from "../crypto/session";
27
27
  import { APIError } from "../errors";
28
+ import { parseHttpDate } from "../utils/http-date";
29
+ import { CLOCK_CORRECTION_THRESHOLD_MS, ServerClock } from "../utils/server-clock";
28
30
  /**
29
31
  * Product token sent as the `User-Agent` on every request. Monaco's edge
30
32
  * rejects requests with an empty or absent `User-Agent`, so we set one
@@ -35,112 +37,6 @@ import { APIError } from "../errors";
35
37
  * only takes effect in server runtimes (Node/undici, Bun, Deno).
36
38
  */
37
39
  const SDK_USER_AGENT = "@0xmonaco/core";
38
- const MONTHS = {
39
- Jan: 0,
40
- Feb: 1,
41
- Mar: 2,
42
- Apr: 3,
43
- May: 4,
44
- Jun: 5,
45
- Jul: 6,
46
- Aug: 7,
47
- Sep: 8,
48
- Oct: 9,
49
- Nov: 10,
50
- Dec: 11,
51
- };
52
- // The three RFC 7231 §7.1.1.1 `HTTP-date` grammars a recipient must accept.
53
- // Each is anchored so trailing junk is rejected, and `Date.parse` is avoided
54
- // entirely (it is permissive — it reads asctime in local time and rolls
55
- // impossible dates like 31 Feb forward instead of rejecting them).
56
- const MONTH = "Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec";
57
- const IMF_FIXDATE = new RegExp(`^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\\d{2}) (${MONTH}) (\\d{4}) (\\d{2}):(\\d{2}):(\\d{2}) GMT$`);
58
- const RFC850_DATE = new RegExp(`^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\\d{2})-(${MONTH})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2}) GMT$`);
59
- const ASCTIME_DATE = new RegExp(`^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (${MONTH}) ([ \\d]\\d) (\\d{2}):(\\d{2}):(\\d{2}) (\\d{4})$`);
60
- /**
61
- * Expand an RFC 850 two-digit year per RFC 7231 §7.1.1.1: a full timestamp that
62
- * appears more than 50 years in the future is interpreted as the most recent
63
- * past year with the same last two digits. The comparison is against the exact
64
- * receipt instant plus 50 years (not just calendar years), so the boundary can
65
- * fall mid-year — e.g. received 2026-06-01, `31-Dec-76` is 50y7m ahead and maps
66
- * to 1976, while `01-Jan-76` is under 50 years ahead and stays 2076.
67
- */
68
- function expandTwoDigitYear(twoDigitYear, month, day, hour, minute, second) {
69
- const now = new Date(Date.now());
70
- const candidateYear = Math.floor(now.getUTCFullYear() / 100) * 100 + twoDigitYear;
71
- const candidate = Date.UTC(candidateYear, month, day, hour, minute, second);
72
- const fiftyYearsAhead = Date.UTC(now.getUTCFullYear() + 50, now.getUTCMonth(), now.getUTCDate(), now.getUTCHours(), now.getUTCMinutes(), now.getUTCSeconds());
73
- return candidate > fiftyYearsAhead ? candidateYear - 100 : candidateYear;
74
- }
75
- /**
76
- * Parse an RFC 7231 `HTTP-date` — in any of the three formats a recipient must
77
- * accept (IMF-fixdate, the obsolete RFC 850 form, and asctime) — into a UTC
78
- * epoch in milliseconds, or `undefined` if the value is not a well-formed, real
79
- * calendar date. All three denote UTC, so timestamps are built with `Date.UTC`
80
- * and the fields are round-tripped to reject impossible dates such as
81
- * `31 Feb` or `25:00:00`. RFC 850's 2-digit year follows the RFC 7231 rule
82
- * (see {@link expandTwoDigitYear}). The redundant day-name is intentionally not
83
- * validated against the date — RFC 7231 encourages robust parsing, and the
84
- * date/time fields already determine the instant unambiguously, so a mismatched
85
- * weekday still yields the intended delay rather than a dropped header and an
86
- * arbitrary fallback.
87
- */
88
- function parseHttpDate(value) {
89
- let day;
90
- let month;
91
- let year;
92
- let hour;
93
- let minute;
94
- let second;
95
- const imf = IMF_FIXDATE.exec(value);
96
- const rfc850 = RFC850_DATE.exec(value);
97
- const asctime = ASCTIME_DATE.exec(value);
98
- if (imf) {
99
- day = Number(imf[1]);
100
- month = MONTHS[imf[2]];
101
- year = Number(imf[3]);
102
- hour = Number(imf[4]);
103
- minute = Number(imf[5]);
104
- second = Number(imf[6]);
105
- }
106
- else if (rfc850) {
107
- day = Number(rfc850[1]);
108
- month = MONTHS[rfc850[2]];
109
- hour = Number(rfc850[4]);
110
- minute = Number(rfc850[5]);
111
- second = Number(rfc850[6]);
112
- if (month === undefined)
113
- return undefined;
114
- year = expandTwoDigitYear(Number(rfc850[3]), month, day, hour, minute, second);
115
- }
116
- else if (asctime) {
117
- month = MONTHS[asctime[1]];
118
- day = Number((asctime[2] ?? "").trim());
119
- hour = Number(asctime[3]);
120
- minute = Number(asctime[4]);
121
- second = Number(asctime[5]);
122
- year = Number(asctime[6]);
123
- }
124
- else {
125
- return undefined;
126
- }
127
- if (month === undefined)
128
- return undefined;
129
- const timestamp = Date.UTC(year, month, day, hour, minute, second);
130
- const utc = new Date(timestamp);
131
- // Reject impossible calendar/clock values that Date.UTC silently rolls over
132
- // (e.g. 31 Feb, 25:00:00). A `:60` leap second is likewise dropped rather
133
- // than special-cased — leap seconds never appear in a Retry-After header.
134
- if (utc.getUTCFullYear() !== year ||
135
- utc.getUTCMonth() !== month ||
136
- utc.getUTCDate() !== day ||
137
- utc.getUTCHours() !== hour ||
138
- utc.getUTCMinutes() !== minute ||
139
- utc.getUTCSeconds() !== second) {
140
- return undefined;
141
- }
142
- return timestamp;
143
- }
144
40
  export class BaseAPI {
145
41
  apiUrl;
146
42
  /** Active session keypair (raw bytes) used to sign authenticated requests. */
@@ -148,6 +44,8 @@ export class BaseAPI {
148
44
  /** Application secret key (`sk_...`) sent in the `x-server-key` header for backend-authenticated requests. */
149
45
  serverKey;
150
46
  requestSigning = "legacy";
47
+ /** Server time learned from responses; signing timestamps are read from it. */
48
+ serverClock = new ServerClock();
151
49
  /** Opt in only after the target server supports RFC 9421. No automatic downgrade. */
152
50
  setRequestSigning(protocol) {
153
51
  if (protocol !== "legacy" && protocol !== "rfc9421")
@@ -155,6 +53,10 @@ export class BaseAPI {
155
53
  this.requestSigning = protocol;
156
54
  }
157
55
  retryOptions;
56
+ /** Share one server clock across API instances so any response calibrates all of them. */
57
+ setServerClock(clock) {
58
+ this.serverClock = clock;
59
+ }
158
60
  /**
159
61
  * Creates a new BaseAPI instance.
160
62
  *
@@ -293,6 +195,7 @@ export class BaseAPI {
293
195
  requestBody,
294
196
  });
295
197
  }
198
+ this.serverClock.observeResponse(response.headers, Date.now());
296
199
  // Parse response body with error handling for non-JSON responses
297
200
  let responseBody;
298
201
  const contentType = response.headers?.get("content-type") || "unknown";
@@ -396,7 +299,6 @@ export class BaseAPI {
396
299
  }
397
300
  const url = `${this.apiUrl}${endpoint}`;
398
301
  const requestBody = this.parseRequestBody(options.body);
399
- const signatureHeaders = this.buildSignatureHeaders(endpoint, options);
400
302
  let extraHeaders = options.headers;
401
303
  if (this.requestSigning === "rfc9421") {
402
304
  const normalized = {};
@@ -405,15 +307,28 @@ export class BaseAPI {
405
307
  });
406
308
  extraHeaders = normalized;
407
309
  }
408
- return this.executeRequest(url, endpoint, {
310
+ const send = () => this.executeRequest(url, endpoint, {
409
311
  ...options,
410
312
  ...(this.requestSigning === "rfc9421" ? { redirect: "error" } : {}),
411
313
  headers: {
412
314
  "Content-Type": "application/json",
413
315
  ...extraHeaders,
414
- ...signatureHeaders,
316
+ ...this.buildSignatureHeaders(endpoint, options),
415
317
  },
416
318
  }, requestBody);
319
+ // A 401 whose response moved the server clock was most likely a
320
+ // timestamp-skew rejection: re-sign once with the corrected time.
321
+ const offsetBefore = this.serverClock.offsetMs;
322
+ try {
323
+ return await send();
324
+ }
325
+ catch (error) {
326
+ const clockMoved = Math.abs(this.serverClock.offsetMs - offsetBefore) >= CLOCK_CORRECTION_THRESHOLD_MS;
327
+ if (error instanceof APIError && error.statusCode === StatusCodes.UNAUTHORIZED && clockMoved) {
328
+ return send();
329
+ }
330
+ throw error;
331
+ }
417
332
  }
418
333
  /**
419
334
  * Compute the per-request ed25519 signature headers.
@@ -441,9 +356,9 @@ export class BaseAPI {
441
356
  throw new Error("Signature headers are managed by the SDK");
442
357
  }
443
358
  const url = new URL(`${this.apiUrl}${endpoint}`);
444
- return buildHttpSignatureHeaders(this.sessionKeypair, method, url.pathname + url.search, utf8ToBytes(options.body ?? ""), Math.floor(Date.now() / 1000));
359
+ return buildHttpSignatureHeaders(this.sessionKeypair, method, url.pathname + url.search, utf8ToBytes(options.body ?? ""), Math.floor(this.serverClock.now() / 1000));
445
360
  }
446
- const timestampMs = Date.now();
361
+ const timestampMs = this.serverClock.now();
447
362
  const bodyBytes = typeof options.body === "string" ? utf8ToBytes(options.body) : new Uint8Array(0);
448
363
  const bodyHash = sha256Hex(bodyBytes);
449
364
  const signingString = composeSigningString(method, endpoint, timestampMs, bodyHash);
@@ -23,6 +23,7 @@ export declare const perpRoutes: {
23
23
  state?: string;
24
24
  page?: number;
25
25
  pageSize?: number;
26
+ pageToken?: string;
26
27
  }) => string;
27
28
  readonly cancelConditional: (conditionalOrderId: string) => string;
28
29
  readonly getConditional: (conditionalOrderId: string) => string;
@@ -32,6 +33,7 @@ export declare const perpRoutes: {
32
33
  tradingPairId?: string;
33
34
  page?: number;
34
35
  pageSize?: number;
36
+ pageToken?: string;
35
37
  }) => string;
36
38
  readonly getTwap: (twapOrderId: string) => string;
37
39
  readonly getTwapTca: (twapOrderId: string) => string;
@@ -75,6 +75,16 @@ export class PositionsAPIImpl extends BaseAPI {
75
75
  expiresAt: request.stopLoss.expiresAt,
76
76
  }
77
77
  : undefined,
78
+ trailingStop: request.trailingStop
79
+ ? {
80
+ trailBps: request.trailingStop.trailBps,
81
+ activationPrice: request.trailingStop.activationPrice,
82
+ quantity: request.trailingStop.quantity,
83
+ closePosition: request.trailingStop.closePosition,
84
+ slippageToleranceBps: request.trailingStop.slippageToleranceBps,
85
+ expiresAt: request.trailingStop.expiresAt,
86
+ }
87
+ : undefined,
78
88
  oco: request.oco,
79
89
  }),
80
90
  });
@@ -21,7 +21,7 @@
21
21
  * );
22
22
  * ```
23
23
  */
24
- import type { BatchCancelOrdersResponse, BatchCreateOrderParams, BatchCreateOrdersResponse, BatchReplaceOrderParams, BatchReplaceOrdersResponse, CancelConditionalOrderResponse, CancelOrderResponse, CancelTwapOrderResponse, ConditionalOrder, CreateOrderResponse, CreateTwapOrderOptions, CreateTwapOrderResponse, GetOrderResponse, GetPaginatedOrdersParams, GetPaginatedOrdersResponse, GetTwapOrderTcaResponse, ListConditionalOrdersParams, ListConditionalOrdersResponse, ListTwapOrdersParams, ListTwapOrdersResponse, MarginMode, OrderSide, ParentTpSlLegParams, PositionSide, ReplaceOrderResponse, SelfTradePreventionMode, TimeInForce, TradingAPI, TradingMode, TwapOrder } from "@0xmonaco/types";
24
+ import type { BatchCancelOrdersResponse, BatchCreateOrderParams, BatchCreateOrdersResponse, BatchReplaceOrderParams, BatchReplaceOrdersResponse, CancelConditionalOrderResponse, CancelOrderResponse, CancelTwapOrderResponse, ConditionalOrder, CreateOrderResponse, CreateTwapOrderOptions, CreateTwapOrderResponse, GetOrderResponse, GetPaginatedOrdersParams, GetPaginatedOrdersResponse, GetTwapOrderTcaResponse, ListConditionalOrdersParams, ListConditionalOrdersResponse, ListTwapOrdersParams, ListTwapOrdersResponse, MarginMode, OrderSide, ParentTpSlLegParams, ParentTrailingStopLegParams, PositionSide, ReplaceOrderResponse, SelfTradePreventionMode, TimeInForce, TradingAPI, TradingMode, TwapOrder } from "@0xmonaco/types";
25
25
  import { BaseAPI } from "../base";
26
26
  export declare class TradingAPIImpl extends BaseAPI implements TradingAPI {
27
27
  /**
@@ -94,6 +94,8 @@ export declare class TradingAPIImpl extends BaseAPI implements TradingAPI {
94
94
  idempotencyKey?: string;
95
95
  takeProfit?: ParentTpSlLegParams;
96
96
  stopLoss?: ParentTpSlLegParams;
97
+ /** Trailing stop that arms when this order fully fills; margin entry orders only. */
98
+ trailingStop?: ParentTrailingStopLegParams;
97
99
  }): Promise<CreateOrderResponse>;
98
100
  /**
99
101
  * Places a market order for immediate execution.
@@ -152,6 +154,8 @@ export declare class TradingAPIImpl extends BaseAPI implements TradingAPI {
152
154
  selfTradePreventionMode?: SelfTradePreventionMode;
153
155
  takeProfit?: ParentTpSlLegParams;
154
156
  stopLoss?: ParentTpSlLegParams;
157
+ /** Trailing stop that arms when this order fully fills; margin entry orders only. */
158
+ trailingStop?: ParentTrailingStopLegParams;
155
159
  clientOrderId?: string;
156
160
  /** Generated once per call when omitted. Supply the same key to retry a logical order within 24 hours. */
157
161
  idempotencyKey?: string;
@@ -187,13 +191,20 @@ export declare class TradingAPIImpl extends BaseAPI implements TradingAPI {
187
191
  /**
188
192
  * Lists conditional orders for the authenticated user.
189
193
  *
190
- * The response `total` is bounded by the listing's pagination reach: exact up
191
- * to `pageSize` x 10,000 rows and saturating there, so a caller with more
192
- * matching conditional orders than page-number mode can page through reads the
193
- * cap, not its lifetime total; `page` is capped at 10,000 and a larger page is
194
- * rejected.
194
+ * Cursor (pageToken) pagination is the default: when neither `page` nor
195
+ * `pageToken` is passed, the SDK drives a cursor walk (it sends
196
+ * `pageToken=""`), which seeks on `(createdAt, id)` newest-first and resumes
197
+ * via each response's `nextPageToken` until that token comes back empty. A
198
+ * cursor page size may go up to 1000.
195
199
  *
196
- * @param params - Optional pagination and margin-account / trading-pair / state filters
200
+ * Passing the deprecated `page` (without `pageToken`) selects legacy
201
+ * page-number mode instead, which keeps the 100 page-size cap and populates a
202
+ * bounded `total` (exact up to `pageSize` x 10,000 rows, saturating there);
203
+ * `page` is capped at 10,000. Cursor mode returns `total`/`page` as 0 and runs
204
+ * no COUNT.
205
+ *
206
+ * @param params - Optional filters and pagination; `pageToken` drives cursor
207
+ * mode, `page` drives the deprecated legacy mode
197
208
  * @returns Promise resolving to ListConditionalOrdersResponse with orders and pagination info
198
209
  * @throws {APIError} When API communication fails
199
210
  */
@@ -518,20 +529,27 @@ export declare class TradingAPIImpl extends BaseAPI implements TradingAPI {
518
529
  /**
519
530
  * Lists TWAP parent orders for the authenticated user.
520
531
  *
521
- * The response `total` is bounded by the listing's pagination reach: exact up
522
- * to `pageSize` x 10,000 rows and saturating there, so a caller with more
523
- * matching TWAP orders than page-number mode can page through reads the cap,
524
- * not its lifetime total; `page` is capped at 10,000 and a larger page is
525
- * rejected.
532
+ * Cursor (pageToken) pagination is the default: when neither `page` nor
533
+ * `pageToken` is passed, the SDK drives a cursor walk (it sends
534
+ * `pageToken=""`), which seeks on `(createdAt, id)` newest-first and resumes
535
+ * via each response's `nextPageToken` until that token comes back empty. A
536
+ * cursor page size may go up to 1000.
537
+ *
538
+ * Passing the deprecated `page` (without `pageToken`) selects legacy
539
+ * page-number mode instead, which keeps the 100 page-size cap and populates a
540
+ * bounded `total` (exact up to `pageSize` x 10,000 rows, saturating there);
541
+ * `page` is capped at 10,000. Cursor mode returns `total`/`page` as 0 and runs
542
+ * no COUNT.
526
543
  *
527
- * @param params - Optional pagination and state / trading-pair filters
544
+ * @param params - Optional filters and pagination; `pageToken` drives cursor
545
+ * mode, `page` drives the deprecated legacy mode
528
546
  * @returns Promise resolving to ListTwapOrdersResponse with orders and pagination info
529
547
  * @throws {APIError} When API communication fails
530
548
  *
531
549
  * @example
532
550
  * ```typescript
533
551
  * const active = await tradingAPI.listTwapOrders({ state: "ACTIVE", pageSize: 50 });
534
- * console.log(`Found ${active.total} active TWAP orders`);
552
+ * console.log(`Found ${active.orders.length} active TWAP orders`);
535
553
  * ```
536
554
  */
537
555
  listTwapOrders(params?: ListTwapOrdersParams): Promise<ListTwapOrdersResponse>;
@@ -41,6 +41,19 @@ function parentTpSlLegToRequest(leg) {
41
41
  closePosition: leg.closePosition,
42
42
  };
43
43
  }
44
+ function parentTrailingStopLegToRequest(leg) {
45
+ if (!leg)
46
+ return undefined;
47
+ return {
48
+ trailBps: leg.trailBps,
49
+ activationPrice: leg.activationPrice,
50
+ // Same sizing contract as the TP/SL legs above — see the note there.
51
+ quantity: leg.quantity,
52
+ closePosition: leg.closePosition,
53
+ slippageToleranceBps: leg.slippageToleranceBps,
54
+ expiresAt: leg.expiresAt,
55
+ };
56
+ }
44
57
  export class TradingAPIImpl extends BaseAPI {
45
58
  /**
46
59
  * Places a limit order on the order book.
@@ -125,6 +138,7 @@ export class TradingAPIImpl extends BaseAPI {
125
138
  idempotencyKey: options?.idempotencyKey === undefined ? crypto.randomUUID() : options.idempotencyKey,
126
139
  takeProfit: parentTpSlLegToRequest(options?.takeProfit),
127
140
  stopLoss: parentTpSlLegToRequest(options?.stopLoss),
141
+ trailingStop: parentTrailingStopLegToRequest(options?.trailingStop),
128
142
  };
129
143
  return await this.makeAuthenticatedRequest(perpRoutes.orders.create(), {
130
144
  method: "POST",
@@ -201,6 +215,7 @@ export class TradingAPIImpl extends BaseAPI {
201
215
  selfTradePreventionMode: options?.selfTradePreventionMode,
202
216
  takeProfit: parentTpSlLegToRequest(options?.takeProfit),
203
217
  stopLoss: parentTpSlLegToRequest(options?.stopLoss),
218
+ trailingStop: parentTrailingStopLegToRequest(options?.trailingStop),
204
219
  clientOrderId: options?.clientOrderId,
205
220
  idempotencyKey: options?.idempotencyKey === undefined ? crypto.randomUUID() : options.idempotencyKey,
206
221
  };
@@ -261,13 +276,20 @@ export class TradingAPIImpl extends BaseAPI {
261
276
  /**
262
277
  * Lists conditional orders for the authenticated user.
263
278
  *
264
- * The response `total` is bounded by the listing's pagination reach: exact up
265
- * to `pageSize` x 10,000 rows and saturating there, so a caller with more
266
- * matching conditional orders than page-number mode can page through reads the
267
- * cap, not its lifetime total; `page` is capped at 10,000 and a larger page is
268
- * rejected.
279
+ * Cursor (pageToken) pagination is the default: when neither `page` nor
280
+ * `pageToken` is passed, the SDK drives a cursor walk (it sends
281
+ * `pageToken=""`), which seeks on `(createdAt, id)` newest-first and resumes
282
+ * via each response's `nextPageToken` until that token comes back empty. A
283
+ * cursor page size may go up to 1000.
269
284
  *
270
- * @param params - Optional pagination and margin-account / trading-pair / state filters
285
+ * Passing the deprecated `page` (without `pageToken`) selects legacy
286
+ * page-number mode instead, which keeps the 100 page-size cap and populates a
287
+ * bounded `total` (exact up to `pageSize` x 10,000 rows, saturating there);
288
+ * `page` is capped at 10,000. Cursor mode returns `total`/`page` as 0 and runs
289
+ * no COUNT.
290
+ *
291
+ * @param params - Optional filters and pagination; `pageToken` drives cursor
292
+ * mode, `page` drives the deprecated legacy mode
271
293
  * @returns Promise resolving to ListConditionalOrdersResponse with orders and pagination info
272
294
  * @throws {APIError} When API communication fails
273
295
  */
@@ -275,13 +297,24 @@ export class TradingAPIImpl extends BaseAPI {
275
297
  if (params) {
276
298
  validate(ListConditionalOrdersSchema, params);
277
299
  }
278
- const { page = 1, pageSize = 20, marginAccountId, tradingPairId, state } = params || {};
300
+ const { page, pageSize = 20, marginAccountId, tradingPairId, state, pageToken } = params || {};
301
+ // Legacy offset mode only when the caller explicitly asked for a page and no
302
+ // cursor was given; everything else drives cursor (pageToken) mode.
303
+ const legacyOffsetMode = page !== undefined && pageToken === undefined;
304
+ // Cursor requests may use pages up to 1000 (keyset reads); page-number
305
+ // requests keep the server's 100 cap.
306
+ const boundedPageSize = Math.min(Math.max(pageSize, 1), legacyOffsetMode ? 100 : 1000);
279
307
  return await this.makeAuthenticatedRequest(perpRoutes.orders.listConditional({
280
- page,
281
- pageSize: Math.min(Math.max(pageSize, 1), 100),
282
308
  marginAccountId,
283
309
  tradingPairId,
284
310
  state,
311
+ // Legacy mode sends `page` and omits `pageToken`; cursor mode omits
312
+ // `page` and sends `pageToken` — present even as the empty string, the
313
+ // documented way to start a walk (the server selects cursor mode by the
314
+ // token's presence, not its value).
315
+ page: legacyOffsetMode ? page : undefined,
316
+ pageSize: boundedPageSize,
317
+ pageToken: legacyOffsetMode ? undefined : (pageToken ?? ""),
285
318
  }), { method: "GET" });
286
319
  }
287
320
  /**
@@ -806,32 +839,50 @@ export class TradingAPIImpl extends BaseAPI {
806
839
  /**
807
840
  * Lists TWAP parent orders for the authenticated user.
808
841
  *
809
- * The response `total` is bounded by the listing's pagination reach: exact up
810
- * to `pageSize` x 10,000 rows and saturating there, so a caller with more
811
- * matching TWAP orders than page-number mode can page through reads the cap,
812
- * not its lifetime total; `page` is capped at 10,000 and a larger page is
813
- * rejected.
842
+ * Cursor (pageToken) pagination is the default: when neither `page` nor
843
+ * `pageToken` is passed, the SDK drives a cursor walk (it sends
844
+ * `pageToken=""`), which seeks on `(createdAt, id)` newest-first and resumes
845
+ * via each response's `nextPageToken` until that token comes back empty. A
846
+ * cursor page size may go up to 1000.
814
847
  *
815
- * @param params - Optional pagination and state / trading-pair filters
848
+ * Passing the deprecated `page` (without `pageToken`) selects legacy
849
+ * page-number mode instead, which keeps the 100 page-size cap and populates a
850
+ * bounded `total` (exact up to `pageSize` x 10,000 rows, saturating there);
851
+ * `page` is capped at 10,000. Cursor mode returns `total`/`page` as 0 and runs
852
+ * no COUNT.
853
+ *
854
+ * @param params - Optional filters and pagination; `pageToken` drives cursor
855
+ * mode, `page` drives the deprecated legacy mode
816
856
  * @returns Promise resolving to ListTwapOrdersResponse with orders and pagination info
817
857
  * @throws {APIError} When API communication fails
818
858
  *
819
859
  * @example
820
860
  * ```typescript
821
861
  * const active = await tradingAPI.listTwapOrders({ state: "ACTIVE", pageSize: 50 });
822
- * console.log(`Found ${active.total} active TWAP orders`);
862
+ * console.log(`Found ${active.orders.length} active TWAP orders`);
823
863
  * ```
824
864
  */
825
865
  async listTwapOrders(params) {
826
866
  if (params) {
827
867
  validate(ListTwapOrdersSchema, params);
828
868
  }
829
- const { page = 1, pageSize = 20, state, tradingPairId } = params || {};
869
+ const { page, pageSize = 20, state, tradingPairId, pageToken } = params || {};
870
+ // Legacy offset mode only when the caller explicitly asked for a page and no
871
+ // cursor was given; everything else drives cursor (pageToken) mode.
872
+ const legacyOffsetMode = page !== undefined && pageToken === undefined;
873
+ // Cursor requests may use pages up to 1000 (keyset reads); page-number
874
+ // requests keep the server's 100 cap.
875
+ const boundedPageSize = Math.min(Math.max(pageSize, 1), legacyOffsetMode ? 100 : 1000);
830
876
  return await this.makeAuthenticatedRequest(perpRoutes.orders.listTwap({
831
- page,
832
- pageSize: Math.min(Math.max(pageSize, 1), 100),
833
877
  state,
834
878
  tradingPairId,
879
+ // Legacy mode sends `page` and omits `pageToken`; cursor mode omits
880
+ // `page` and sends `pageToken` — present even as the empty string, the
881
+ // documented way to start a walk (the server selects cursor mode by the
882
+ // token's presence, not its value).
883
+ page: legacyOffsetMode ? page : undefined,
884
+ pageSize: boundedPageSize,
885
+ pageToken: legacyOffsetMode ? undefined : (pageToken ?? ""),
835
886
  }), { method: "GET" });
836
887
  }
837
888
  }
@@ -1,5 +1,5 @@
1
1
  import type { MonacoWebSocketConfig, SessionCredentials } from "@0xmonaco/types";
2
- export type { MessageHandler, MonacoWebSocket, ResyncHandler, StatusHandler, WebSocketResyncInfo } from "@0xmonaco/types";
2
+ export type { MessageHandler, MonacoWebSocket, ReauthenticationRequiredHandler, ResyncHandler, StatusHandler, WebSocketReauthenticationInfo, WebSocketResyncInfo, } from "@0xmonaco/types";
3
3
  /**
4
4
  * Options for {@link createMonacoWebSocket}. The reconnect/resync knobs and
5
5
  * callbacks are shared with the main SDK config via {@link MonacoWebSocketConfig}
@@ -1,5 +1,10 @@
1
+ import { ServerClock } from "../../utils/server-clock";
1
2
  import type { MonacoWebSocket, MonacoWebSocketOptions } from "./types";
2
3
  /**
3
4
  * Create a Monaco WebSocket client
5
+ *
6
+ * @param serverClock - Server time used to stamp the auth handshake; the SDK
7
+ * passes the clock its REST clients calibrate so a skewed device clock does
8
+ * not fail authentication.
4
9
  */
5
- export declare function createMonacoWebSocket(baseUrl: string, options?: MonacoWebSocketOptions): MonacoWebSocket;
10
+ export declare function createMonacoWebSocket(baseUrl: string, options?: MonacoWebSocketOptions, serverClock?: ServerClock): MonacoWebSocket;
@@ -1,12 +1,21 @@
1
1
  import { hexToBytes } from "@noble/hashes/utils.js";
2
2
  import { composeWsSigningString, signMessage } from "../../crypto/session";
3
3
  import { ALL_MAGNITUDES } from "../../utils";
4
+ import { ServerClock } from "../../utils/server-clock";
4
5
  import { keysToCamelCase } from "./utils";
5
6
  // Connection constants
6
7
  const CONNECTION_TIMEOUT = 10000;
7
8
  const HEARTBEAT_INTERVAL = 15000;
8
9
  const MAX_RECONNECT_DELAY = 30000;
9
10
  const RECONNECT_BASE_DELAY = 1000;
11
+ /**
12
+ * Handshakes rejected for timestamp skew that are retried before the session
13
+ * is treated as lost. A restored session can authenticate before any REST
14
+ * response has calibrated the server clock; each retry re-signs with
15
+ * whatever the clock has learned since.
16
+ */
17
+ const MAX_SKEW_AUTH_RETRIES = 3;
18
+ const SKEW_REJECTION = /timestamp outside allowed skew/i;
10
19
  function isRecord(value) {
11
20
  return typeof value === "object" && value !== null && !Array.isArray(value);
12
21
  }
@@ -155,9 +164,21 @@ function parseConditionalOrderEventData(raw) {
155
164
  cancelledAt: readOptionalString(data, "cancelledAt"),
156
165
  expiresAt: readOptionalString(data, "expiresAt"),
157
166
  failureReason: readOptionalString(data, "failureReason"),
167
+ // Trailing-stop fields (0XM-2827), explicit `null` on TP/SL rows. A live
168
+ // `ratcheted` frame carries the moved `triggerPrice` together with the
169
+ // `watermarkPrice` it was derived from; none of these is validated.
170
+ trailBps: readOptionalNumber(data, "trailBps"),
171
+ activationPrice: readOptionalString(data, "activationPrice"),
172
+ watermarkPrice: readOptionalString(data, "watermarkPrice"),
173
+ trailArmedAt: readOptionalString(data, "trailArmedAt"),
158
174
  // Free-form on the wire (the server field is a plain string), so it is
159
175
  // passed through untouched rather than matched against a value list.
160
176
  reason: readString(data, "reason"),
177
+ // Sent on every frame by current servers, but read optionally: a frame
178
+ // from an older deployment during a rolling release omits it, and a
179
+ // `readString` there would throw inside the message handler and drop the
180
+ // update.
181
+ createdAt: readOptionalString(data, "createdAt"),
161
182
  updatedAt: readString(data, "updatedAt"),
162
183
  // Populated on the subscribe-time snapshot frame and absent on live
163
184
  // updates (0XM-2440). Reading it optionally is what keeps the snapshot's
@@ -662,8 +683,12 @@ function snapshotFrameHandler(channel, parseItem, onSnapshot) {
662
683
  }
663
684
  /**
664
685
  * Create a Monaco WebSocket client
686
+ *
687
+ * @param serverClock - Server time used to stamp the auth handshake; the SDK
688
+ * passes the clock its REST clients calibrate so a skewed device clock does
689
+ * not fail authentication.
665
690
  */
666
- export function createMonacoWebSocket(baseUrl, options = {}) {
691
+ export function createMonacoWebSocket(baseUrl, options = {}, serverClock = new ServerClock()) {
667
692
  let ws = null;
668
693
  let session = options.session;
669
694
  let reconnectAttempts = 0;
@@ -683,6 +708,17 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
683
708
  // Close code of the connection loss the next successful reopen recovers
684
709
  // from; null when no resync is owed (first connect, clean disconnect).
685
710
  let pendingResyncCode = null;
711
+ // Authentication-ending Error frame received on the current socket, tagged
712
+ // with the session it rejected. The server answers handshakes in the order
713
+ // they were sent, so each Authenticated / AUTH_FAILED reply belongs to the
714
+ // oldest pending handshake, and SESSION_INVALID revokes the session the
715
+ // server last confirmed. All of it resets per socket so a frame from one
716
+ // connection never decides how a later connection's close is read.
717
+ let lastAuthError = null;
718
+ let pendingAuthSessions = [];
719
+ // Consecutive handshakes rejected for clock skew; reset on success.
720
+ let skewAuthRetries = 0;
721
+ let confirmedSession;
686
722
  // Handler storage: channel -> set of handlers (supports multiple subscribers per channel)
687
723
  const handlers = new Map();
688
724
  // Subscribe-time Snapshot frame handlers, keyed by the RAW subscribed
@@ -730,8 +766,9 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
730
766
  const sendAuthenticate = () => {
731
767
  if (!session || ws?.readyState !== WebSocket.OPEN)
732
768
  return;
733
- const timestamp = Date.now();
769
+ const timestamp = serverClock.now();
734
770
  const signature = signMessage(hexToBytes(session.privateKey), composeWsSigningString(session.publicKey, timestamp));
771
+ pendingAuthSessions.push(session);
735
772
  ws.send(JSON.stringify({
736
773
  type: "Authenticate",
737
774
  public_key: session.publicKey,
@@ -831,8 +868,8 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
831
868
  };
832
869
  const routeMessage = (msg) => {
833
870
  if (msg.type === "Error") {
834
- // Connection-scoped, not channel-scoped: an Error frame matches no
835
- // subscription, so without this it reached no consumer at all and a
871
+ // An Error frame matches no subscription even when it names a channel,
872
+ // so without this it reached no consumer at all and a
836
873
  // failed subscribe / invalidated session / unreadable subscribe-time
837
874
  // snapshot was silent. `code` is passed through whatever its value —
838
875
  // a code added server-side after this SDK shipped must still reach the
@@ -841,10 +878,32 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
841
878
  // and synthesizing the missing half would send the app down a branch on
842
879
  // data the server never sent.
843
880
  if (typeof msg.code === "string" && typeof msg.message === "string") {
844
- options.onError?.({ code: msg.code, message: msg.message });
881
+ // The server sends the reason a connection is ending as an Error
882
+ // frame just before the close; the close event decides from it.
883
+ // Recorded before any application code runs, so a throwing onError
884
+ // cannot make the close look transient.
885
+ if (msg.code === "AUTH_FAILED") {
886
+ lastAuthError = { code: msg.code, message: msg.message, session: pendingAuthSessions.shift() };
887
+ }
888
+ else if (msg.code === "SESSION_INVALID") {
889
+ lastAuthError = { code: msg.code, message: msg.message, session: confirmedSession ?? pendingAuthSessions[0] };
890
+ }
891
+ // A per-channel failure names the channel it concerns; the key is
892
+ // left off entirely otherwise, so a connection-wide failure reaches
893
+ // the app exactly as the server sent it. A non-string channel is
894
+ // dropped rather than the frame: the code and message are still
895
+ // actionable without it. (The frame is untyped JSON, so the declared
896
+ // `string` type of `channel` is not trusted here.)
897
+ const channel = msg.channel;
898
+ options.onError?.(typeof channel === "string" ? { code: msg.code, message: msg.message, channel } : { code: msg.code, message: msg.message });
845
899
  }
846
900
  return;
847
901
  }
902
+ if (msg.type === "Authenticated") {
903
+ confirmedSession = pendingAuthSessions.shift();
904
+ skewAuthRetries = 0;
905
+ return;
906
+ }
848
907
  if (!msg.channel)
849
908
  return;
850
909
  if (msg.type === "Snapshot") {
@@ -876,6 +935,9 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
876
935
  try {
877
936
  const socket = new WebSocket(getUrl());
878
937
  ws = socket;
938
+ lastAuthError = null;
939
+ pendingAuthSessions = [];
940
+ confirmedSession = undefined;
879
941
  const timeout = setTimeout(() => {
880
942
  if (ws !== socket) {
881
943
  resolve();
@@ -947,6 +1009,62 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
947
1009
  }
948
1010
  ws = null;
949
1011
  stopHeartbeat();
1012
+ // Terminal authentication loss: the server revoked the session
1013
+ // (SESSION_INVALID, then 1008) or rejected this connection's
1014
+ // handshake (AUTH_FAILED, then a close). Reconnecting would replay
1015
+ // the same rejected credentials into the same rejection forever, so
1016
+ // drop them, skip the reconnect, and let the app supply a fresh
1017
+ // session. Handlers stay registered so that session resubscribes
1018
+ // them. A 1008 without SESSION_INVALID (the message-rate cap) is a
1019
+ // back-off-and-retry and falls through to the reconnect below, as
1020
+ // does a close after the app already replaced the rejected session
1021
+ // (e.g. re-authenticating from onError): the fresh session is what
1022
+ // the reconnect uses.
1023
+ const authError = lastAuthError;
1024
+ // A handshake stamped outside the server's window is a clock
1025
+ // problem, not a dead session: reconnect and re-sign, a bounded
1026
+ // number of times, instead of discarding the credentials.
1027
+ const skewRetry = autoReconnect && authError?.code === "AUTH_FAILED" && SKEW_REJECTION.test(authError.message) && skewAuthRetries < MAX_SKEW_AUTH_RETRIES;
1028
+ if (skewRetry)
1029
+ skewAuthRetries++;
1030
+ const authLoss = !skewRetry && authError !== null && authError.session !== undefined && (authError.code === "AUTH_FAILED" || event.code === 1008);
1031
+ const terminalAuthLoss = authLoss && session === authError.session;
1032
+ if (terminalAuthLoss) {
1033
+ session = undefined;
1034
+ // The reopen with a fresh session still owes the app a resync —
1035
+ // nothing was delivered while the session was dead — whether or
1036
+ // not automatic reconnect is on, since that reopen is driven by
1037
+ // the new session, not by the reconnect loop.
1038
+ if (pendingResyncCode === null) {
1039
+ pendingResyncCode = event.code;
1040
+ }
1041
+ try {
1042
+ options.onStatusChange?.("disconnected");
1043
+ }
1044
+ finally {
1045
+ // Recovery must not hinge on the status observer not throwing.
1046
+ options.onReauthenticationRequired?.({ code: authError.code, message: authError.message, closeCode: event.code });
1047
+ }
1048
+ return;
1049
+ }
1050
+ // The app already handed over a replacement session on this socket.
1051
+ // Had it arrived after the close, setSessionKeypair would have
1052
+ // connected with it; honour that even with automatic reconnect off
1053
+ // (with it on, the reconnect below uses the new session).
1054
+ if (authLoss && session !== undefined && !autoReconnect) {
1055
+ if (pendingResyncCode === null) {
1056
+ pendingResyncCode = event.code;
1057
+ }
1058
+ try {
1059
+ options.onStatusChange?.("disconnected");
1060
+ }
1061
+ finally {
1062
+ connect().catch((err) => {
1063
+ console.warn("WebSocket: Failed to connect with the replacement session:", err);
1064
+ });
1065
+ }
1066
+ return;
1067
+ }
950
1068
  // Reconnect on abnormal close. 1013 is the server's slow-client
951
1069
  // shed: the stream has a gap, so the reopen owes the app a resync.
952
1070
  // Keep the first owed code (upgrading to 1013 if it arrives later):
@@ -1574,6 +1692,7 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
1574
1692
  getStatus,
1575
1693
  setSessionKeypair: (credentials) => {
1576
1694
  session = credentials ?? undefined;
1695
+ skewAuthRetries = 0;
1577
1696
  // If already connected, (re)send the signed handshake so the connection
1578
1697
  // picks up the new session without tearing down. If not yet connected,
1579
1698
  // onopen will send it. Clearing the session leaves the connection up for
package/dist/sdk.js CHANGED
@@ -24,6 +24,7 @@ import { WithdrawalsAPIImpl } from "./api/withdrawals";
24
24
  import { generateSessionKeypair, privateKeyHex, publicKeyHex } from "./crypto/session";
25
25
  import { APIError, InvalidConfigError, InvalidStateError } from "./errors";
26
26
  import { resolveApiUrl, resolveWsUrl } from "./networks";
27
+ import { ServerClock } from "./utils/server-clock";
27
28
  /**
28
29
  * Default receipt-polling frequency for the SDK's internal viem public client.
29
30
  * Sei produces sub-second blocks, so viem's 4000ms default adds seconds of
@@ -189,8 +190,10 @@ export class MonacoSDKImpl {
189
190
  this.walletClient = cfg.walletClient;
190
191
  }
191
192
  // Instantiate APIs (wallet-dependent APIs will be initialized lazily or error if wallet not set)
193
+ const serverClock = new ServerClock();
192
194
  const configureSigning = (api) => {
193
195
  api.setRequestSigning(cfg.requestSigning ?? "legacy");
196
+ api.setServerClock(serverClock);
194
197
  return api;
195
198
  };
196
199
  this.applications = configureSigning(new ApplicationsAPIImpl(apiUrl));
@@ -218,7 +221,7 @@ export class MonacoSDKImpl {
218
221
  // Thread the reconnect/resync knobs and callbacks from SDK config into the
219
222
  // internally-built ws client. The session keypair is not passed here — it
220
223
  // is propagated on login via propagateSession/setSessionKeypair.
221
- this.ws = createMonacoWebSocket(wsUrl, cfg.ws);
224
+ this.ws = createMonacoWebSocket(wsUrl, cfg.ws, serverClock);
222
225
  // Auto-connect WebSocket, log errors
223
226
  this.ws.connect().catch(console.error);
224
227
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Parse an RFC 7231 `HTTP-date` — in any of the three formats a recipient must
3
+ * accept (IMF-fixdate, the obsolete RFC 850 form, and asctime) — into a UTC
4
+ * epoch in milliseconds, or `undefined` if the value is not a well-formed, real
5
+ * calendar date. All three denote UTC, so timestamps are built with `Date.UTC`
6
+ * and the fields are round-tripped to reject impossible dates such as
7
+ * `31 Feb` or `25:00:00`. RFC 850's 2-digit year follows the RFC 7231 rule
8
+ * (see {@link expandTwoDigitYear}). The redundant day-name is intentionally not
9
+ * validated against the date — RFC 7231 encourages robust parsing, and the
10
+ * date/time fields already determine the instant unambiguously, so a mismatched
11
+ * weekday still yields the intended delay rather than a dropped header and an
12
+ * arbitrary fallback.
13
+ */
14
+ export declare function parseHttpDate(value: string): number | undefined;
@@ -0,0 +1,106 @@
1
+ const MONTHS = {
2
+ Jan: 0,
3
+ Feb: 1,
4
+ Mar: 2,
5
+ Apr: 3,
6
+ May: 4,
7
+ Jun: 5,
8
+ Jul: 6,
9
+ Aug: 7,
10
+ Sep: 8,
11
+ Oct: 9,
12
+ Nov: 10,
13
+ Dec: 11,
14
+ };
15
+ // The three RFC 7231 §7.1.1.1 `HTTP-date` grammars a recipient must accept.
16
+ // Each is anchored so trailing junk is rejected, and `Date.parse` is avoided
17
+ // entirely (it is permissive — it reads asctime in local time and rolls
18
+ // impossible dates like 31 Feb forward instead of rejecting them).
19
+ const MONTH = "Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec";
20
+ const IMF_FIXDATE = new RegExp(`^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\\d{2}) (${MONTH}) (\\d{4}) (\\d{2}):(\\d{2}):(\\d{2}) GMT$`);
21
+ const RFC850_DATE = new RegExp(`^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\\d{2})-(${MONTH})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2}) GMT$`);
22
+ const ASCTIME_DATE = new RegExp(`^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (${MONTH}) ([ \\d]\\d) (\\d{2}):(\\d{2}):(\\d{2}) (\\d{4})$`);
23
+ /**
24
+ * Expand an RFC 850 two-digit year per RFC 7231 §7.1.1.1: a full timestamp that
25
+ * appears more than 50 years in the future is interpreted as the most recent
26
+ * past year with the same last two digits. The comparison is against the exact
27
+ * receipt instant plus 50 years (not just calendar years), so the boundary can
28
+ * fall mid-year — e.g. received 2026-06-01, `31-Dec-76` is 50y7m ahead and maps
29
+ * to 1976, while `01-Jan-76` is under 50 years ahead and stays 2076.
30
+ */
31
+ function expandTwoDigitYear(twoDigitYear, month, day, hour, minute, second) {
32
+ const now = new Date(Date.now());
33
+ const candidateYear = Math.floor(now.getUTCFullYear() / 100) * 100 + twoDigitYear;
34
+ const candidate = Date.UTC(candidateYear, month, day, hour, minute, second);
35
+ const fiftyYearsAhead = Date.UTC(now.getUTCFullYear() + 50, now.getUTCMonth(), now.getUTCDate(), now.getUTCHours(), now.getUTCMinutes(), now.getUTCSeconds());
36
+ return candidate > fiftyYearsAhead ? candidateYear - 100 : candidateYear;
37
+ }
38
+ /**
39
+ * Parse an RFC 7231 `HTTP-date` — in any of the three formats a recipient must
40
+ * accept (IMF-fixdate, the obsolete RFC 850 form, and asctime) — into a UTC
41
+ * epoch in milliseconds, or `undefined` if the value is not a well-formed, real
42
+ * calendar date. All three denote UTC, so timestamps are built with `Date.UTC`
43
+ * and the fields are round-tripped to reject impossible dates such as
44
+ * `31 Feb` or `25:00:00`. RFC 850's 2-digit year follows the RFC 7231 rule
45
+ * (see {@link expandTwoDigitYear}). The redundant day-name is intentionally not
46
+ * validated against the date — RFC 7231 encourages robust parsing, and the
47
+ * date/time fields already determine the instant unambiguously, so a mismatched
48
+ * weekday still yields the intended delay rather than a dropped header and an
49
+ * arbitrary fallback.
50
+ */
51
+ export function parseHttpDate(value) {
52
+ let day;
53
+ let month;
54
+ let year;
55
+ let hour;
56
+ let minute;
57
+ let second;
58
+ const imf = IMF_FIXDATE.exec(value);
59
+ const rfc850 = RFC850_DATE.exec(value);
60
+ const asctime = ASCTIME_DATE.exec(value);
61
+ if (imf) {
62
+ day = Number(imf[1]);
63
+ month = MONTHS[imf[2]];
64
+ year = Number(imf[3]);
65
+ hour = Number(imf[4]);
66
+ minute = Number(imf[5]);
67
+ second = Number(imf[6]);
68
+ }
69
+ else if (rfc850) {
70
+ day = Number(rfc850[1]);
71
+ month = MONTHS[rfc850[2]];
72
+ hour = Number(rfc850[4]);
73
+ minute = Number(rfc850[5]);
74
+ second = Number(rfc850[6]);
75
+ if (month === undefined)
76
+ return undefined;
77
+ year = expandTwoDigitYear(Number(rfc850[3]), month, day, hour, minute, second);
78
+ }
79
+ else if (asctime) {
80
+ month = MONTHS[asctime[1]];
81
+ day = Number((asctime[2] ?? "").trim());
82
+ hour = Number(asctime[3]);
83
+ minute = Number(asctime[4]);
84
+ second = Number(asctime[5]);
85
+ year = Number(asctime[6]);
86
+ }
87
+ else {
88
+ return undefined;
89
+ }
90
+ if (month === undefined)
91
+ return undefined;
92
+ const timestamp = Date.UTC(year, month, day, hour, minute, second);
93
+ const utc = new Date(timestamp);
94
+ // Reject impossible calendar/clock values that Date.UTC silently rolls over
95
+ // (e.g. 31 Feb, 25:00:00). A `:60` leap second is likewise dropped rather
96
+ // than special-cased — leap seconds never appear in a Retry-After header.
97
+ if (utc.getUTCFullYear() !== year ||
98
+ utc.getUTCMonth() !== month ||
99
+ utc.getUTCDate() !== day ||
100
+ utc.getUTCHours() !== hour ||
101
+ utc.getUTCMinutes() !== minute ||
102
+ utc.getUTCSeconds() !== second) {
103
+ return undefined;
104
+ }
105
+ return timestamp;
106
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Offsets below this are left uncorrected: the server accepts signatures
3
+ * within ±30s, and the `Date` header only has whole-second resolution, so
4
+ * correcting a nearly-right clock would add noise without fixing anything.
5
+ */
6
+ export declare const CLOCK_CORRECTION_THRESHOLD_MS = 5000;
7
+ /**
8
+ * The server's notion of "now", learned from API response `Date` headers.
9
+ *
10
+ * Request signatures carry a timestamp the server rejects outside a ±30s
11
+ * window, so a device whose clock is minutes off fails every signed call.
12
+ * Each response reports the server's time; the offset from the local clock
13
+ * is applied to signing timestamps once it exceeds
14
+ * {@link CLOCK_CORRECTION_THRESHOLD_MS}.
15
+ */
16
+ export declare class ServerClock {
17
+ private offset;
18
+ /** Milliseconds added to the local clock; 0 while the device clock is close enough. */
19
+ get offsetMs(): number;
20
+ /** Current epoch milliseconds on the server's clock. */
21
+ now(): number;
22
+ /**
23
+ * Record the server time from a response, skipping any the browser may
24
+ * have served from its HTTP cache: a cached copy keeps its original `Date`,
25
+ * which can be older than the ±30s signing window.
26
+ */
27
+ observeResponse(headers: Headers | undefined, receivedAtMs: number): void;
28
+ /**
29
+ * Record the server time from a response `Date` header.
30
+ *
31
+ * The server stamps `Date` when it sends the response, so it is compared
32
+ * with the local receipt time; request upload and server processing do not
33
+ * skew the estimate. The header is truncated to the second, so it is read
34
+ * as the middle of that second. A missing or malformed header leaves the
35
+ * offset unchanged.
36
+ */
37
+ observe(dateHeader: string | null | undefined, receivedAtMs: number): void;
38
+ }
@@ -0,0 +1,67 @@
1
+ import { parseHttpDate } from "./http-date";
2
+ /**
3
+ * Offsets below this are left uncorrected: the server accepts signatures
4
+ * within ±30s, and the `Date` header only has whole-second resolution, so
5
+ * correcting a nearly-right clock would add noise without fixing anything.
6
+ */
7
+ export const CLOCK_CORRECTION_THRESHOLD_MS = 5_000;
8
+ /** True when `Cache-Control` lets a cache store the response and serve it later. */
9
+ function mayBeCached(cacheControl) {
10
+ if (!cacheControl)
11
+ return false;
12
+ const directives = cacheControl.split(",").map((directive) => {
13
+ const [name = "", value = ""] = directive.split("=", 2);
14
+ return { name: name.trim().toLowerCase(), value: value.trim().replace(/^"(.*)"$/, "$1") };
15
+ });
16
+ if (directives.some(({ name }) => name === "no-store" || name === "no-cache"))
17
+ return false;
18
+ return directives.some(({ name, value }) => name === "immutable" || ((name === "max-age" || name === "s-maxage") && Number(value) > 0));
19
+ }
20
+ /**
21
+ * The server's notion of "now", learned from API response `Date` headers.
22
+ *
23
+ * Request signatures carry a timestamp the server rejects outside a ±30s
24
+ * window, so a device whose clock is minutes off fails every signed call.
25
+ * Each response reports the server's time; the offset from the local clock
26
+ * is applied to signing timestamps once it exceeds
27
+ * {@link CLOCK_CORRECTION_THRESHOLD_MS}.
28
+ */
29
+ export class ServerClock {
30
+ offset = 0;
31
+ /** Milliseconds added to the local clock; 0 while the device clock is close enough. */
32
+ get offsetMs() {
33
+ return this.offset;
34
+ }
35
+ /** Current epoch milliseconds on the server's clock. */
36
+ now() {
37
+ return Date.now() + this.offset;
38
+ }
39
+ /**
40
+ * Record the server time from a response, skipping any the browser may
41
+ * have served from its HTTP cache: a cached copy keeps its original `Date`,
42
+ * which can be older than the ±30s signing window.
43
+ */
44
+ observeResponse(headers, receivedAtMs) {
45
+ if (!headers || mayBeCached(headers.get("cache-control")))
46
+ return;
47
+ this.observe(headers.get("date"), receivedAtMs);
48
+ }
49
+ /**
50
+ * Record the server time from a response `Date` header.
51
+ *
52
+ * The server stamps `Date` when it sends the response, so it is compared
53
+ * with the local receipt time; request upload and server processing do not
54
+ * skew the estimate. The header is truncated to the second, so it is read
55
+ * as the middle of that second. A missing or malformed header leaves the
56
+ * offset unchanged.
57
+ */
58
+ observe(dateHeader, receivedAtMs) {
59
+ if (!dateHeader)
60
+ return;
61
+ const serverMs = parseHttpDate(dateHeader.trim());
62
+ if (serverMs === undefined)
63
+ return;
64
+ const estimate = Math.round(serverMs + 500 - receivedAtMs);
65
+ this.offset = Math.abs(estimate) < CLOCK_CORRECTION_THRESHOLD_MS ? 0 : estimate;
66
+ }
67
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0xmonaco/core",
3
- "version": "1.0.65",
3
+ "version": "1.0.67",
4
4
  "type": "module",
5
5
  "homepage": "https://docs.0xmonaco.com/sdk/typescript",
6
6
  "main": "./dist/index.js",
@@ -19,8 +19,8 @@
19
19
  "viem": "^2.45.2"
20
20
  },
21
21
  "dependencies": {
22
- "@0xmonaco/contracts": "1.0.65",
23
- "@0xmonaco/types": "1.0.65",
22
+ "@0xmonaco/contracts": "1.0.67",
23
+ "@0xmonaco/types": "1.0.67",
24
24
  "@noble/curves": "^1.9.1",
25
25
  "@noble/hashes": "^2.4.0",
26
26
  "http-status-codes": "^2.3.0"