@totalis/hip4-client 0.0.0-stage → 0.65.2

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.
Files changed (74) hide show
  1. package/CHANGELOG.md +1541 -0
  2. package/LICENSE +202 -0
  3. package/README.md +143 -2
  4. package/dist/api-keys.d.ts +118 -0
  5. package/dist/api-keys.js +104 -0
  6. package/dist/audience-contract.d.ts +52 -0
  7. package/dist/audience-contract.js +1 -0
  8. package/dist/base.d.ts +41 -0
  9. package/dist/base.js +60 -0
  10. package/dist/client.d.ts +16 -0
  11. package/dist/client.js +15 -0
  12. package/dist/controller.d.ts +50 -0
  13. package/dist/controller.js +60 -0
  14. package/dist/errors.d.ts +3 -0
  15. package/dist/errors.js +3 -0
  16. package/dist/flow-errors.d.ts +61 -0
  17. package/dist/flow-errors.js +70 -0
  18. package/dist/generated/account-read.d.ts +2009 -0
  19. package/dist/generated/account-read.js +1 -0
  20. package/dist/generated/api-keys.d.ts +1303 -0
  21. package/dist/generated/api-keys.js +1 -0
  22. package/dist/generated/browse.d.ts +938 -0
  23. package/dist/generated/browse.js +1 -0
  24. package/dist/generated/client.d.ts +73 -0
  25. package/dist/generated/client.js +1 -0
  26. package/dist/generated/errors.d.ts +404 -0
  27. package/dist/generated/errors.js +1342 -0
  28. package/dist/generated/maker.d.ts +47 -0
  29. package/dist/generated/maker.js +1 -0
  30. package/dist/generated/openapi.d.ts +8044 -0
  31. package/dist/generated/openapi.js +1 -0
  32. package/dist/generated/operator.d.ts +7 -0
  33. package/dist/generated/operator.js +1 -0
  34. package/dist/generated/realtime.d.ts +1416 -0
  35. package/dist/generated/realtime.js +1 -0
  36. package/dist/generated/vault-abi.d.ts +39 -0
  37. package/dist/generated/vault-abi.js +56 -0
  38. package/dist/generated/version.d.ts +23 -0
  39. package/dist/generated/version.js +143 -0
  40. package/dist/hypercore.d.ts +111 -0
  41. package/dist/hypercore.js +132 -0
  42. package/dist/maker.d.ts +205 -0
  43. package/dist/maker.js +608 -0
  44. package/dist/money.d.ts +59 -0
  45. package/dist/money.js +66 -0
  46. package/dist/operator.d.ts +4 -0
  47. package/dist/operator.js +4 -0
  48. package/dist/pagination.d.ts +20 -0
  49. package/dist/pagination.js +27 -0
  50. package/dist/partner.d.ts +59 -0
  51. package/dist/partner.js +57 -0
  52. package/dist/realtime.d.ts +19 -0
  53. package/dist/realtime.js +10 -0
  54. package/dist/request.d.ts +112 -0
  55. package/dist/request.js +187 -0
  56. package/dist/signer.d.ts +51 -0
  57. package/dist/signer.js +90 -0
  58. package/dist/stream.d.ts +133 -0
  59. package/dist/stream.js +318 -0
  60. package/dist/totalis.d.ts +656 -0
  61. package/dist/totalis.js +1160 -0
  62. package/dist/typed-data.d.ts +330 -0
  63. package/dist/typed-data.js +340 -0
  64. package/dist/ui.d.ts +54 -0
  65. package/dist/ui.js +77 -0
  66. package/dist/wallet-session.d.ts +45 -0
  67. package/dist/wallet-session.js +61 -0
  68. package/examples/browser-wallet.html +69 -0
  69. package/examples/browser-wallet.mjs +57 -0
  70. package/examples/node-bot.mjs +57 -0
  71. package/examples/onboard.mjs +132 -0
  72. package/examples/partner-frontend.mjs +44 -0
  73. package/examples/partner-server.mjs +22 -0
  74. package/package.json +104 -4
