@acta-markets/ts-sdk 0.0.4-beta → 0.0.6-beta

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/README.md CHANGED
@@ -88,11 +88,14 @@ client.ws!.on("positions", (positions) => {
88
88
  console.log("first expiry", positions[0]?.expiry_ts);
89
89
  });
90
90
 
91
- client.ws!.getMarkets();
91
+ const marketsRequestId = client.ws!.getMarkets();
92
+ console.log("GetMarkets request_id:", marketsRequestId);
92
93
  // positions require auth:
93
94
  // client.ws!.getPositions();
94
95
  ```
95
96
 
97
+ Most WS read/query methods now return the generated `request_id` so UI code can correlate responses (`data.request_id`) without building custom request id plumbing.
98
+
96
99
  ### RFQ → accept quote → sign sponsored tx (wallet UX)
97
100
 
98
101
  ```ts
@@ -184,6 +184,8 @@ describe("events parsing", () => {
184
184
  Buffer.from(pk(3)),
185
185
  u64(777),
186
186
  Buffer.from([1]), // is_put
187
+ Buffer.from([6]), // underlying_decimals
188
+ Buffer.from([9]), // quote_decimals
187
189
  ]));
188
190
  const d = (0, events_1.decodeActaEventLine)(line);
189
191
  expect(d.kind).toBe(events_1.EventKind.CreateMarket);
@@ -194,6 +196,8 @@ describe("events parsing", () => {
194
196
  expect(d.event.quoteMint).toBe(addr(3));
195
197
  expect(d.event.expiryTs).toBe(777n);
196
198
  expect(d.event.isPut).toBe(1);
199
+ expect(d.event.underlyingDecimals).toBe(6);
200
+ expect(d.event.quoteDecimals).toBe(9);
197
201
  }
198
202
  }
199
203
  // 9: FinalizeMarket
@@ -215,6 +219,7 @@ describe("events parsing", () => {
215
219
  u64(222),
216
220
  Buffer.from([1]),
217
221
  Buffer.from(pk(3)),
222
+ Buffer.from(pk(9)),
218
223
  ]));
219
224
  const d = (0, events_1.decodeActaEventLine)(line);
220
225
  expect(d.kind).toBe(events_1.EventKind.CreateOracle);
@@ -225,6 +230,7 @@ describe("events parsing", () => {
225
230
  expect(d.event.expiryTs).toBe(222n);
226
231
  expect(d.event.oracleType).toBe(1);
227
232
  expect(d.event.authority).toBe(addr(3));
233
+ expect(Buffer.from(d.event.feedId)).toEqual(Buffer.from(pk(9)));
228
234
  }
229
235
  }
230
236
  // 15: UpdateOraclePrice
@@ -89,6 +89,8 @@ function getActaEventEncoder() {
89
89
  ["quoteMint", (0, kit_1.getAddressEncoder)()],
90
90
  ["expiryTs", (0, kit_1.getU64Encoder)()],
91
91
  ["isPut", (0, kit_1.getU8Encoder)()],
92
+ ["underlyingDecimals", (0, kit_1.getU8Encoder)()],
93
+ ["quoteDecimals", (0, kit_1.getU8Encoder)()],
92
94
  ]),
93
95
  ],
94
96
  [
@@ -142,6 +144,7 @@ function getActaEventEncoder() {
142
144
  ["expiryTs", (0, kit_1.getU64Encoder)()],
143
145
  ["oracleType", (0, kit_1.getU8Encoder)()],
144
146
  ["authority", (0, kit_1.getAddressEncoder)()],
147
+ ["feedId", (0, kit_1.fixEncoderSize)((0, kit_1.getBytesEncoder)(), 32)],
145
148
  ]),
146
149
  ],
