@0xmonaco/core 1.0.66 → 1.0.69

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);
@@ -31,7 +31,10 @@ export declare class BuildercodesAPIImpl extends BaseAPI implements Buildercodes
31
31
  */
32
32
  getConfig(): Promise<GetBuildercodesConfigResponse>;
33
33
  /**
34
- * List this frontend's per-trade BuilderCodes credits, newest first.
34
+ * List this frontend's per-trade BuilderCodes credits, newest first. Each
35
+ * credit carries its payout `kind`: `REVENUE_SHARE` (your share of Monaco's
36
+ * fee) or `BUILDER_FEE` (your builder fee). Pass `kind` to list one stream;
37
+ * `total` then counts only that stream.
35
38
  *
36
39
  * The server scopes every query to the authenticated application; neither
37
40
  * the application id nor payout wallet can be supplied by the caller.
@@ -40,7 +43,9 @@ export declare class BuildercodesAPIImpl extends BaseAPI implements Buildercodes
40
43
  /**
41
44
  * Summarize this frontend's BuilderCodes earnings by token.
42
45
  *
43
- * Amounts are RAW atomic units and remain separated by token.
46
+ * Amounts are RAW atomic units and remain separated by token. `byToken` sums
47
+ * every payout kind; `byTokenAndKind` splits the same totals into the
48
+ * revenue share and the builder fee.
44
49
  */
45
50
  getPayoutSummary(): Promise<GetBuildercodePayoutSummaryResponse>;
46
51
  /**
@@ -103,7 +103,10 @@ export class BuildercodesAPIImpl extends BaseAPI {
103
103
  });
104
104
  }
105
105
  /**
106
- * List this frontend's per-trade BuilderCodes credits, newest first.
106
+ * List this frontend's per-trade BuilderCodes credits, newest first. Each
107
+ * credit carries its payout `kind`: `REVENUE_SHARE` (your share of Monaco's
108
+ * fee) or `BUILDER_FEE` (your builder fee). Pass `kind` to list one stream;
109
+ * `total` then counts only that stream.
107
110
  *
108
111
  * The server scopes every query to the authenticated application; neither
109
112
  * the application id nor payout wallet can be supplied by the caller.
@@ -119,6 +122,9 @@ export class BuildercodesAPIImpl extends BaseAPI {
119
122
  if (params?.pageSize !== undefined) {
120
123
  searchParams.append("pageSize", params.pageSize.toString());
121
124
  }
125
+ if (params?.kind !== undefined) {
126
+ searchParams.append("kind", params.kind);
127
+ }
122
128
  const query = searchParams.toString();
123
129
  const path = query ? `/api/v1/buildercodes/payouts?${query}` : "/api/v1/buildercodes/payouts";
124
130
  return this.makeAuthenticatedRequest(path, { method: "GET" });
@@ -126,7 +132,9 @@ export class BuildercodesAPIImpl extends BaseAPI {
126
132
  /**
127
133
  * Summarize this frontend's BuilderCodes earnings by token.
128
134
  *
129
- * Amounts are RAW atomic units and remain separated by token.
135
+ * Amounts are RAW atomic units and remain separated by token. `byToken` sums
136
+ * every payout kind; `byTokenAndKind` splits the same totals into the
137
+ * revenue share and the builder fee.
130
138
  */