package/dist/maker.js ADDED
@@ -0,0 +1,608 @@
1
+ import { audienceClient, headerRecord } from "./base.js";
2
+ import { ApiError, randomBytes32, resendable, sendOnce, singleFetch, sleep, unwrap, uuidv7 } from "./request.js";
3
+ import { toTotalisSigner } from "./signer.js";
4
+ import { items } from "./pagination.js";
5
+ import { TotalisStream } from "./stream.js";
6
+ import { follow } from "./totalis.js";
7
+ import { address, cashoutTransferTypedData, hashTypedData, quoteTypedData, sellBackTypedData } from "./typed-data.js";
8
+ import { calldata, controllerTransactions } from "./controller.js";
9
+ export { controllerTransactions, encodeCall, openMakerVaultTransaction } from "./controller.js";
10
+ export function createMakerClient(environment, options) {
11
+ return audienceClient(environment, options);
12
+ }
13
+ /**
14
+ * `quote` or `bid` on an RFQ where a taker's acceptance of this maker's quote
15
+ * awaits its answer. Publishing would replace that quote, which the venue
16
+ * refuses with 409 `STATE_CONFLICT`. Nothing was published: answer the
17
+ * acceptance (`confirm`, or `onAcceptance`) first, or let its deadline pass.
18
+ */
19
+ export class AcceptancePendingError extends Error {
20
+ rfqId;
21
+ quoteId;
22
+ deadline;
23
+ name = "AcceptancePendingError";
24
+ code = "ACCEPTANCE_PENDING";
25
+ constructor(rfqId, quoteId, deadline) {
26
+ super(`an acceptance of quote ${quoteId} on RFQ ${rfqId} awaits this maker's answer until ${deadline}`);
27
+ this.rfqId = rfqId;
28
+ this.quoteId = quoteId;
29
+ this.deadline = deadline;
30
+ }
31
+ }
32
+ // An opening event older than this arrived by replay, not live fanout.
33
+ const REPLAYED_AFTER_MS = 2_000;
34
+ /** An in-memory store that drops entries a minute after they expire. */
35
+ export function memoryPublished() {
36
+ const entries = new Map();
37
+ return {
38
+ record(digest, expiry) {
39
+ const stale = Date.now() / 1000 - 60;
40
+ for (const [known, until] of entries)
41
+ if (until < stale)
42
+ entries.delete(known);
43
+ entries.set(digest, expiry);
44
+ },
45
+ forget: (digest) => void entries.delete(digest),
46
+ expiry: (digest) => entries.get(digest),
47
+ };
48
+ }
49
+ const randomSalt = () => BigInt(randomBytes32()).toString();
50
+ const expiryIn = (seconds) => String(Math.floor(Date.now() / 1000) + seconds);
51
+ /**
52
+ * The maker SDK over the public maker surface (the one Totalis MM uses):
53
+ * publish unsigned entry and cash-out quotes on one route, then confirm the
54
+ * exact terms a taker accepted when QUOTE_ACCEPTED arrives.
55
+ */
56
+ export function createMaker(options) {
57
+ const { environment, apiKey, makerId, signer: signerInput, stream: streamOptions, streamBaseUrl, ...clientOptions } = options;
58
+ const client = createMakerClient(environment, { ...clientOptions, accessToken: apiKey });
59
+ const recoveryFetch = singleFetch(clientOptions.fetch);
60
+ const signer = toTotalisSigner(signerInput);
61
+ const stream = new TotalisStream({
62
+ environment, accessToken: apiKey, headers: headerRecord(clientOptions.headers), ...streamOptions,
63
+ subscription: { ...streamOptions?.subscription, type: "maker", maker_id: makerId },
64
+ ...(clientOptions.baseUrl === undefined ? {} : { baseUrl: clientOptions.baseUrl }),
65
+ ...(streamBaseUrl === undefined ? {} : { streamBaseUrl }),
66
+ });
67
+ let deploymentRead;
68
+ // Acceptances still before their deadline and not yet answered, from the
69
+ // snapshot, the acceptances GET and QUOTE_ACCEPTED, so a handler attached
70
+ // after the stream is live still answers what it already delivered.
71
+ const pending = new Map();
72
+ /** This maker's unanswered acceptance on the RFQ, if any. Replacing or cancelling its quote would withdraw it. */
73
+ const awaiting = (rfqId) => {
74
+ for (const [id, acceptance] of pending) {
75
+ // Deadlines are venue time: a fast local clock must not drop a live acceptance.
76
+ if (Date.parse(acceptance.deadline) <= stream.serverNow())
77
+ pending.delete(id);
78
+ else if (acceptance.rfq_id === rfqId)
79
+ return acceptance;
80
+ }
81
+ return undefined;
82
+ };
83
+ const refuseWhileAwaiting = (rfqId) => {
84
+ const acceptance = awaiting(rfqId);
85
+ if (acceptance !== undefined)
86
+ throw new AcceptancePendingError(rfqId, acceptance.quote_id, acceptance.deadline);
87
+ };
88
+ // Resolves once the acceptances a truncated snapshot left out have been paged
89
+ // in, and rejects while that read has failed: until then they are not known.
90
+ let acceptancesPaged = Promise.resolve();
91
+ let pagingFailed = false;
92
+ const pageAcceptances = () => {
93
+ pagingFailed = false;
94
+ const paging = continueAcceptances();
95
+ acceptancesPaged = paging;
96
+ paging.catch((error) => {
97
+ if (acceptancesPaged === paging)
98
+ pagingFailed = true;
99
+ handlers.forEach(({ failed }) => failed(error));
100
+ });
101
+ return paging;
102
+ };
103
+ /**
104
+ * Pending acceptances are known: the stream is live and any truncated
105
+ * acceptances are paged in. A failed page read is read again here, and
106
+ * rejects again if it fails, so nothing is quoted while acceptances are unknown.
107
+ */
108
+ const acceptancesKnown = async () => {
109
+ await stream.live();
110
+ if (pagingFailed)
111
+ pageAcceptances();
112
+ await acceptancesPaged;
113
+ };
114
+ // Called with an RFQ whose acceptance was answered, so a held RFQ is offered again at once.
115
+ const released = new Set();
116
+ // Answered acceptances until their deadline, so a copy still being replayed or paged does not hold the RFQ again.
117
+ const answeredUntil = new Map();
118
+ /** `filled`: the answer filled the RFQ, so it is no longer open to quote. */
119
+ const answered = (acceptance, filled = false) => {
120
+ answeredUntil.set(acceptance.quote_id, Date.parse(acceptance.deadline));
121
+ if (pending.delete(acceptance.quote_id))
122
+ released.forEach((release) => release(acceptance.rfq_id, filled));
123
+ };
124
+ /**
125
+ * Record a digest as signable before publishing it (a lost response may
126
+ * still have published it), and forget it when the venue definitely rejects it.
127
+ */
128
+ async function publish(rfqId, digest, expiry, post) {
129
+ // An acceptance may have arrived while the terms were prepared.
130
+ refuseWhileAwaiting(rfqId);
131
+ await published.record(digest, expiry);
132
+ try {
133
+ await post();
134
+ }
135
+ catch (error) {
136
+ if (!resendable(error))
137
+ await published.forget(digest);
138
+ throw error;
139
+ }
140
+ }
141
+ const published = options.published ?? memoryPublished();
142
+ const deployment = () => (deploymentRead ??= client.GET("/v1/deployment").then(unwrap).catch((error) => {
143
+ deploymentRead = undefined;
144
+ throw error;
145
+ }));
146
+ // Fees can change by governance and accepts require the current ones, so
147
+ // every quote reads them; only the deployment identity is cached.
148
+ async function vaultFees() {
149
+ const { fee_bps, taker_fee_bps } = unwrap(await client.GET("/v1/deployment"));
150
+ if (fee_bps === null || taker_fee_bps === null)
151
+ throw new Error("the venue has not published the vault's fees yet");
152
+ return { fee_bps, taker_fee_bps };
153
+ }
154
+ /**
155
+ * Publish an unsigned quote on an entry RFQ; it replaces this maker's
156
+ * previous quote there. Nothing is signed until a taker accepts it. The
157
+ * fees are the vault's current ones, read from GET /v1/deployment for this quote.
158
+ * Throws `AcceptancePendingError`, publishing nothing, while a taker's
159
+ * acceptance of this maker's quote on the RFQ awaits an answer: replacing
160
+ * the quote would withdraw that acceptance. It starts the maker stream if
161
+ * needed and waits until it is live, so the pending acceptances are known.
162
+ */
163
+ async function quote(rfq, terms) {
164
+ await acceptancesKnown();
165
+ refuseWhileAwaiting(rfq.rfq_id);
166
+ const [chain, fees] = await Promise.all([deployment(), vaultFees()]);
167
+ const entry = {
168
+ maker_id: makerId, legs: [...rfq.legs].sort((a, b) => Number(a.outcome_id) - Number(b.outcome_id)), stake: rfq.stake, payout: String(terms.payout),
169
+ fee_bps: fees.fee_bps, taker_fee_bps: fees.taker_fee_bps, taker: rfq.taker.toLowerCase(),
170
+ expiry: expiryIn(terms.lifetimeSeconds ?? 20), salt: terms.salt ?? randomSalt(),
171
+ };
172
+ const quote_id = hashTypedData(quoteTypedData(chain, entry));
173
+ await publish(rfq.rfq_id, quote_id, Number(entry.expiry), async () => unwrap(await client.POST("/v1/rfqs/{rfq_id}/quotes", {
174
+ params: { path: { rfq_id: rfq.rfq_id }, header: { "Idempotency-Key": uuidv7() } }, body: { quote_id, entry },
175
+ })));
176
+ return { quoteId: quote_id, entry };
177
+ }
178
+ /**
179
+ * Publish an unsigned cash-out quote on the same route as entry quotes: a
180
+ * sell-back when this maker wrote the position (`rfq.maker_id` is this
181
+ * maker), paid from the maker vault; otherwise a transfer to this maker's
182
+ * signer, paid from that wallet's own account balance in the vault, not the
183
+ * maker vault. The venue refuses a transfer that balance cannot pay with
184
+ * `INVALID_REQUEST` naming `transfer.price`, so bid only on your own
185
+ * positions unless it is funded. The owner signs it before accepting, so
186
+ * confirming it executes it. Throws `AcceptancePendingError` like `quote`.
187
+ */
188
+ async function bid(rfq, terms) {
189
+ await acceptancesKnown();
190
+ refuseWhileAwaiting(rfq.rfq_id);
191
+ const chain = await deployment();
192
+ const common = { position_id: rfq.position_id, price: String(terms.price), expiry: expiryIn(terms.lifetimeSeconds ?? 45), salt: terms.salt ?? randomSalt(), leg_state: rfq.leg_state };
193
+ let command;
194
+ if (rfq.maker_id === makerId) {
195
+ const sell_back = { ...common, owner: rfq.owner };
196
+ command = { quote_id: hashTypedData(sellBackTypedData(chain, sell_back)), sell_back };
197
+ }
198
+ else {
199
+ const transfer = { ...common, seller: rfq.owner, buyer: await signer.address() };
200
+ command = { quote_id: hashTypedData(cashoutTransferTypedData(chain, transfer)), transfer };
201
+ }
202
+ await publish(rfq.rfq_id, command.quote_id, Number(common.expiry), async () => unwrap(await client.POST("/v1/rfqs/{rfq_id}/quotes", {
203
+ params: { path: { rfq_id: rfq.rfq_id }, header: { "Idempotency-Key": uuidv7() } }, body: command,
204
+ })));
205
+ return { quoteId: command.quote_id, ...command };
206
+ }
207
+ /**
208
+ * Cancel a published quote. `ACCEPTED` means this maker already confirmed it:
209
+ * it is filled, and there is nothing left to answer. While a taker's
210
+ * acceptance of it awaits an answer the venue refuses the cancel with 409
211
+ * `STATE_CONFLICT`: turn the acceptance down with
212
+ * `confirm(acceptance, "DECLINE")` instead.
213
+ */
214
+ async function cancelQuote(quoteId) {
215
+ const response = await client.DELETE("/v1/quotes/{quote_id}", { params: { path: { quote_id: quoteId } } });
216
+ // A quote this maker already confirmed answers 409 with {status: "ACCEPTED"}: a result, not a failure.
217
+ const body = response.error;
218
+ if (response.response.status === 409 && typeof body === "object" && body !== null && "status" in body && body.status === "ACCEPTED")
219
+ return { status: "ACCEPTED" };
220
+ const result = unwrap(response);
221
+ // Cancelled or replaced can no longer be accepted, so it must no longer be signable.
222
+ if (result.status !== "ACCEPTED") {
223
+ await published.forget(quoteId);
224
+ const acceptance = pending.get(quoteId);
225
+ if (acceptance !== undefined)
226
+ answered(acceptance);
227
+ }
228
+ return result;
229
+ }
230
+ /**
231
+ * Arm or renew this maker's quote lease: its open quotes stand only while the
232
+ * lease is live. If `leaseMs` passes without another renewal, the venue
233
+ * cancels every open quote of this maker that no taker is accepting, and each
234
+ * one it publishes after, until the lease is renewed or released. Takers see
235
+ * QUOTE_REMOVED with status CANCELLED. Without a lease nothing changes.
236
+ */
237
+ async function renewQuoteLease(leaseMs) {
238
+ return unwrap(await client.PUT("/v1/makers/{maker_id}/quote-lease", { params: { path: { maker_id: makerId } }, body: { lease_ms: leaseMs } }));
239
+ }
240
+ /** Disarm the quote lease: open quotes then stand until their own expiry. `released` is false if none was armed. */
241
+ async function releaseQuoteLease() {
242
+ return unwrap(await client.DELETE("/v1/makers/{maker_id}/quote-lease", { params: { path: { maker_id: makerId } } }));
243
+ }
244
+ let stopLease;
245
+ /**
246
+ * Renew the quote lease now and every third of `leaseMs` until the returned
247
+ * stop, `close()` or another `keepQuoteLease` call. If this process dies or
248
+ * loses the venue, the lease lapses and its quotes are withdrawn. Stopping
249
+ * leaves the lease to lapse; `releaseQuoteLease()` keeps quotes standing
250
+ * without one. `onError` gets each failed renewal; renewals continue.
251
+ */
252
+ function keepQuoteLease(leaseMs, onError = () => undefined) {
253
+ stopLease?.();
254
+ let timer;
255
+ let stopped = false;
256
+ const renew = () => void renewQuoteLease(leaseMs).catch(onError).finally(() => {
257
+ if (!stopped)
258
+ timer = setTimeout(renew, leaseMs / 3);
259
+ });
260
+ const stop = () => {
261
+ stopped = true;
262
+ clearTimeout(timer);
263
+ if (stopLease === stop)
264
+ stopLease = undefined;
265
+ };
266
+ stopLease = stop;
267
+ renew();
268
+ return stop;
269
+ }
270
+ /** The exact terms an acceptance asks this maker to sign, checked against the quote ID. */
271
+ async function termsFor(acceptance) {
272
+ const chain = await deployment();
273
+ const typedData = acceptance.kind === "ENTRY" ? quoteTypedData(chain, acceptance.entry)
274
+ : acceptance.kind === "SELL_BACK" ? sellBackTypedData(chain, acceptance.sell_back)
275
+ : cashoutTransferTypedData(chain, acceptance.transfer);
276
+ if (hashTypedData(typedData) !== acceptance.quote_id)
277
+ throw new Error("acceptance terms do not match their quote ID");
278
+ // The quote signer signs only terms this maker published and that are still live.
279
+ const expiry = await published.expiry(acceptance.quote_id);
280
+ if (expiry === undefined || expiry * 1000 <= Date.now())
281
+ throw new Error("acceptance is not for a live quote this maker published");
282
+ return typedData;
283
+ }
284
+ // Each delivery also goes to every handler.
285
+ const handlers = new Set();
286
+ const deliver = (acceptance) => {
287
+ for (const [id, { deadline }] of pending)
288
+ if (Date.parse(deadline) <= stream.serverNow())
289
+ pending.delete(id);
290
+ for (const [id, deadline] of answeredUntil)
291
+ if (deadline <= stream.serverNow())
292
+ answeredUntil.delete(id);
293
+ if (Date.parse(acceptance.deadline) > stream.serverNow() && !answeredUntil.has(acceptance.quote_id))
294
+ pending.set(acceptance.quote_id, acceptance);
295
+ handlers.forEach(({ dispatch }) => dispatch(acceptance));
296
+ };
297
+ const continueAcceptances = async () => {
298
+ const read = (query) => client.GET("/v1/makers/{maker_id}/acceptances", { params: { path: { maker_id: makerId }, query: { ...query, limit: 200 } } }).then(unwrap);
299
+ for await (const acceptance of items(read, (page) => page.acceptances))
300
+ deliver(acceptance);
301
+ };
302
+ stream.on("reset", () => pending.clear());
303
+ // A resumed stream replays events with nothing marking their end (a maker
304
+ // stream has no publication freshness), so it is live before any replayed
305
+ // QUOTE_ACCEPTED is applied. Without a snapshot on this connection, the
306
+ // pending acceptances are read in full before anything is quoted.
307
+ let snapshotted = false;
308
+ stream.on("status", (status) => {
309
+ if (status !== "live")
310
+ snapshotted = false;
311
+ else if (!snapshotted)
312
+ void pageAcceptances().catch(() => undefined);
313
+ });
314
+ stream.on("snapshot", (snapshot) => {
315
+ snapshotted = true;
316
+ (snapshot.resources.acceptances ?? []).forEach(deliver);
317
+ if (!(snapshot.truncated ?? []).some((truncation) => truncation.resource === "acceptances"))
318
+ return;
319
+ void pageAcceptances().catch(() => undefined);
320
+ });
321
+ stream.on("event", (event) => void (event.event_type === "QUOTE_ACCEPTED" && deliver(event.payload)));
322
+ // Each acceptance's answer is decided, signed and built once, registered before any await so
323
+ // concurrent callers share it; asking again resends those exact bytes.
324
+ const answers = new Map();
325
+ function answer(acceptance, decide) {
326
+ let entry = answers.get(acceptance.quote_id);
327
+ if (entry === undefined) {
328
+ const built = (async () => {
329
+ const decision = await decide();
330
+ const command = decision === "DECLINE"
331
+ ? { decision }
332
+ : { decision, maker_signature: await signer.signTypedData(await termsFor(acceptance)) };
333
+ // A quote has at most one acceptance, so the quote ID alone keys the answer.
334
+ const params = { path: { quote_id: acceptance.quote_id } };
335
+ return async () => {
336
+ const result = await sendOnce(() => client.POST("/v1/quotes/{quote_id}/confirm", { params, body: command }));
337
+ // Answered: unless it filled the RFQ, its quotes may change again.
338
+ answered(acceptance, result.status === "ACCEPTED" || result.status === "FILLED");
339
+ return result;
340
+ };
341
+ })();
342
+ entry = { until: Date.parse(acceptance.deadline), built };
343
+ answers.set(acceptance.quote_id, entry);
344
+ // Nothing was sent if deciding or signing failed; a later delivery may decide again.
345
+ built.catch(() => answers.delete(acceptance.quote_id));
346
+ for (const [id, { until }] of answers)
347
+ if (until < Date.now() - 60_000)
348
+ answers.delete(id);
349
+ }
350
+ return entry.built.then((send) => send());
351
+ }
352
+ /**
353
+ * Answer an acceptance before its deadline. CONFIRM signs the exact accepted
354
+ * terms with the quote signer. The first answer for a quote is final:
355
+ * calling again, or `NetworkError.resend()`, resends the identical request,
356
+ * which the venue answers with the stored result.
357
+ */
358
+ function confirm(acceptance, decision = "CONFIRM") {
359
+ return answer(acceptance, () => decision);
360
+ }
361
+ /**
362
+ * Handle every pending acceptance: from QUOTE_ACCEPTED, live or replayed by
363
+ * the stream after a reconnect with a cursor, and the snapshot's
364
+ * `acceptances` (paging GET /v1/makers/{maker_id}/acceptances when the
365
+ * snapshot truncated them), including acceptances the stream delivered
366
+ * before it was attached. The handler decides once per quote; if every send
367
+ * of the answer is lost, a redelivery before the deadline resends that same
368
+ * answer. `onError` receives the acceptance, or none when paging the
369
+ * truncated acceptances failed.
370
+ */
371
+ function onAcceptance(handler, onError = () => undefined) {
372
+ const busy = new Set();
373
+ const answered = new Set();
374
+ const dispatch = (request) => {
375
+ const id = request.quote_id;
376
+ if (busy.has(id) || answered.has(id) || Date.parse(request.deadline) <= Date.now())
377
+ return;
378
+ busy.add(id);
379
+ void answer(request, () => handler(request)).then(() => answered.add(id), (error) => {
380
+ // A lost or backed-off answer is resent unchanged by the next delivery.
381
+ if (!resendable(error))
382
+ answered.add(id);
383
+ onError(error, request);
384
+ }).finally(() => busy.delete(id));
385
+ };
386
+ const entry = { dispatch, failed: (error) => onError(error) };
387
+ handlers.add(entry);
388
+ stream.start();
389
+ [...pending.values()].forEach(dispatch);
390
+ return () => void handlers.delete(entry);
391
+ }
392
+ /**
393
+ * Every open RFQ once per subscription: from RFQ_CREATED, the snapshot's
394
+ * open RFQs, and a paged `GET /v1/rfqs` after a reconnect that replayed
395
+ * instead of snapshotting (or whose snapshot was truncated). Each call keeps
396
+ * its own in-memory handled set, so another subscription or a restart
397
+ * delivers the still-open RFQs again; quoting one again replaces this
398
+ * maker's previous quote on it, so a repeat cannot fill twice. An RFQ where
399
+ * a taker's acceptance of this maker's quote awaits an answer is held back
400
+ * until that acceptance is answered or its deadline passes, so quoting it
401
+ * again does not withdraw an acceptance this maker already knows of.
402
+ */
403
+ function onRfq(handler, onError = () => undefined) {
404
+ const seen = new Set();
405
+ // RFQs that ended since a snapshot or held delivery captured them open.
406
+ const ended = new Set();
407
+ const held = new Map();
408
+ const release = (rfqId, filled = false) => {
409
+ if (filled)
410
+ ended.add(rfqId);
411
+ const entry = held.get(rfqId);
412
+ if (entry === undefined)
413
+ return;
414
+ clearTimeout(entry.timer);
415
+ held.delete(rfqId);
416
+ call(entry.rfq);
417
+ };
418
+ const call = (rfq) => {
419
+ if (stream.status === "closed" || ended.has(rfq.rfq_id) || seen.has(rfq.rfq_id) || held.has(rfq.rfq_id) || !(Date.parse(rfq.expires_at) > stream.serverNow()))
420
+ return;
421
+ const acceptance = awaiting(rfq.rfq_id);
422
+ if (acceptance !== undefined) {
423
+ // Offered again once the acceptance is answered, or when its deadline passes.
424
+ held.set(rfq.rfq_id, { rfq, timer: setTimeout(() => release(rfq.rfq_id), Math.max(Date.parse(acceptance.deadline) - stream.serverNow(), 0) + 50) });
425
+ return;
426
+ }
427
+ seen.add(rfq.rfq_id);
428
+ if (seen.size > 10_000)
429
+ seen.delete(seen.values().next().value);
430
+ // A failed handler leaves the RFQ deliverable again by a later snapshot or recovery read.
431
+ const failed = (error) => {
432
+ seen.delete(rfq.rfq_id);
433
+ onError(error);
434
+ };
435
+ try {
436
+ void Promise.resolve(handler(rfq)).catch(failed);
437
+ }
438
+ catch (error) {
439
+ failed(error);
440
+ }
441
+ };
442
+ // A live RFQ_CREATED carries the whole open RFQ; its event type is the
443
+ // status. A replayed one may have been accepted or cancelled since, so it is
444
+ // read once more and delivered only if still open.
445
+ const opened = async (event) => {
446
+ if (event.event_type !== "RFQ_CREATED")
447
+ return;
448
+ const now = stream.serverNow();
449
+ if (!(Date.parse(event.payload.expires_at) > now) || seen.has(event.payload.rfq_id))
450
+ return;
451
+ if (now - Date.parse(event.occurred_at) <= REPLAYED_AFTER_MS)
452
+ return call({ ...event.payload, status: "OPEN" });
453
+ const current = unwrap(await client.GET("/v1/rfqs/{rfq_id}", { params: { path: { rfq_id: event.payload.rfq_id } } }));
454
+ if (current.status === "OPEN")
455
+ call(current);
456
+ };
457
+ let covered = false;
458
+ let stopped = false;
459
+ const afterAcceptances = (deliver) => {
460
+ if (stopped)
461
+ return;
462
+ void acceptancesKnown().then(() => {
463
+ if (!stopped)
464
+ deliver();
465
+ }, (error) => {
466
+ if (stopped)
467
+ return;
468
+ onError(error);
469
+ if (stream.status !== "closed")
470
+ setTimeout(() => afterAcceptances(deliver), 1_000);
471
+ });
472
+ };
473
+ const recover = async () => {
474
+ for await (const rfq of items((query) => client.GET("/v1/rfqs", { params: { query: { ...query, limit: 100 } } }).then(unwrap), (page) => page.rfqs)) {
475
+ if (rfq.status === "OPEN")
476
+ call(rfq);
477
+ }
478
+ };
479
+ const offs = [
480
+ stream.on("event", (event) => {
481
+ if (event.event_type === "RFQ_ACCEPTED" || event.event_type === "RFQ_CANCELLED" || event.event_type === "RFQ_FAILED") {
482
+ ended.add(event.payload.rfq_id);
483
+ if (ended.size > 10_000)
484
+ ended.delete(ended.values().next().value);
485
+ return;
486
+ }
487
+ afterAcceptances(() => void opened(event).catch(onError));
488
+ }),
489
+ stream.on("snapshot", (snapshot) => {
490
+ const resources = snapshot.resources;
491
+ // The acceptance listener ran first; a truncated snapshot's other acceptances are paged in before any RFQ is offered.
492
+ afterAcceptances(() => (resources.open_rfqs ?? []).forEach(call));
493
+ covered = !(snapshot.truncated ?? []).some((truncation) => truncation.resource === "open_rfqs");
494
+ }),
495
+ stream.on("status", (status) => {
496
+ if (status === "live" && !covered)
497
+ afterAcceptances(() => void recover().catch(onError));
498
+ if (status !== "live")
499
+ covered = false;
500
+ }),
501
+ ];
502
+ stream.start();
503
+ if (stream.status === "live")
504
+ afterAcceptances(() => void recover().catch(onError));
505
+ released.add(release);
506
+ return () => {
507
+ stopped = true;
508
+ offs.forEach((off) => off());
509
+ released.delete(release);
510
+ held.forEach(({ timer }) => clearTimeout(timer));
511
+ held.clear();
512
+ };
513
+ }
514
+ /** Vault transactions for this maker, for its controller wallet to sign and send. */
515
+ const controller = async () => {
516
+ const { owner } = unwrap(await client.GET("/v1/makers/{maker_id}/capital", { params: { path: { maker_id: makerId } } }));
517
+ return controllerTransactions(await deployment(), makerId, owner);
518
+ };
519
+ /**
520
+ * The pending vault withdrawal, if any, and what would block executing it
521
+ * now. Send `controller()`'s `executeWithdraw` once `pending.executable`.
522
+ */
523
+ const withdrawal = async () => unwrap(await client.GET("/v1/makers/{maker_id}/withdrawal", { params: { path: { maker_id: makerId } } }));
524
+ const settled = new Set(["NO_IMPROVEMENT", "FAILED"]);
525
+ const operationDone = new Set(["COMMITTED", "REVERTED", "EXPIRED_UNEXECUTED"]);
526
+ /**
527
+ * Ask for a collateral reduction and follow the job on the maker stream until
528
+ * it needs nothing more from the maker: NO_IMPROVEMENT, FAILED, or DURABLE
529
+ * with the attestation transaction ready (or already executed). A job stays
530
+ * QUEUED until `next_eligible_at`. With `signTransaction`, a wallet that
531
+ * holds HYPE for the gas (the maker wallet, or any other) signs the returned
532
+ * call as a raw EIP-1559 transaction, and it is submitted through the
533
+ * self-funded route. Without it, the job is returned with its `transaction`
534
+ * and `idempotencyKey`: sign the transaction yourself and submit it to
535
+ * `POST /v1/makers/{maker_id}/operations/{operation_id}/self-funded-transaction`
536
+ * under that same `Idempotency-Key`, the only one the route admits for this job.
537
+ */
538
+ async function reduceCollateral(request = {}) {
539
+ const path = { maker_id: makerId };
540
+ // The self-funded route admits the attestation only under the key that created the job.
541
+ const header = { "Idempotency-Key": uuidv7() };
542
+ const { job_id } = await sendOnce(() => client.POST("/v1/makers/{maker_id}/collateral-reductions", { params: { path, header } }));
543
+ const read = async (signal) => unwrap(await client.GET("/v1/makers/{maker_id}/collateral-reductions/{job_id}", { params: { path: { maker_id: makerId, job_id } }, signal, fetch: recoveryFetch }));
544
+ const ready = (job) => settled.has(job.status) ||
545
+ (job.status === "DURABLE" && (job.transaction !== null || (job.operation_status !== null && operationDone.has(job.operation_status))));
546
+ const job = await follow(stream, read, ready, (event) => (event.event_type === "COLLATERAL_REDUCTION_UPDATED" && event.payload.job_id === job_id) ||
547
+ event.event_type === "OPERATION_UPDATED", request.signal, clientOptions.retry || undefined);
548
+ const idempotencyKey = header["Idempotency-Key"];
549
+ if (job.transaction === null || job.operation_id === null || request.signTransaction === undefined)
550
+ return { job, idempotencyKey };
551
+ const { chain_id, to, data } = job.transaction;
552
+ const signed_transaction = (await request.signTransaction({ chain_id, to: address(to), data: calldata(data), value: "0" })).toLowerCase();
553
+ const submitted = await sendOnce(() => client.POST("/v1/makers/{maker_id}/operations/{operation_id}/self-funded-transaction", {
554
+ params: { path: { maker_id: makerId, operation_id: job.operation_id }, header }, body: { signed_transaction },
555
+ }));
556
+ return { job, idempotencyKey, submitted };
557
+ }
558
+ return {
559
+ client, stream, deployment, quote, bid, cancelQuote, renewQuoteLease, releaseQuoteLease, keepQuoteLease, confirm, onAcceptance, onRfq, controller, withdrawal, reduceCollateral,
560
+ close: () => {
561
+ stopLease?.();
562
+ stream.close();
563
+ },
564
+ };
565
+ }
566
+ /**
567
+ * Read the account's maker (`GET /v1/me/maker`) and, once indexed or
568
+ * `PENDING`, admit it (`POST /v1/me/maker`, unless `admit: false`). Admission needs no signature
569
+ * and repeats safely. The POST's 404 (no maker indexed yet) is `NOT_INDEXED`,
570
+ * not an error.
571
+ */
572
+ export async function makerReadiness(client, options = {}) {
573
+ const init = options.signal === undefined ? {} : { signal: options.signal };
574
+ let { ownership } = unwrap(await client.GET("/v1/me/maker", init));
575
+ // A self-service PENDING profile is activated by the same POST; an operator decision keeps it as it is.
576
+ if ((ownership.state === "SETUP_REQUIRED" || ownership.state === "PENDING") && options.admit !== false) {
577
+ try {
578
+ ({ ownership } = unwrap(await client.POST("/v1/me/maker", init)));
579
+ }
580
+ catch (error) {
581
+ if (error instanceof ApiError && error.status === 404)
582
+ return { state: "NOT_INDEXED", ownership };
583
+ throw error;
584
+ }
585
+ if (ownership.state === "SETUP_REQUIRED")
586
+ return { state: "NOT_INDEXED", ownership };
587
+ }
588
+ switch (ownership.state) {
589
+ case "READY":
590
+ case "PENDING":
591
+ case "RESTRICTED":
592
+ return { state: ownership.state, makerId: ownership.maker.maker_id, ownership };
593
+ case "NOT_CREATED":
594
+ return { state: "NOT_INDEXED", ownership };
595
+ default:
596
+ return { state: ownership.state, ownership };
597
+ }
598
+ }
599
+ /** `makerReadiness` again every `intervalMs` (default 3000) while `NOT_INDEXED`; resolves with the first other state. */
600
+ export async function waitForMaker(client, options = {}) {
601
+ const { intervalMs = 3_000, ...read } = options;
602
+ for (;;) {
603
+ const readiness = await makerReadiness(client, read);
604
+ if (readiness.state !== "NOT_INDEXED")
605
+ return readiness;
606
+ await sleep(intervalMs, options.signal);
607
+ }
608
+ }
@@ -0,0 +1,59 @@
1
+ import type { components } from "./generated/openapi.js";
2
+ type Schemas = components["schemas"];
3
+ type Balance = Schemas["Balance"];
4
+ type EntryTerms = Pick<Schemas["schemas-QuoteTerms"], "stake" | "payout" | "fee_bps" | "taker_fee_bps">;
5
+ type CashoutTerms = {
6
+ readonly kind: "SELL_BACK";
7
+ readonly sell_back: Pick<Schemas["schemas-SellBackTerms"], "price">;
8
+ } | {
9
+ readonly kind: "TRANSFER";
10
+ readonly transfer: Pick<Schemas["schemas-TransferTerms"], "price">;
11
+ };
12
+ /**
13
+ * Spendable Totalis cash from `GET /v1/me/balances`, in USDC base units, as
14
+ * the SDK plans entries and withdrawals with it. `null` means unknown (the
15
+ * component is not `CURRENT`), never 0: show it as unknown and read again.
16
+ * - `vault`: free vault cash (`VAULT_FREE`).
17
+ * - `wallet`: wallet USDC (`WALLET_USDC`) less `pending.wallet_reserved`
18
+ * (open withdrawals, stake fundings and wallet deposits not yet committed),
19
+ * floored at 0.
20
+ * - `total`: the venue's spendable `amount`; it subtracts withdrawals and
21
+ * stake fundings but not a pending wallet deposit, which moves into the
22
+ * vault, so it can exceed `vault + wallet` while a deposit is pending.
23
+ */
24
+ export declare function spendableCash(balance: Pick<Balance, "amount" | "state" | "components" | "pending">): {
25
+ readonly vault: bigint | null;
26
+ readonly wallet: bigint | null;
27
+ readonly total: bigint | null;
28
+ };
29
+ /**
30
+ * What the owner receives from a gross close amount (a settlement payout or a
31
+ * sell-back price), as the vault pays it: the profit fee is
32
+ * `floor((amount - netStake) * feeBps / 10000)` on the part above the
33
+ * position's net stake, and nothing at or below it. `feeBps` and `netStake`
34
+ * are the position's own (`fee_bps`, `net_stake`), fixed when it opened.
35
+ */
36
+ export declare function netOfProfitFee(amount: bigint | string, netStake: bigint | string, feeBps: number): bigint;
37
+ /** A quote's net stake: the stake less the entry fee, `floor(stake * taker_fee_bps / 10000)`. */
38
+ export declare function netStake(terms: Pick<EntryTerms, "stake" | "taker_fee_bps">): bigint;
39
+ /**
40
+ * What the taker receives if every leg wins, net of the profit fee: the
41
+ * figure to show next to an entry quote, whose `payout` is gross. A fractional
42
+ * or partial result pays less; `netOfProfitFee` applies to whatever the
43
+ * position settles at. Example: stake 3 USDC at 20 bps entry fee and payout
44
+ * 9.18 USDC at 100 bps profit fee nets 9.118140 USDC.
45
+ */
46
+ export declare function netPayout(quote: {
47
+ readonly entry: EntryTerms;
48
+ } | EntryTerms): bigint;
49
+ /**
50
+ * What the owner receives from a cash-out quote. A `TRANSFER` pays its price
51
+ * in full; a `SELL_BACK` withholds the position's profit fee on the price
52
+ * above its net stake. `position` is the position read (`GET
53
+ * /v1/me/positions/{position_id}`); `null` when its `net_stake` is unknown.
54
+ */
55
+ export declare function netCashout(bid: CashoutTerms, position: {
56
+ readonly net_stake: string | null;
57
+ readonly fee_bps: number;
58
+ }): bigint | null;
59
+ export {};