@0xmonaco/core 1.0.54 → 1.0.56

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.
@@ -20,7 +20,7 @@
20
20
  * }
21
21
  * ```
22
22
  */
23
- import type { SessionCredentials } from "@0xmonaco/types";
23
+ import type { RequestSigning, SessionCredentials } from "@0xmonaco/types";
24
24
  import { type SessionKeypair } from "../crypto/session";
25
25
  export interface RetryOptions {
26
26
  maxRetries?: number;
@@ -32,6 +32,9 @@ export declare abstract class BaseAPI {
32
32
  protected sessionKeypair?: SessionKeypair;
33
33
  /** Application secret key (`sk_...`) sent in the `x-server-key` header for backend-authenticated requests. */
34
34
  protected serverKey?: string;
35
+ private requestSigning;
36
+ /** Opt in only after the target server supports RFC 9421. No automatic downgrade. */
37
+ setRequestSigning(protocol: RequestSigning): void;
35
38
  protected retryOptions: Required<RetryOptions>;
36
39
  /**
37
40
  * Creates a new BaseAPI instance.
@@ -108,10 +111,11 @@ export declare abstract class BaseAPI {
108
111
  /**
109
112
  * Compute the per-request ed25519 signature headers.
110
113
  *
111
- * The signed payload is `METHOD\nPATH_WITH_QUERY\nTIMESTAMP_MS\nSHA256_BODY_HEX`,
114
+ * Legacy signing uses `METHOD\nPATH_WITH_QUERY\nTIMESTAMP_MS\nSHA256_BODY_HEX`,
112
115
  * matching the server (`handlers::auth::compose_signing_string`). `endpoint`
113
116
  * is the path-with-query exactly as sent; the body hash covers the raw bytes
114
- * so a request can't be replayed with a different body.
117
+ * so a request cannot be reused with a different body. RFC 9421 is opt-in
118
+ * and covers the final URL path/query plus Content-Digest.
115
119
  */
116
120
  private buildSignatureHeaders;
117
121
  /**
package/dist/api/base.js CHANGED
@@ -22,6 +22,7 @@
22
22
  */
23
23
  import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils";
24
24
  import { StatusCodes } from "http-status-codes";
25
+ import { buildHttpSignatureHeaders } from "../crypto/http-signatures";
25
26
  import { composeSigningString, keypairFromHex, sha256Hex, signMessage } from "../crypto/session";
26
27
  import { APIError } from "../errors";
27
28
  /**
@@ -146,6 +147,13 @@ export class BaseAPI {
146
147
  sessionKeypair;
147
148
  /** Application secret key (`sk_...`) sent in the `x-server-key` header for backend-authenticated requests. */
148
149
  serverKey;
150
+ requestSigning = "legacy";
151
+ /** Opt in only after the target server supports RFC 9421. No automatic downgrade. */
152
+ setRequestSigning(protocol) {
153
+ if (protocol !== "legacy" && protocol !== "rfc9421")
154
+ throw new Error("Unsupported request signing protocol");
155
+ this.requestSigning = protocol;
156
+ }
149
157
  retryOptions;
150
158
  /**
151
159
  * Creates a new BaseAPI instance.
@@ -389,22 +397,32 @@ export class BaseAPI {
389
397
  const url = `${this.apiUrl}${endpoint}`;
390
398
  const requestBody = this.parseRequestBody(options.body);
391
399
  const signatureHeaders = this.buildSignatureHeaders(endpoint, options);
400
+ let extraHeaders = options.headers;
401
+ if (this.requestSigning === "rfc9421") {
402
+ const normalized = {};
403
+ new Headers(options.headers).forEach((value, name) => {
404
+ normalized[name] = value;
405
+ });
406
+ extraHeaders = normalized;
407
+ }
392
408
  return this.executeRequest(url, endpoint, {
393
409
  ...options,
410
+ ...(this.requestSigning === "rfc9421" ? { redirect: "error" } : {}),
394
411
  headers: {
395
412
  "Content-Type": "application/json",
413
+ ...extraHeaders,
396
414
  ...signatureHeaders,
397
- ...options.headers,
398
415
  },
399
416
  }, requestBody);
400
417
  }
401
418
  /**
402
419
  * Compute the per-request ed25519 signature headers.
403
420
  *
404
- * The signed payload is `METHOD\nPATH_WITH_QUERY\nTIMESTAMP_MS\nSHA256_BODY_HEX`,
421
+ * Legacy signing uses `METHOD\nPATH_WITH_QUERY\nTIMESTAMP_MS\nSHA256_BODY_HEX`,
405
422
  * matching the server (`handlers::auth::compose_signing_string`). `endpoint`
406
423
  * is the path-with-query exactly as sent; the body hash covers the raw bytes
407
- * so a request can't be replayed with a different body.
424
+ * so a request cannot be reused with a different body. RFC 9421 is opt-in
425
+ * and covers the final URL path/query plus Content-Digest.
408
426
  */
409
427
  buildSignatureHeaders(endpoint, options) {
410
428
  if (!this.sessionKeypair) {
@@ -414,6 +432,17 @@ export class BaseAPI {
414
432
  });
415
433
  }
416
434
  const method = (options.method ?? "GET").toUpperCase();
435
+ if (this.requestSigning === "rfc9421") {
436
+ if (options.body != null && typeof options.body !== "string")
437
+ throw new Error("Signed requests require a string body");
438
+ const headers = new Headers(options.headers);
439
+ for (const name of ["signature", "signature-input", "content-digest", "x-monaco-publickey", "x-monaco-timestamp", "x-monaco-signature"]) {
440
+ if (headers.has(name))
441
+ throw new Error("Signature headers are managed by the SDK");
442
+ }
443
+ 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));
445
+ }
417
446
  const timestampMs = Date.now();
418
447
  const bodyBytes = typeof options.body === "string" ? utf8ToBytes(options.body) : new Uint8Array(0);
419
448
  const bodyHash = sha256Hex(bodyBytes);
@@ -164,6 +164,7 @@ export declare const perpRoutes: {
164
164
  };
165
165
  readonly sweeper: {
166
166
  readonly register: () => string;
167
+ readonly chains: () => string;
167
168
  };
168
169
  readonly whitelist: {
169
170
  readonly submit: () => string;
@@ -89,6 +89,7 @@ export const perpRoutes = {
89
89
  },
90
90
  sweeper: {
91
91
  register: () => `${API_V1}/sweeper/register`,
92
+ chains: () => `${API_V1}/sweeper/chains`,
92
93
  },
93
94
  whitelist: {
94
95
  submit: () => `${API_V1}/whitelist`,
@@ -22,7 +22,7 @@
22
22
  * const margin = await sdk.sweeper.register({ depositTarget: "margin" });
23
23
  * ```