131
139
  async getPayoutSummary() {
132
140
  return this.makeAuthenticatedRequest("/api/v1/buildercodes/payouts/summary", { method: "GET" });
@@ -35,8 +35,8 @@ export declare class FaucetAPIImpl extends BaseAPI implements FaucetAPI {
35
35
  *
36
36
  * Read-only: remaining requests in the rolling 24h window, when the next
37
37
  * request becomes available (`nextAvailableAt`, absent when nothing is in
38
- * the window), and the 5 most recent requests. Wallet-scoped across apps,
39
- * matching mint enforcement.
38
+ * the window), and the 5 most recent requests. Scoped to the wallet on this
39
+ * app (each app has its own quota), matching mint enforcement.
40
40
  *
41
41
  * @returns Promise resolving to the quota and recent-request history
42
42
  */
@@ -40,8 +40,8 @@ export class FaucetAPIImpl extends BaseAPI {
40
40
  *
41
41
  * Read-only: remaining requests in the rolling 24h window, when the next
42
42
  * request becomes available (`nextAvailableAt`, absent when nothing is in
43
- * the window), and the 5 most recent requests. Wallet-scoped across apps,
44
- * matching mint enforcement.
43
+ * the window), and the 5 most recent requests. Scoped to the wallet on this
44
+ * app (each app has its own quota), matching mint enforcement.
45
45
  *
46
46
  * @returns Promise resolving to the quota and recent-request history
47
47
  */
@@ -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;
@@ -191,13 +191,20 @@ export declare class TradingAPIImpl extends BaseAPI implements TradingAPI {
191
191
  /**
192
192
  * Lists conditional orders for the authenticated user.
193
193
  *
194
- * The response `total` is bounded by the listing's pagination reach: exact up
195
- * to `pageSize` x 10,000 rows and saturating there, so a caller with more
196
- * matching conditional orders than page-number mode can page through reads the
197
- * cap, not its lifetime total; `page` is capped at 10,000 and a larger page is
198
- * rejected.
199
- *
200
- * @param params - Optional pagination and margin-account / trading-pair / state filters
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.
199
+ *
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
201
208
  * @returns Promise resolving to ListConditionalOrdersResponse with orders and pagination info
202
209
  * @throws {APIError} When API communication fails
203
210
  */
@@ -522,20 +529,27 @@ export declare class TradingAPIImpl extends BaseAPI implements TradingAPI {
522
529
  /**
523
530
  * Lists TWAP parent orders for the authenticated user.
524
531
  *
525
- * The response `total` is bounded by the listing's pagination reach: exact up
526
- * to `pageSize` x 10,000 rows and saturating there, so a caller with more
527
- * matching TWAP orders than page-number mode can page through reads the cap,
528
- * not its lifetime total; `page` is capped at 10,000 and a larger page is
529
- * 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.
530
543
  *
531
- * @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
532
546
  * @returns Promise resolving to ListTwapOrdersResponse with orders and pagination info
533
547
  * @throws {APIError} When API communication fails
534
548
  *
535
549
  * @example
536
550
  * ```typescript
537
551
  * const active = await tradingAPI.listTwapOrders({ state: "ACTIVE", pageSize: 50 });
538
- * console.log(`Found ${active.total} active TWAP orders`);
552
+ * console.log(`Found ${active.orders.length} active TWAP orders`);
539
553
  * ```
540
554
  */
541
555
  listTwapOrders(params?: ListTwapOrdersParams): Promise<ListTwapOrdersResponse>;
@@ -276,13 +276,20 @@ export class TradingAPIImpl extends BaseAPI {
276
276
  /**
277
277
  * Lists conditional orders for the authenticated user.
278
278
  *
279
- * The response `total` is bounded by the listing's pagination reach: exact up
280
- * to `pageSize` x 10,000 rows and saturating there, so a caller with more
281
- * matching conditional orders than page-number mode can page through reads the
282
- * cap, not its lifetime total; `page` is capped at 10,000 and a larger page is
283
- * rejected.
284
- *
285
- * @param params - Optional pagination and margin-account / trading-pair / state filters
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.
284
+ *
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
286
293
  * @returns Promise resolving to ListConditionalOrdersResponse with orders and pagination info
287
294
  * @throws {APIError} When API communication fails
288
295
  */
@@ -290,13 +297,24 @@ export class TradingAPIImpl extends BaseAPI {
290
297
  if (params) {
291
298
  validate(ListConditionalOrdersSchema, params);
292
299
  }
293
- 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);
294
307
  return await this.makeAuthenticatedRequest(perpRoutes.orders.listConditional({
295
- page,
296
- pageSize: Math.min(Math.max(pageSize, 1), 100),
297
308
  marginAccountId,
298
309
  tradingPairId,
299
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 ?? ""),
300
318
  }), { method: "GET" });
301
319
  }
302
320
  /**
@@ -821,32 +839,50 @@ export class TradingAPIImpl extends BaseAPI {
821
839
  /**
822
840
  * Lists TWAP parent orders for the authenticated user.
823
841
  *
824
- * The response `total` is bounded by the listing's pagination reach: exact up
825
- * to `pageSize` x 10,000 rows and saturating there, so a caller with more
826
- * matching TWAP orders than page-number mode can page through reads the cap,
827
- * not its lifetime total; `page` is capped at 10,000 and a larger page is
828
- * 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.
829
847
  *
830
- * @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
831
856
  * @returns Promise resolving to ListTwapOrdersResponse with orders and pagination info
832
857
  * @throws {APIError} When API communication fails
833
858
  *
834
859
  * @example
835
860
  * ```typescript
836
861
  * const active = await tradingAPI.listTwapOrders({ state: "ACTIVE", pageSize: 50 });
837
- * console.log(`Found ${active.total} active TWAP orders`);
862
+ * console.log(`Found ${active.orders.length} active TWAP orders`);
838
863
  * ```
839
864
  */
840
865
  async listTwapOrders(params) {
841
866
  if (params) {
842
867
  validate(ListTwapOrdersSchema, params);
843
868
  }
844
- 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);
845
876
  return await this.makeAuthenticatedRequest(perpRoutes.orders.listTwap({
846
- page,
847
- pageSize: Math.min(Math.max(pageSize, 1), 100),
848
877
  state,
849
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 ?? ""),
850
886
  }), { method: "GET" });
851
887
  }
