@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.
- package/dist/api/base.d.ts +5 -0
- package/dist/api/base.js +26 -111
- package/dist/api/perp/routes.d.ts +2 -0
- package/dist/api/positions/api.js +10 -0
- package/dist/api/trading/api.d.ts +32 -14
- package/dist/api/trading/api.js +70 -19
- package/dist/api/websocket/types.d.ts +1 -1
- package/dist/api/websocket/websocket.d.ts +6 -1
- package/dist/api/websocket/websocket.js +124 -5
- package/dist/sdk.js +4 -1
- package/dist/utils/http-date.d.ts +14 -0
- package/dist/utils/http-date.js +106 -0
- package/dist/utils/server-clock.d.ts +38 -0
- package/dist/utils/server-clock.js +67 -0
- package/package.json +3 -3
package/dist/api/base.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
...
|
|
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(
|
|
359
|
+
return buildHttpSignatureHeaders(this.sessionKeypair, method, url.pathname + url.search, utf8ToBytes(options.body ?? ""), Math.floor(this.serverClock.now() / 1000));
|
|
445
360
|
}
|
|
446
|
-
const timestampMs =
|
|
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
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
*
|
|
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
|
|
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.
|
|
552
|
+
* console.log(`Found ${active.orders.length} active TWAP orders`);
|
|
535
553
|
* ```
|
|
536
554
|
*/
|
|
537
555
|
listTwapOrders(params?: ListTwapOrdersParams): Promise<ListTwapOrdersResponse>;
|
package/dist/api/trading/api.js
CHANGED
|
@@ -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
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
810
|
-
*
|
|
811
|
-
*
|
|
812
|
-
*
|
|
813
|
-
*
|
|
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
|
-
*
|
|
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.
|
|
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
|
|
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 =
|
|
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
|
-
//
|
|
835
|
-
//
|
|
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
|
-
|
|
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.
|
|
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.
|
|
23
|
-
"@0xmonaco/types": "1.0.
|
|
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"
|