24
24
  */
25
- import type { RegisterSweeperRequest, RegisterSweeperResponse, SweeperAPI } from "@0xmonaco/types";
25
+ import type { GetSweeperChainsResponse, RegisterSweeperRequest, RegisterSweeperResponse, SweeperAPI } from "@0xmonaco/types";
26
26
  import { BaseAPI } from "../base";
27
27
  export declare class SweeperAPIImpl extends BaseAPI implements SweeperAPI {
28
28
  /**
@@ -42,10 +42,25 @@ export declare class SweeperAPIImpl extends BaseAPI implements SweeperAPI {
42
42
  * rather than a guarantee, since a deposit the backend cannot route to
43
43
  * collateral is credited to the spot wallet instead.
44
44
  *
45
+ * The deposit address is identical on every supported chain and one
46
+ * registration is watched on all of them (see {@link getChains} for the
47
+ * set), so there is nothing chain-specific to pass.
48
+ *
45
49
  * @param body - Optionally the destination ledger; everything else comes
46
50
  * from the session
47
51
  * @returns Promise resolving to the deposit address and the initial-check
48
52
  * state
49
53
  */
50
54
  register(body?: RegisterSweeperRequest): Promise<RegisterSweeperResponse>;
55
+ /**
56
+ * The chains and assets the sweeper watches: where a registered deposit
57
+ * address accepts deposits, which tokens are swept on each chain, and each
58
+ * token's minimum sweep amount in raw base units.
59
+ *
60
+ * Public — no session needed. The set changes only with operator
61
+ * configuration, so it is safe to cache briefly.
62
+ *
63
+ * @returns Promise resolving to the supported chain/asset listing
64
+ */
65
+ getChains(): Promise<GetSweeperChainsResponse>;
51
66
  }