147
150
  [
@@ -247,6 +250,8 @@ function getActaEventDecoder() {
247
250
  ["quoteMint", (0, kit_1.getAddressDecoder)()],
248
251
  ["expiryTs", (0, kit_1.getU64Decoder)()],
249
252
  ["isPut", (0, kit_1.getU8Decoder)()],
253
+ ["underlyingDecimals", (0, kit_1.getU8Decoder)()],
254
+ ["quoteDecimals", (0, kit_1.getU8Decoder)()],
250
255
  ]),
251
256
  ],
252
257
  [
@@ -300,6 +305,7 @@ function getActaEventDecoder() {
300
305
  ["expiryTs", (0, kit_1.getU64Decoder)()],
301
306
  ["oracleType", (0, kit_1.getU8Decoder)()],
302
307
  ["authority", (0, kit_1.getAddressDecoder)()],
308
+ ["feedId", (0, kit_1.fixDecoderSize)((0, kit_1.getBytesDecoder)(), 32)],
303
309
  ]),
304
310
  ],
305
311
  [
@@ -1883,6 +1883,14 @@
1883
1883
  {
1884
1884
  "name": "is_put",
1885
1885
  "type": "u8"
1886
+ },
1887
+ {
1888
+ "name": "underlying_decimals",
1889
+ "type": "u8"
1890
+ },
1891
+ {
1892
+ "name": "quote_decimals",
1893
+ "type": "u8"
1886
1894
  }
1887
1895
  ]
1888
1896
  },
@@ -2030,6 +2038,15 @@
2030
2038
  {
2031
2039
  "name": "authority",
2032
2040
  "type": "publicKey"
2041
+ },
2042
+ {
2043
+ "name": "feed_id",
2044
+ "type": {
2045
+ "array": [
2046
+ "u8",
2047
+ 32
2048
+ ]
2049
+ }
2033
2050
  }
2034
2051
  ]
2035
2052
  },
@@ -1,4 +1,4 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ACTA_IDL_SHA256 = void 0;
4
- exports.ACTA_IDL_SHA256 = "1466fbb647b4c33af315eefbb61e5e572ce14f22f16d050ac2894f8e017aaf66";
4
+ exports.ACTA_IDL_SHA256 = "704677f7071ecbe98f442fb62468d882329b906950a707fc47aad0f38683636f";
@@ -73,6 +73,21 @@ function getCloseInfo(ev) {
73
73
  reason: typeof rec.reason === "string" ? rec.reason : undefined,
74
74
  };
75
75
  }