852
888
  }
@@ -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
  }
@@ -165,10 +174,14 @@ function parseConditionalOrderEventData(raw) {
165
174
  // Free-form on the wire (the server field is a plain string), so it is
166
175
  // passed through untouched rather than matched against a value list.
167
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"),
168
182
  updatedAt: readString(data, "updatedAt"),
169
- // Populated on the subscribe-time snapshot frame and absent on live
170
- // updates (0XM-2440). Reading it optionally is what keeps the snapshot's
171
- // value from being silently dropped before the subscriber.
183
+ // Populated on live updates and on the subscribe-time snapshot frame
184
+ // (0XM-2440); absent only for a row written before the counter existed.
172
185
  version: readOptionalNumber(data, "version"),
173
186
  };
174
187
  }
@@ -583,6 +596,7 @@ function parseOrderSnapshotItem(raw) {
583
596
  // `clientOrderId: undefined`, which a structural comparison would see.
584
597
  const clientOrderId = readOptionalString(item, "clientOrderId");
585
598
  const selfTradePreventionMode = readOptionalString(item, "selfTradePreventionMode");
599
+ const parentOrderId = readOptionalString(item, "parentOrderId");
586
600
  const version = readOptionalNumber(item, "version");
587
601
  return {
588
602
  orderId: readString(item, "orderId"),
@@ -617,6 +631,9 @@ function parseOrderSnapshotItem(raw) {
617
631
  // Spread in conditionally like `clientOrderId`: absent means the order
618
632
  // requested no override, and must stay absent (0XM-2733).
619
633
  ...(selfTradePreventionMode === undefined ? {} : { selfTradePreventionMode }),
634
+ // Spread in conditionally too: absent for an order that is not a TWAP
635
+ // child, and absent must stay absent.
636
+ ...(parentOrderId === undefined ? {} : { parentOrderId }),
620
637
  // Spread in conditionally for the same reason as `clientOrderId`: the
621
638
  // server omits the key when unknown, and absent must stay absent rather
622
639
  // than becoming `version: undefined` — a resync compares this value, and
@@ -669,8 +686,12 @@ function snapshotFrameHandler(channel, parseItem, onSnapshot) {
669
686
  }
670
687
  /**
671
688
  * Create a Monaco WebSocket client
689
+ *
690
+ * @param serverClock - Server time used to stamp the auth handshake; the SDK
691
+ * passes the clock its REST clients calibrate so a skewed device clock does
692
+ * not fail authentication.
672
693
  */
673
- export function createMonacoWebSocket(baseUrl, options = {}) {
694
+ export function createMonacoWebSocket(baseUrl, options = {}, serverClock = new ServerClock()) {
674
695
  let ws = null;
675
696
  let session = options.session;
676
697
  let reconnectAttempts = 0;
@@ -698,6 +719,8 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
698
719
  // connection never decides how a later connection's close is read.
699
720
  let lastAuthError = null;
700
721
  let pendingAuthSessions = [];
722
+ // Consecutive handshakes rejected for clock skew; reset on success.
723
+ let skewAuthRetries = 0;
701
724
  let confirmedSession;
702
725
  // Handler storage: channel -> set of handlers (supports multiple subscribers per channel)
703
726
  const handlers = new Map();
@@ -746,7 +769,7 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
746
769
  const sendAuthenticate = () => {
747
770
  if (!session || ws?.readyState !== WebSocket.OPEN)
748
771
  return;
749
- const timestamp = Date.now();
772
+ const timestamp = serverClock.now();
750
773
  const signature = signMessage(hexToBytes(session.privateKey), composeWsSigningString(session.publicKey, timestamp));
751
774
  pendingAuthSessions.push(session);
752
775
  ws.send(JSON.stringify({
@@ -881,6 +904,7 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
881
904
  }
882
905
  if (msg.type === "Authenticated") {
883
906
  confirmedSession = pendingAuthSessions.shift();
907
+ skewAuthRetries = 0;
884
908
  return;
885
909
  }
886
910
  if (!msg.channel)
@@ -1000,7 +1024,13 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
1000
1024
  // (e.g. re-authenticating from onError): the fresh session is what
1001
1025
  // the reconnect uses.
1002
1026
  const authError = lastAuthError;
1003
- const authLoss = authError !== null && authError.session !== undefined && (authError.code === "AUTH_FAILED" || event.code === 1008);
1027
+ // A handshake stamped outside the server's window is a clock
1028
+ // problem, not a dead session: reconnect and re-sign, a bounded
1029
+ // number of times, instead of discarding the credentials.
1030
+ const skewRetry = autoReconnect && authError?.code === "AUTH_FAILED" && SKEW_REJECTION.test(authError.message) && skewAuthRetries < MAX_SKEW_AUTH_RETRIES;
1031
+ if (skewRetry)
1032
+ skewAuthRetries++;
1033
+ const authLoss = !skewRetry && authError !== null && authError.session !== undefined && (authError.code === "AUTH_FAILED" || event.code === 1008);
1004
1034
  const terminalAuthLoss = authLoss && session === authError.session;
1005
1035
  if (terminalAuthLoss) {
1006
1036
  session = undefined;
@@ -1665,6 +1695,7 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
1665
1695
  getStatus,
1666
1696
  setSessionKeypair: (credentials) => {
1667
1697
  session = credentials ?? undefined;
1698
+ skewAuthRetries = 0;
1668
1699
  // If already connected, (re)send the signed handshake so the connection
1669
1700
  // picks up the new session without tearing down. If not yet connected,
1670
1701
  // onopen will send it. Clearing the session leaves the connection up for
@@ -1,27 +1,13 @@
1
1
  /**
2
- * Whitelist API Implementation
3
- *
4
- * Public (unauthenticated) whitelist/waitlist application submission. The
5
- * server validates and de-duplicates by wallet address and email, creating an
6
- * inactive user pending manual approval. This is an onboarding endpoint, not a
7
- * trading operation.
8
- *
9
- * @example
10
- * ```typescript
11
- * const { message, userId } = await sdk.whitelist.submit({
12
- * walletAddress: "0x...",
13
- * email: "user@example.com",
14
- * });
15
- * ```
2
+ * Retained client for the retired public whitelist endpoint.
3
+ * Submissions return HTTP 403 FORBIDDEN without changing applicant data.
16
4
  */
17
5
  import type { SubmitWhitelistRequest, SubmitWhitelistResponse, WhitelistAPI } from "@0xmonaco/types";
18
6
  import { BaseAPI } from "../base";
19
7
  export declare class WhitelistAPIImpl extends BaseAPI implements WhitelistAPI {
20
8
  /**
21
- * Submits a whitelist (waitlist) application. Public — no auth required.
22
- *
23
- * @param body - Applicant details
24
- * @returns Promise resolving to the status message and created user id
9
+ * @deprecated Public whitelist applications are closed. The server returns HTTP 403.
10
+ * @param body - Legacy applicant details; the request shape is retained for compatibility.
25
11
  */
26
12
  submit(body: SubmitWhitelistRequest): Promise<SubmitWhitelistResponse>;
27
13
  }
@@ -1,27 +1,13 @@
1
1
  /**
2
- * Whitelist API Implementation
3
- *
4
- * Public (unauthenticated) whitelist/waitlist application submission. The
5
- * server validates and de-duplicates by wallet address and email, creating an
6
- * inactive user pending manual approval. This is an onboarding endpoint, not a
7
- * trading operation.
8
- *
9
- * @example
10
- * ```typescript
11
- * const { message, userId } = await sdk.whitelist.submit({
12
- * walletAddress: "0x...",
13
- * email: "user@example.com",
14
- * });
15
- * ```
2
+ * Retained client for the retired public whitelist endpoint.
3
+ * Submissions return HTTP 403 FORBIDDEN without changing applicant data.
16
4
  */
17
5
  import { BaseAPI } from "../base";
18
6
  import { perpRoutes } from "../perp/routes";
19
7
  export class WhitelistAPIImpl extends BaseAPI {
20
8
  /**
21
- * Submits a whitelist (waitlist) application. Public — no auth required.
22
- *
23
- * @param body - Applicant details
24
- * @returns Promise resolving to the status message and created user id
9
+ * @deprecated Public whitelist applications are closed. The server returns HTTP 403.
10
+ * @param body - Legacy applicant details; the request shape is retained for compatibility.
25
11
  */
26
12
  async submit(body) {
27
13
  return await this.makePublicRequest(perpRoutes.whitelist.submit(), {
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.66",
3
+ "version": "1.0.69",
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.66",
23
- "@0xmonaco/types": "1.0.66",
22
+ "@0xmonaco/contracts": "1.0.69",
23
+ "@0xmonaco/types": "1.0.69",
24
24
  "@noble/curves": "^1.9.1",
25
25
  "@noble/hashes": "^2.4.0",
26
26
  "http-status-codes": "^2.3.0"