@@ -69,6 +69,10 @@ export class SweeperAPIImpl extends BaseAPI {
69
69
  * rather than a guarantee, since a deposit the backend cannot route to
70
70
  * collateral is credited to the spot wallet instead.
71
71
  *
72
+ * The deposit address is identical on every supported chain and one
73
+ * registration is watched on all of them (see {@link getChains} for the
74
+ * set), so there is nothing chain-specific to pass.
75
+ *
72
76
  * @param body - Optionally the destination ledger; everything else comes
73
77
  * from the session
74
78
  * @returns Promise resolving to the deposit address and the initial-check
@@ -82,4 +86,19 @@ export class SweeperAPIImpl extends BaseAPI {
82
86
  body: JSON.stringify(wireBody),
83
87
  });
84
88
  }
89
+ /**
90
+ * The chains and assets the sweeper watches: where a registered deposit
91
+ * address accepts deposits, which tokens are swept on each chain, and each
92
+ * token's minimum sweep amount in raw base units.
93
+ *
94
+ * Public — no session needed. The set changes only with operator
95
+ * configuration, so it is safe to cache briefly.
96
+ *
97
+ * @returns Promise resolving to the supported chain/asset listing
98
+ */
99
+ async getChains() {
100
+ return await this.makePublicRequest(perpRoutes.sweeper.chains(), {
101
+ method: "GET",
102
+ });
103
+ }
85
104
  }
@@ -82,6 +82,12 @@ function readOptionalNumber(source, field) {
82
82
  }
83
83
  return value;
84
84
  }