76
+ function generateRequestId() {
77
+ const cryptoApi = globalThis.crypto;
78
+ if (cryptoApi?.randomUUID) {
79
+ return cryptoApi.randomUUID();
80
+ }
81
+ if (cryptoApi?.getRandomValues) {
82
+ const bytes = cryptoApi.getRandomValues(new Uint8Array(16));
83
+ // RFC4122 v4 bits: version=0100, variant=10xx.
84
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
85
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
86
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
87
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
88
+ }
89
+ return `req-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
90
+ }
76
91
  class ActaWsClient extends TypedEventEmitter {
77
92
  ws = null;
78
93
  options;
@@ -82,6 +97,7 @@ class ActaWsClient extends TypedEventEmitter {
82
97
  pendingResumeSessionId = null;
83
98
  connectionState = "disconnected";
84
99
  sessionId = null;
100
+ lastAuthSessionId = null;
85
101
  helloSent = false;
86
102
  welcomeReceived = false;
87
103
  pendingMessages = [];
@@ -92,6 +108,7 @@ class ActaWsClient extends TypedEventEmitter {
92
108
  subscribedChannels = new Set(["rfqs"]);
93
109
  subscribedMarkets = new Set();
94
110
  hasMarketScope = false;
111
+ marketDescriptorsByMarket = new Map();
95
112
  state = {
96
113
  stats: null,
97
114
  activeRfqs: new Map(),
@@ -124,6 +141,7 @@ class ActaWsClient extends TypedEventEmitter {
124
141
  this.authProvider = null;
125
142
  this.authRequested = false;
126
143
  this.pendingResumeSessionId = null;
144
+ this.lastAuthSessionId = null;
127
145
  this.shouldReconnect = this.options.autoReconnect;
128
146
  this.doConnect();
129
147
  }
@@ -179,6 +197,13 @@ class ActaWsClient extends TypedEventEmitter {
179
197
  }
180
198
  async createRfq(request) {
181
199
  this.ensureAuthenticated();
200
+ (0, wirePolicy_1.assertWsU64Safe)(request.strike, "strike");
201
+ (0, wirePolicy_1.assertWsU64Safe)(request.quantity, "quantity");
202
+ const marketDescriptor = this.marketDescriptorsByMarket.get(request.market);
203
+ if (!marketDescriptor) {
204
+ throw new Error(`Missing market descriptor for market=${request.market}; call getMarketDescriptors() before createRfq().`);
205
+ }
206
+ (0, wirePolicy_1.validateQuantityBySizeRule)(request.quantity, marketDescriptor.size_rule);
182
207
  this.send({
183
208
  type: "RfqRequest",
184
209
  data: {
@@ -223,47 +248,89 @@ class ActaWsClient extends TypedEventEmitter {
223
248
  }
224
249
  getPositions() {
225
250
  this.ensureAuthenticated();
226
- this.send({ type: "GetPositions" });
251
+ const requestId = this.nextRequestId();
252
+ this.send({
253
+ type: "GetPositions",
254
+ data: { request_id: requestId },
255
+ });
256
+ return requestId;
227
257
  }
228
258
  getMarkets() {
229
- this.send({ type: "GetMarkets" });
259
+ const requestId = this.nextRequestId();
260
+ this.send({
261
+ type: "GetMarkets",
262
+ data: { request_id: requestId },
263
+ });
264
+ return requestId;
230
265
  }
231
266
  getMarketDescriptors(args) {
267
+ const requestId = this.nextRequestId();
232
268
  const data = {
269
+ request_id: requestId,
233
270
  active_only: args?.active_only ?? true,
234
271
  };
235
272
  this.send({ type: "GetMarketDescriptors", data });
273
+ return requestId;
236
274
  }
237
275
  getExpiries(args) {
276
+ const requestId = this.nextRequestId();
238
277
  this.send({
239
278
  type: "GetExpiries",
240
279
  data: {
280
+ request_id: requestId,
241
281
  underlying_mint: args?.underlying_mint,
242
282
  quote_mint: args?.quote_mint,
243
283
  is_put: args?.is_put ?? null,
244
284
  },
245
285
  });
286
+ return requestId;
246
287
  }
247
288
  getTokens(args) {
289
+ const requestId = this.nextRequestId();
248
290
  const data = {
291
+ request_id: requestId,
249
292
  active_only: args?.active_only ?? true,
250
293
  };
251
294
  this.send({ type: "GetTokens", data });
295
+ return requestId;
252
296
  }
253
297
  getMyActiveRfqs() {
254
298
  this.ensureAuthenticated();
255
- this.send({ type: "GetMyActiveRfqs" });
299
+ const requestId = this.nextRequestId();
300
+ this.send({
301
+ type: "GetMyActiveRfqs",
302
+ data: { request_id: requestId },
303
+ });
304
+ return requestId;
256
305
  }
257
306
  getActiveRfqs() {
258
- this.send({ type: "GetActiveRfqs" });
307
+ const requestId = this.nextRequestId();
308
+ this.send({
309
+ type: "GetActiveRfqs",
310
+ data: { request_id: requestId },
311
+ });
312
+ return requestId;
313
+ }
314
+ logout() {
315
+ this.send({ type: "Logout" });
259
316
  }
260
317
  getOrderStatus(orderIdHex) {
261
318
  this.ensureAuthenticated();
262
- this.send({ type: "GetOrderStatus", data: { order_id: orderIdHex } });
319
+ const requestId = this.nextRequestId();
320
+ this.send({
321
+ type: "GetOrderStatus",
322
+ data: { request_id: requestId, order_id: orderIdHex },
323
+ });
324
+ return requestId;
263
325
  }
264
326
  cancelRfq(rfqId) {
265
327
  this.ensureAuthenticated();
266
- this.send({ type: "CancelRfq", data: { rfq_id: rfqId } });
328
+ const requestId = this.nextRequestId();
329
+ this.send({
330
+ type: "CancelRfq",
331
+ data: { rfq_id: rfqId, request_id: requestId },
332
+ });
333
+ return requestId;
267
334
  }
268
335
  submitQuote(quote) {
269
336
  this.ensureAuthenticated();
@@ -293,7 +360,12 @@ class ActaWsClient extends TypedEventEmitter {
293
360
  }
294
361
  cancelQuote(rfqId) {
295
362
  this.ensureAuthenticated();
296
- this.send({ type: "CancelQuote", data: { rfq_id: rfqId } });
363
+ const requestId = this.nextRequestId();
364
+ this.send({
365
+ type: "CancelQuote",
366
+ data: { rfq_id: rfqId, request_id: requestId },
367
+ });
368
+ return requestId;
297
369
  }
298
370
  subscribe(channels, markets) {
299
371
  this.ensureAuthenticated();
@@ -418,6 +490,14 @@ class ActaWsClient extends TypedEventEmitter {
418
490
  case "AuthError":
419
491
  this.handleAuthError(message.data.reason, message.data.message);
420
492
  break;
493
+ case "LogoutSuccess":
494
+ this.sessionId = null;
495
+ this.pendingResumeSessionId = null;
496
+ this.lastAuthSessionId = null;
497
+ this.startAuthSent = false;
498
+ this.setConnectionState("connecting");
499
+ this.emit("logoutSuccess");
500
+ break;
421
501
  case "Snapshot":
422
502
  this.handleSnapshot(message.data);
423
503
  break;
@@ -428,7 +508,13 @@ class ActaWsClient extends TypedEventEmitter {
428
508
  this.handleMarkets(message.data.markets ?? []);
429
509
  break;
430
510
  case "MarketDescriptors":
431
- this.emit("marketDescriptors", message.data.markets ?? []);
511
+ {
512
+ const marketDescriptors = message.data.markets ?? [];
513
+ for (const marketDescriptor of marketDescriptors) {
514
+ this.marketDescriptorsByMarket.set(marketDescriptor.market.market_pda, marketDescriptor);
515
+ }
516
+ this.emit("marketDescriptors", marketDescriptors);
517
+ }
432
518
  break;
433
519
  case "Expiries":
434
520
  this.emit("expiries", message.data.expiries_ts ?? []);
@@ -587,10 +673,12 @@ class ActaWsClient extends TypedEventEmitter {
587
673
  }
588
674
  /** Taker-only: request current indicative prices for a market + position_type. */
589
675
  getIndicativePrices(req) {
676
+ const requestId = this.nextRequestId();
590
677
  this.send({
591
678
  type: "GetIndicativePrices",
592
- data: req,
679
+ data: { ...req, request_id: requestId },
593
680
  });
681
+ return requestId;
594
682
  }
595
683
  /** Maker-only: respond to an indicative request (unsigned, non-binding). */
596
684
  sendIndicativePricesResponse(resp) {
@@ -619,8 +707,9 @@ class ActaWsClient extends TypedEventEmitter {
619
707
  }
620
708
  }
621
709
  async beginAuthHandshake() {
622
- if (this.pendingResumeSessionId) {
623
- this.sendResumeAuth(this.pendingResumeSessionId);
710
+ const resumeSessionId = this.pendingResumeSessionId ?? this.lastAuthSessionId;
711
+ if (resumeSessionId) {
712
+ this.sendResumeAuth(resumeSessionId);
624
713
  return;
625
714
  }
626
715
  await this.sendStartAuth();
@@ -657,6 +746,9 @@ class ActaWsClient extends TypedEventEmitter {
657
746
  handleAuthSuccess(sessionId, expiresAt) {
658
747
  this.sessionId = sessionId;
659
748
  this.pendingResumeSessionId = null;
749
+ if (expiresAt !== null) {
750
+ this.lastAuthSessionId = sessionId;
751
+ }
660
752
  this.setConnectionState("authenticated");
661
753
  this.emit("authenticated", sessionId, expiresAt);
662
754
  if (this.subscribedChannels.size > 0) {
@@ -668,12 +760,13 @@ class ActaWsClient extends TypedEventEmitter {
668
760
  }
669
761
  }
670
762
  handleAuthError(reason, message) {
671
- this.emit("authError", reason);
763
+ this.emit("authError", reason, message);
672
764
  if (reason === "session_expired" &&
673
765
  this.authRequested &&
674
766
  this.authProvider &&
675
- this.pendingResumeSessionId) {
767
+ (this.pendingResumeSessionId || this.lastAuthSessionId)) {
676
768
  this.pendingResumeSessionId = null;
769
+ this.lastAuthSessionId = null;
677
770
  this.startAuthSent = false;
678
771
  void this.sendStartAuth().catch((err) => {
679
772
  this.emit("error", err);
@@ -789,6 +882,9 @@ class ActaWsClient extends TypedEventEmitter {
789
882
  this.log("Cannot send, WebSocket not open");
790
883
  }
791
884
  }
885
+ nextRequestId() {
886
+ return generateRequestId();
887
+ }
792
888
  ensureAuthenticated() {
793
889
  if (this.connectionState !== "authenticated") {
794
890
  throw new Error("Client is not authenticated");
@@ -883,7 +979,6 @@ class ActaWsClient extends TypedEventEmitter {
883
979
  this.helloSent = false;
884
980
  this.welcomeReceived = false;
885
981
  this.startAuthSent = false;
886
- this.pendingResumeSessionId = null;
887
982
  this.pendingMessages = [];
888
983
  this.setConnectionState("disconnected");
889
984
  }
@@ -13,6 +13,7 @@ const WELCOME_MESSAGE = {
13
13
  enabled_features: [],
14
14
  },
15
15
  };
16
+ const createdClients = [];
16
17
  class MockWebSocket {
17
18
  url;
18
19
  readyState = WS_CONNECTING;
@@ -67,6 +68,7 @@ function makeHarness(overrides = {}) {
67
68
  return currentSocket;
68
69
  },
69
70
  });
71
+ createdClients.push(client);
70
72
  return {
71
73
  client,
72
74
  socket: () => {
@@ -83,6 +85,9 @@ async function flushMicrotasks() {
83
85
  }
84
86
  describe("ActaWsClient", () => {
85
87
  afterEach(() => {
88
+ for (const client of createdClients.splice(0, createdClients.length)) {
89
+ client.disconnect();
90
+ }
86
91
  jest.restoreAllMocks();
87
92
  jest.useRealTimers();
88
93
  });
@@ -169,13 +174,204 @@ describe("ActaWsClient", () => {
169
174
  });
170
175
  it("emits authError reason on AuthError", () => {
171
176
  const { client } = makeHarness();
172
- const reasons = [];
173
- client.on("authError", (reason) => reasons.push(reason));
177
+ const errors = [];
178
+ client.on("authError", (reason, message) => errors.push({ reason, message }));
174
179
  client.handleMessage({
175
180
  type: "AuthError",
176
181
  data: { reason: "invalid_signature", message: "bad signature bytes" },
177
182
  });
178
- expect(reasons).toEqual(["invalid_signature"]);
183
+ expect(errors).toEqual([
184
+ { reason: "invalid_signature", message: "bad signature bytes" },
185
+ ]);
186
+ });
187
+ it("uses last auth session for resume-first reconnect", async () => {
188
+ const { client, socket } = makeHarness();
189
+ const auth = makeAuthProvider("WalletPubkey", "WalletSignature");
190
+ client.connectAndAuthenticate(auth);
191
+ let ws = socket();
192
+ ws.triggerOpen();
193
+ ws.triggerMessage(WELCOME_MESSAGE);
194
+ await flushMicrotasks();
195
+ ws.triggerMessage({
196
+ type: "AuthSuccess",
197
+ data: { session_id: "persisted-session", expires_at: 1_710_086_400 },
198
+ });
199
+ ws.triggerClose(1006, "network_drop");
200
+ client.connectAndAuthenticate(auth);
201
+ ws = socket();
202
+ ws.triggerOpen();
203
+ ws.triggerMessage(WELCOME_MESSAGE);
204
+ await flushMicrotasks();
205
+ const sentTypes = ws.sent.map((payload) => parseClientMessage(payload).type);
206
+ expect(sentTypes).toEqual(["Hello", "ResumeAuth"]);
207
+ const resumeAuth = parseClientMessage(ws.sent[1]);
208
+ expect(resumeAuth.type).toBe("ResumeAuth");
209
+ if (resumeAuth.type === "ResumeAuth") {
210
+ expect(resumeAuth.data.session_id).toBe("persisted-session");
211
+ }
212
+ });
213
+ it("sends Logout and emits logoutSuccess", () => {
214
+ const { client, socket } = makeHarness();
215
+ const events = [];
216
+ client.on("logoutSuccess", () => events.push("logoutSuccess"));
217
+ client.connectAnonymous();
218
+ const ws = socket();
219
+ ws.triggerOpen();
220
+ ws.triggerMessage(WELCOME_MESSAGE);
221
+ client.logout();
222
+ const sent = parseClientMessage(ws.sent[1]);
223
+ expect(sent.type).toBe("Logout");
224
+ ws.triggerMessage({ type: "LogoutSuccess", data: {} });
225
+ expect(events).toEqual(["logoutSuccess"]);
226
+ });
227
+ it("adds request_id to public request messages", () => {
228
+ const { client, socket } = makeHarness();
229
+ client.connectAnonymous();
230
+ const ws = socket();
231
+ ws.triggerOpen();
232
+ ws.triggerMessage(WELCOME_MESSAGE);
233
+ const getMarketsId = client.getMarkets();
234
+ const getMarketDescriptorsId = client.getMarketDescriptors();
235
+ const getTokensId = client.getTokens();
236
+ const getExpiriesId = client.getExpiries();
237
+ const getActiveRfqsId = client.getActiveRfqs();
238
+ const getIndicativePricesId = client.getIndicativePrices({
239
+ market: "market-1",
240
+ position_type: "covered_call",
241
+ });
242
+ const sent = ws.sent.slice(1).map(parseClientMessage);
243
+ const withRequestId = sent.filter((msg) => msg.type !== "Logout");
244
+ for (const msg of withRequestId) {
245
+ if ("data" in msg) {
246
+ const data = msg.data;
247
+ expect(typeof data.request_id).toBe("string");
248
+ expect(data.request_id.length).toBeGreaterThan(0);
249
+ }
250
+ }
251
+ expect(sent[0].data.request_id).toBe(getMarketsId);
252
+ expect(sent[1].data.request_id).toBe(getMarketDescriptorsId);
253
+ expect(sent[2].data.request_id).toBe(getTokensId);
254
+ expect(sent[3].data.request_id).toBe(getExpiriesId);
255
+ expect(sent[4].data.request_id).toBe(getActiveRfqsId);
256
+ expect(sent[5].data.request_id).toBe(getIndicativePricesId);
257
+ });
258
+ it("adds request_id to authenticated request messages", () => {
259
+ const { client, socket } = makeHarness();
260
+ client.connectAnonymous();
261
+ const ws = socket();
262
+ ws.triggerOpen();
263
+ ws.triggerMessage(WELCOME_MESSAGE);
264
+ client.handleMessage({
265
+ type: "AuthSuccess",
266
+ data: { session_id: "session-id", expires_at: 1_710_086_400 },
267
+ });
268
+ const getPositionsId = client.getPositions();
269
+ const getMyActiveRfqsId = client.getMyActiveRfqs();
270
+ const getOrderStatusId = client.getOrderStatus("11".repeat(32));
271
+ const cancelRfqId = client.cancelRfq("rfq-1");
272
+ const cancelQuoteId = client.cancelQuote("rfq-2");
273
+ const sent = ws.sent.slice(1).map(parseClientMessage);
274
+ const authRequests = sent.filter((msg) => [
275
+ "GetPositions",
276
+ "GetMyActiveRfqs",
277
+ "GetOrderStatus",
278
+ "CancelRfq",
279
+ "CancelQuote",
280
+ ].includes(msg.type));
281
+ expect(authRequests).toHaveLength(5);
282
+ for (const msg of authRequests) {
283
+ if ("data" in msg) {
284
+ const data = msg.data;
285
+ expect(typeof data.request_id).toBe("string");
286
+ expect(data.request_id.length).toBeGreaterThan(0);
287
+ }
288
+ }
289
+ expect(authRequests[0].data.request_id).toBe(getPositionsId);
290
+ expect(authRequests[1].data.request_id).toBe(getMyActiveRfqsId);
291
+ expect(authRequests[2].data.request_id).toBe(getOrderStatusId);
292
+ expect(authRequests[3].data.request_id).toBe(cancelRfqId);
293
+ expect(authRequests[4].data.request_id).toBe(cancelQuoteId);
294
+ });
295
+ it("createRfq rejects when market descriptor is missing", async () => {
296
+ const { client, socket } = makeHarness();
297
+ client.connectAnonymous();
298
+ const ws = socket();
299
+ ws.triggerOpen();
300
+ ws.triggerMessage(WELCOME_MESSAGE);
301
+ client.handleMessage({
302
+ type: "AuthSuccess",
303
+ data: { session_id: "session-id", expires_at: 1_710_086_400 },
304
+ });
305
+ await expect(client.createRfq({
306
+ market: "market-1",
307
+ position_type: "covered_call",
308
+ strike: 100_000_000_000,
309
+ quantity: 2_000_000_000,
310
+ })).rejects.toThrow("Missing market descriptor");
311
+ const sentTypes = ws.sent.map((payload) => parseClientMessage(payload).type);
312
+ expect(sentTypes).not.toContain("RfqRequest");
313
+ });
314
+ it("createRfq validates quantity against market size rule", async () => {
315
+ const { client, socket } = makeHarness();
316
+ client.connectAnonymous();
317
+ const ws = socket();
318
+ ws.triggerOpen();
319
+ ws.triggerMessage(WELCOME_MESSAGE);
320
+ client.handleMessage({
321
+ type: "AuthSuccess",
322
+ data: { session_id: "session-id", expires_at: 1_710_086_400 },
323
+ });
324
+ client.handleMessage({
325
+ type: "MarketDescriptors",
326
+ data: {
327
+ request_id: "req-1",
328
+ markets: [
329
+ {
330
+ market: {
331
+ chain_id: 1,
332
+ program_id: "program-1",
333
+ market_pda: "market-1",
334
+ underlying_mint: "So11111111111111111111111111111111111111112",
335
+ quote_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
336
+ expiry_ts: 1_800_000_000,
337
+ is_put: false,
338
+ collateral_mint: "So11111111111111111111111111111111111111112",
339
+ settlement_mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
340
+ },
341
+ underlying_oracle_pda: "oracle-underlying",
342
+ quote_oracle_pda: "oracle-quote",
343
+ underlying_decimals: 9,
344
+ quote_decimals: 6,
345
+ size_rule: {
346
+ min_size: 2_000_000_000,
347
+ max_size: 10_000_000_000,
348
+ step: 1_000_000_000,
349
+ },
350
+ },
351
+ ],
352
+ },
353
+ });
354
+ await expect(client.createRfq({
355
+ market: "market-1",
356
+ position_type: "covered_call",
357
+ strike: 100_000_000_000,
358
+ quantity: 2_500_000_000,
359
+ })).rejects.toThrow("(quantity - min_size) % step == 0");
360
+ await client.createRfq({
361
+ market: "market-1",
362
+ position_type: "covered_call",
363
+ strike: 100_000_000_000,
364
+ quantity: 3_000_000_000,
365
+ });
366
+ const sentMessages = ws.sent.map(parseClientMessage);
367
+ const rfqRequest = sentMessages
368
+ .filter((message) => message.type === "RfqRequest")
369
+ .at(-1);
370
+ expect(rfqRequest?.type).toBe("RfqRequest");
371
+ if (rfqRequest?.type === "RfqRequest") {
372
+ expect(rfqRequest.data.market).toBe("market-1");
373
+ expect(rfqRequest.data.quantity).toBe(3_000_000_000);
374
+ }
179
375
  });
180
376
  it("drop_oldest policy keeps the latest queued messages", () => {
181
377
  const { client, socket } = makeHarness({
@@ -12,11 +12,13 @@ function deriveTokenLists(markets) {
12
12
  underlyings.set(uMint, {
13
13
  mint: m.market.underlying_mint,
14
14
  decimals: m.underlying_decimals,
15
+ size_rule: m.size_rule,
15
16
  symbol: m.underlying_symbol,
16
17
  });
17
18
  const q = {
18
19
  mint: m.market.quote_mint,
19
20
  decimals: m.quote_decimals,
21
+ size_rule: m.size_rule,
20
22
  symbol: m.quote_symbol,
21
23
  };
22
24
  const inner = quotesByUnderlying.get(uMint) ?? new Map();
@@ -12,6 +12,7 @@
12
12
  Object.defineProperty(exports, "__esModule", { value: true });
13
13
  exports.assertWsU64Safe = assertWsU64Safe;
14
14
  exports.assertWsI64Safe = assertWsI64Safe;
15
+ exports.validateQuantityBySizeRule = validateQuantityBySizeRule;
15
16
  function assertWsU64Safe(value, name) {
16
17
  if (!Number.isFinite(value) || !Number.isInteger(value)) {
17
18
  throw new Error(`${name} must be an integer number (u64 on wire)`);
@@ -32,3 +33,18 @@ function assertWsI64Safe(value, name) {
32
33
  throw new Error(`${name} exceeds JS safe integer range; WS wire requires safe integers`);
33
34
  }
34
35
  }
36
+ function validateQuantityBySizeRule(quantity, rule, quantityName = "quantity") {
37
+ assertWsU64Safe(quantity, quantityName);
38
+ assertWsU64Safe(rule.min_size, "size_rule.min_size");
39
+ assertWsU64Safe(rule.max_size, "size_rule.max_size");
40
+ assertWsU64Safe(rule.step, "size_rule.step");
41
+ if (rule.max_size < rule.min_size) {
42
+ throw new Error(`invalid size_rule: max_size (${rule.max_size}) must be >= min_size (${rule.min_size})`);
43
+ }
44
+ if (quantity < rule.min_size || quantity > rule.max_size) {
45
+ throw new Error(`${quantityName} must be within [${rule.min_size}, ${rule.max_size}], got ${quantity}`);
46
+ }
47
+ if ((quantity - rule.min_size) % rule.step !== 0) {
48
+ throw new Error(`${quantityName} must satisfy (quantity - min_size) % step == 0 (quantity=${quantity}, min_size=${rule.min_size}, step=${rule.step})`);
49
+ }
50
+ }