85
+ function readOptionalSequence(source, field) {
86
+ const value = source[field];
87
+ if (value === undefined || value === null)
88
+ return undefined;
89
+ return readSequence(source, field);
90
+ }
85
91
  function readStringArray(source, field) {
86
92
  const value = source[field];
87
93
  if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
@@ -492,6 +498,7 @@ function parseUserBalanceEventData(raw) {
492
498
  lockedRaw: readString(data, "lockedRaw"),
493
499
  total: readString(data, "total"),
494
500
  totalRaw: readString(data, "totalRaw"),
501
+ version: readOptionalSequence(data, "version"),
495
502
  // Free-form on the wire and read leniently: a snapshot row carries the
496
503
  // `snapshot` literal, a live frame the mutation cause, and a reason added
497
504
  // server-side after this SDK shipped must not drop the row (0XM-2627).
@@ -500,6 +507,20 @@ function parseUserBalanceEventData(raw) {
500
507
  updatedAt: readString(data, "updatedAt"),
501
508
  };
502
509
  }
510
+ /**
511
+ * Parse one row from a `balances` channel snapshot.
512
+ *
513
+ * `version` is read leniently, for the same reason `reason` is: a pre-feature
514
+ * server sends no version at all, and rejecting the row would drop the entire
515
+ * subscribe-time baseline for a client whose SDK is ahead of the server it is
516
+ * talking to. `UserBalanceEventData.version` is optional precisely so an absent
517
+ * value is representable, and consumers already treat it as the unranked
518
+ * sentinel. A version that IS present is still validated — `readSequence`
519
+ * rejects negative and unsafe values.
520
+ */
521
+ function parseBalanceSnapshotItem(raw) {
522
+ return parseUserBalanceEventData(raw);
523
+ }
503
524
  /**
504
525
  * Parse one resting order from an `orders` channel snapshot.
505
526
  *
@@ -847,9 +868,14 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
847
868
  sendAuthenticate();
848
869
  resubscribeAll();
849
870
  options.onStatusChange?.("connected");
850
- // Send auth/subscribe requests before asking the application to
851
- // reconcile. This callback does not wait for acknowledgements or
852
- // snapshots, and the SDK does not perform REST recovery itself.
871
+ // Resync-join order: the live stream resumes first (auth +
872
+ // subscribe above), then the application is asked to refetch
873
+ // snapshots and reconcile against each channel's authoritative
874
+ // identity/version. Balance ordering uses the durable row version,
875
+ // never `updatedAt`, arrival time, or receiver backlog.
876
+ //
877
+ // This callback waits for neither acknowledgements nor snapshots,
878
+ // and the SDK performs no REST recovery itself.
853
879
  if (pendingResyncCode !== null) {
854
880
  const code = pendingResyncCode;
855
881
  pendingResyncCode = null;
@@ -1201,7 +1227,7 @@ export function createMonacoWebSocket(baseUrl, options = {}) {
1201
1227
  catch (err) {
1202
1228
  console.error("WebSocket: Error processing balance event", err);
1203
1229
  }
1204
- }, onSnapshot && snapshotFrameHandler("balances", parseUserBalanceEventData, onSnapshot));
1230
+ }, onSnapshot && snapshotFrameHandler("balances", parseBalanceSnapshotItem, onSnapshot));
1205
1231
  };
1206
1232
  const subscribeConditionalOrders = (handler, tradingPairId, onSnapshot) => {
1207
1233
  if (!handler) {
@@ -35,6 +35,7 @@ export declare const COVERED: {
35
35
  get_market_stats: string;
36
36
  get_my_fee_tier: string;
37
37
  get_pulse: string;
38
+ get_sweeper_chains: string;
38
39
  get_my_trader_code: string;
39
40
  get_rewards_balance: string;
40
41
  get_trader_code_info: string;
package/dist/coverage.js CHANGED
@@ -35,6 +35,7 @@ export const COVERED = {
35
35
  get_market_stats: "getMarketStats",
36
36
  get_my_fee_tier: "getMyFeeTier",
37
37
  get_pulse: "pulse.getPulse",
38
+ get_sweeper_chains: "sweeper.getChains",
38
39
  get_my_trader_code: "pitpass.getMyTraderCode",
39
40
  get_rewards_balance: "pitpass.getRewardsBalance",
40
41
  get_trader_code_info: "pitpass.getTraderCodeInfo",
@@ -0,0 +1,3 @@
1
+ import type { SessionKeypair } from "./session";
2
+ /** Sign an already URL-encoded path/query and the exact bytes that will be sent. */
3
+ export declare function buildHttpSignatureHeaders(keypair: SessionKeypair, method: string, target: string, body: Uint8Array, created: number): Record<string, string>;
@@ -0,0 +1,26 @@
1
+ /** Monaco's RFC 9421 Ed25519 profile; key registration remains wallet-authorized. */
2
+ import { ed25519 } from "@noble/curves/ed25519";
3
+ import { sha256 } from "@noble/hashes/sha2";
4
+ import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils";
5
+ function base64(bytes) {
6
+ return btoa(String.fromCharCode(...bytes));
7
+ }
8
+ /** Sign an already URL-encoded path/query and the exact bytes that will be sent. */
9
+ export function buildHttpSignatureHeaders(keypair, method, target, body, created) {
10
+ if (!Number.isSafeInteger(created) || created < 0 || created > 999_999_999_999_999)
11
+ throw new Error("Invalid signature creation time");
12
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: reject control characters before constructing signature lines
13
+ if (!/^[A-Z]+$/.test(method) || !target.startsWith("/") || /[\s#\u0000-\u001f\u007f]/.test(target))
14
+ throw new Error("Invalid signature request target");
15
+ const queryIndex = target.indexOf("?");
16
+ const path = queryIndex < 0 ? target : target.slice(0, queryIndex);
17
+ const query = queryIndex < 0 ? "?" : target.slice(queryIndex);
18
+ const digest = `sha-256=:${base64(sha256(body))}:`;
19
+ const params = `("@method" "@path" "@query" "content-digest");created=${created};keyid="${bytesToHex(keypair.publicKey)}";alg="ed25519"`;
20
+ const base = `"@method": ${method}\n"@path": ${path}\n"@query": ${query}\n"content-digest": ${digest}\n"@signature-params": ${params}`;
21
+ return {
22
+ "Signature-Input": `monaco=${params}`,
23
+ Signature: `monaco=:${base64(ed25519.sign(utf8ToBytes(base), keypair.privateKey))}:`,
24
+ "Content-Digest": digest,
25
+ };
26
+ }
package/dist/sdk.js CHANGED
@@ -181,26 +181,30 @@ export class MonacoSDKImpl {
181
181
  this.walletClient = cfg.walletClient;
182
182
  }
183
183
  // Instantiate APIs (wallet-dependent APIs will be initialized lazily or error if wallet not set)
184
- this.applications = new ApplicationsAPIImpl(apiUrl);
185
- this.market = new MarketAPIImpl(apiUrl);
186
- this.marginAccounts = new MarginAccountsAPIImpl(apiUrl);
187
- this.positions = new PositionsAPIImpl(apiUrl);
188
- this.portfolio = new PortfolioAPIImpl(apiUrl);
189
- this.subAccounts = new SubAccountsAPIImpl(apiUrl);
190
- this.faucet = new FaucetAPIImpl(apiUrl);
191
- this.sweeper = new SweeperAPIImpl(apiUrl);
192
- this.whitelist = new WhitelistAPIImpl(apiUrl);
193
- this.pulse = new PulseAPIImpl(apiUrl);
194
- this.auth = new AuthAPIImpl(this.walletClient, this.chain, apiUrl);
195
- this.delegatedAgents = new DelegatedAgentsAPIImpl(apiUrl);
196
- this.fees = new FeesAPIImpl(apiUrl);
197
- this.pitpass = new PitpassAPIImpl(apiUrl);
198
- this.profile = new ProfileAPIImpl(apiUrl);
199
- this.vault = new VaultAPIImpl(this.publicClient, this.walletClient, this.chain, this.applications, this.profile, apiUrl);
200
- this.withdrawals = new WithdrawalsAPIImpl(apiUrl);
201
- this.trading = new TradingAPIImpl(apiUrl);
202
- this.orderbook = new OrderbookAPIImpl(apiUrl);
203
- this.trades = new TradesAPIImpl(apiUrl);
184
+ const configureSigning = (api) => {
185
+ api.setRequestSigning(cfg.requestSigning ?? "legacy");
186
+ return api;
187
+ };
188
+ this.applications = configureSigning(new ApplicationsAPIImpl(apiUrl));
189
+ this.market = configureSigning(new MarketAPIImpl(apiUrl));
190
+ this.marginAccounts = configureSigning(new MarginAccountsAPIImpl(apiUrl));
191
+ this.positions = configureSigning(new PositionsAPIImpl(apiUrl));
192
+ this.portfolio = configureSigning(new PortfolioAPIImpl(apiUrl));
193
+ this.subAccounts = configureSigning(new SubAccountsAPIImpl(apiUrl));
194
+ this.faucet = configureSigning(new FaucetAPIImpl(apiUrl));
195
+ this.sweeper = configureSigning(new SweeperAPIImpl(apiUrl));
196
+ this.whitelist = configureSigning(new WhitelistAPIImpl(apiUrl));
197
+ this.pulse = configureSigning(new PulseAPIImpl(apiUrl));
198
+ this.auth = configureSigning(new AuthAPIImpl(this.walletClient, this.chain, apiUrl));
199
+ this.delegatedAgents = configureSigning(new DelegatedAgentsAPIImpl(apiUrl));
200
+ this.fees = configureSigning(new FeesAPIImpl(apiUrl));
201
+ this.pitpass = configureSigning(new PitpassAPIImpl(apiUrl));
202
+ this.profile = configureSigning(new ProfileAPIImpl(apiUrl));
203
+ this.vault = configureSigning(new VaultAPIImpl(this.publicClient, this.walletClient, this.chain, this.applications, this.profile, apiUrl));
204
+ this.withdrawals = configureSigning(new WithdrawalsAPIImpl(apiUrl));
205
+ this.trading = configureSigning(new TradingAPIImpl(apiUrl));
206
+ this.orderbook = configureSigning(new OrderbookAPIImpl(apiUrl));
207
+ this.trades = configureSigning(new TradesAPIImpl(apiUrl));
204
208
  // Thread the reconnect/resync knobs and callbacks from SDK config into the
205
209
  // internally-built ws client. The session keypair is not passed here — it
206
210
  // is propagated on login via propagateSession/setSessionKeypair.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0xmonaco/core",
3
- "version": "1.0.54",
3
+ "version": "1.0.56",
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.54",
23
- "@0xmonaco/types": "1.0.54",
22
+ "@0xmonaco/contracts": "1.0.56",
23
+ "@0xmonaco/types": "1.0.56",
24
24
  "@noble/curves": "^1.9.1",
25
25
  "@noble/hashes": "^1.8.0",
26
26
  "http-status-codes": "^2.3.0"