@totalis/hip4-client 0.0.0-stage → 0.73.0

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 +1903 -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 +53 -0
  13. package/dist/controller.js +63 -0
  14. package/dist/errors.d.ts +3 -0
  15. package/dist/errors.js +3 -0
  16. package/dist/flow-errors.d.ts +62 -0
  17. package/dist/flow-errors.js +70 -0
  18. package/dist/generated/account-read.d.ts +2015 -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 +965 -0
  23. package/dist/generated/browse.js +1 -0
  24. package/dist/generated/client.d.ts +104 -0
  25. package/dist/generated/client.js +1 -0
  26. package/dist/generated/errors.d.ts +467 -0
  27. package/dist/generated/errors.js +1792 -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 +10867 -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 +1486 -0
  35. package/dist/generated/realtime.js +1 -0
  36. package/dist/generated/vault-abi.d.ts +49 -0
  37. package/dist/generated/vault-abi.js +70 -0
  38. package/dist/generated/version.d.ts +24 -0
  39. package/dist/generated/version.js +183 -0
  40. package/dist/hypercore.d.ts +111 -0
  41. package/dist/hypercore.js +132 -0
  42. package/dist/maker.d.ts +206 -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 +71 -0
  51. package/dist/partner.js +66 -0
  52. package/dist/realtime.d.ts +19 -0
  53. package/dist/realtime.js +10 -0
  54. package/dist/request.d.ts +115 -0
  55. package/dist/request.js +190 -0
  56. package/dist/signer.d.ts +51 -0
  57. package/dist/signer.js +90 -0
  58. package/dist/stream.d.ts +142 -0
  59. package/dist/stream.js +331 -0
  60. package/dist/totalis.d.ts +719 -0
  61. package/dist/totalis.js +1251 -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 +52 -0
  67. package/dist/wallet-session.js +63 -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 +105 -4
@@ -0,0 +1,1251 @@
1
+ import { keccak_256 } from "@noble/hashes/sha3";
2
+ import { createApiKeyClient, issueApiKey, rotateApiKey } from "./api-keys.js";
3
+ import { audienceClient, headerRecord } from "./base.js";
4
+ import { bridgeAction, bridgeGasBuffer, hyperliquidExchange, hyperliquidNetwork, settleHypercore, signSendAsset, usdcUnits, } from "./hypercore.js";
5
+ import { BalanceUnknownError, EntryError, ExitUnavailableError, normalizeError } from "./flow-errors.js";
6
+ import { spendableCash } from "./money.js";
7
+ import { ApiError, backoffDelay, envelope, NetworkError, randomBytes32, resendable, retryingFetch, sendOnce, singleFetch, sleep, unwrap, uuidv7 } from "./request.js";
8
+ import { toTotalisSigner } from "./signer.js";
9
+ import { TotalisStream } from "./stream.js";
10
+ import { address, cashoutTransferTypedData, exitTypedData, receiveWithAuthorizationTypedData, sellBackTypedData, takerAcceptTypedData, payoutNonce, transferWithAuthorizationTypedData, } from "./typed-data.js";
11
+ /**
12
+ * What an entry's bridge may have moved to its wallet, by its state: nothing
13
+ * before it is sent, once discarded or refused. Only `LANDED` proves it
14
+ * landed. A legacy record sent its bridge before the accept.
15
+ */
16
+ function bridgedCash(signed) {
17
+ if (signed.bridge === undefined)
18
+ return undefined;
19
+ const state = "version" in signed ? signed.bridgeState : "SENT";
20
+ const landed = state === "LANDED" ? "yes" : state === "SENT" || state === "PROCESSED" ? "unknown" : undefined;
21
+ return landed === undefined ? undefined : { amount: usdcUnits(signed.bridge.action.amount).toString(), landed };
22
+ }
23
+ /**
24
+ * An entry's `EntryFailure`; undefined unless its operation ended with a
25
+ * `failure_reason`. Pass the record as `onRecord` last handed it back (or
26
+ * `openPosition`'s `record`): only `LANDED` shows the Core cash moved before
27
+ * `fund_by`. `PROCESSED` only says the nonce is spent, which a refused or
28
+ * expired send also does, so it does not show the move.
29
+ */
30
+ export function entryFailure(record, operation) {
31
+ if (operation.failure_reason !== "INSUFFICIENT_CASH")
32
+ return operation.failure_reason;
33
+ if (record.bridge === undefined || "version" in record && record.bridgeState === "LANDED")
34
+ return "INSUFFICIENT_CASH";
35
+ return "CORE_MOVE_MISSED";
36
+ }
37
+ const answered = { ok: "LANDED", processed: "PROCESSED", refused: "REFUSED" };
38
+ const defaultTransport = hyperliquidExchange();
39
+ /** The acceptance's `fund_by` in ms: until then a WALLET-funded entry may move its Core cash. */
40
+ function fundBy(outcome) {
41
+ if (outcome.status === "NOT_ADMITTED" || outcome.fund_by === undefined)
42
+ return undefined;
43
+ const ms = Date.parse(outcome.fund_by);
44
+ return Number.isNaN(ms) ? undefined : ms;
45
+ }
46
+ /**
47
+ * The latest the venue clock can read, from responses' `server_time`. The
48
+ * venue stamps a response after its request left, so now on the venue is at
49
+ * most that stamp plus the time since sending. A stamp alone, judged at
50
+ * receipt, reads early by the response's delay. Elapsed time is the larger of
51
+ * the monotonic clock's (which may pause while the device sleeps) and the
52
+ * wall clock's (which may step back). `Infinity` before any response.
53
+ */
54
+ function venueClock() {
55
+ const samples = [];
56
+ return {
57
+ /** Call as a request leaves; call the result with its response's `server_time`. */
58
+ sending() {
59
+ const mono = performance.now();
60
+ const wall = Date.now();
61
+ return (serverTime) => {
62
+ const server = Date.parse(serverTime);
63
+ if (!Number.isNaN(server))
64
+ samples.push({ server, mono, wall });
65
+ };
66
+ },
67
+ latest() {
68
+ const mono = performance.now();
69
+ const wall = Date.now();
70
+ return Math.min(...samples.map((sample) => sample.server + Math.max(mono - sample.mono, wall - sample.wall)));
71
+ },
72
+ };
73
+ }
74
+ /** When an entry quote stops being acceptable, in ms on the venue clock: its `accept_before`. A quote without a readable one never is. */
75
+ export function acceptBefore(quote) {
76
+ const ms = Date.parse(quote.accept_before);
77
+ return Number.isNaN(ms) ? -Infinity : ms;
78
+ }
79
+ /** How often a pending acceptance with an unsent bridge is read, and how long past the maker's deadline. */
80
+ const watchMs = 250;
81
+ const watchGraceMs = 2000;
82
+ /** The venue refuses a sponsored deposit under 1 USDC. */
83
+ const minimumDeposit = 1000000n;
84
+ /**
85
+ * Makers answer an RFQ within about a second; collecting that long lets the
86
+ * best of them win instead of the fastest.
87
+ */
88
+ const defaultCollectMs = 1_000;
89
+ const timerSlackMs = 250;
90
+ function termsOf(quote) {
91
+ return quote.kind === "ENTRY" ? quote.entry : quote.kind === "SELL_BACK" ? quote.sell_back : quote.transfer;
92
+ }
93
+ /** The highest payout among quotes still before their `accept_before` (ties go to the earliest). */
94
+ export function bestQuote() {
95
+ return (quotes, now) => quotes
96
+ .filter((quote) => acceptBefore(quote) > now)
97
+ .reduce((best, quote) => best === undefined || BigInt(quote.entry.payout) > BigInt(best.entry.payout) ? quote : best, undefined);
98
+ }
99
+ /**
100
+ * The highest price with at least `minLifeSeconds` left (execution needs 20 s
101
+ * after confirmation) and, given `minPrice` in USDC base units, no lower than it.
102
+ */
103
+ export function bestBid(minLifeSeconds = 25, minPrice) {
104
+ return (bids, now) => bids
105
+ .filter((bid) => Number(termsOf(bid).expiry) * 1000 - now >= minLifeSeconds * 1000 && (minPrice === undefined || BigInt(termsOf(bid).price) >= minPrice))
106
+ .reduce((best, bid) => best === undefined || BigInt(termsOf(bid).price) > BigInt(termsOf(best).price) ? bid : best, undefined);
107
+ }
108
+ /** ABI calldata for a call whose arguments are all static words (addresses and integers). */
109
+ function callData(signature, ...words) {
110
+ const selector = Array.from(keccak_256(new TextEncoder().encode(signature)).slice(0, 4), (byte) => byte.toString(16).padStart(2, "0")).join("");
111
+ return `0x${selector}${words.map((word) => (typeof word === "bigint" ? word.toString(16) : word.slice(2)).padStart(64, "0")).join("")}`;
112
+ }
113
+ const idempotency = (key = uuidv7()) => ({ "Idempotency-Key": key });
114
+ function withTimeout(signal, ms) {
115
+ return signal === undefined ? AbortSignal.timeout(ms) : AbortSignal.any([signal, AbortSignal.timeout(ms)]);
116
+ }
117
+ const unavailable = new Set([500, 502, 503, 504]);
118
+ /** A failed recovery read worth retrying: a lost response, a BACKOFF error, or a 500/502/503/504 without the venue's error envelope. */
119
+ const transientRead = (error) => resendable(error) || (error instanceof ApiError && unavailable.has(error.status) && error.code.startsWith("HTTP_"));
120
+ /**
121
+ * Single-flight recovery reads for a follower. A transient failure is read
122
+ * again after a backoff, or after its Retry-After capped at `maxDelayMs` if
123
+ * that is longer, while the stream stays live; any other failure goes to
124
+ * `fail`. A call while a read is in flight asks for one more read after it. A
125
+ * call during a backoff, or during a read that then fails, reads at once, but
126
+ * during a Retry-After wait it reads when the wait ends. `stop` ends it, aborting the read.
127
+ */
128
+ function recoveryReads(stream, read, onValue, fail, stop, retry = {}) {
129
+ const { baseDelayMs = 250, maxDelayMs = 10_000, sleep: wait = sleep } = retry;
130
+ let reading = false;
131
+ let again = false;
132
+ let interrupt;
133
+ const pause = async (ms, wakeable) => {
134
+ const cut = new AbortController();
135
+ if (wakeable)
136
+ interrupt = () => cut.abort();
137
+ const ended = new Promise((resolve) => {
138
+ cut.signal.addEventListener("abort", () => resolve(), { once: true });
139
+ stop.addEventListener("abort", () => resolve(), { once: true, signal: cut.signal });
140
+ });
141
+ await Promise.race([wait(ms, AbortSignal.any([stop, cut.signal])).catch(() => undefined), ended]);
142
+ interrupt = undefined;
143
+ cut.abort();
144
+ return !stop.aborted;
145
+ };
146
+ return async () => {
147
+ if (reading) {
148
+ again = true;
149
+ interrupt?.();
150
+ return;
151
+ }
152
+ reading = true;
153
+ let failures = 0;
154
+ try {
155
+ while (!stop.aborted && stream.status === "live") {
156
+ again = false;
157
+ try {
158
+ const value = await read(stop);
159
+ if (stop.aborted)
160
+ return;
161
+ failures = 0;
162
+ onValue(value, again);
163
+ if (!again)
164
+ return;
165
+ }
166
+ catch (error) {
167
+ if (stop.aborted)
168
+ return;
169
+ if (!transientRead(error))
170
+ return fail(error);
171
+ const retryAfterMs = error instanceof ApiError ? error.retryAfterMs : undefined;
172
+ const backoff = backoffDelay(failures++, baseDelayMs, maxDelayMs);
173
+ if (retryAfterMs === undefined && again)
174
+ continue;
175
+ const delay = retryAfterMs === undefined ? backoff : Math.max(Math.min(retryAfterMs, maxDelayMs), backoff);
176
+ if (!(await pause(delay, retryAfterMs === undefined)))
177
+ return;
178
+ }
179
+ }
180
+ }
181
+ finally {
182
+ reading = false;
183
+ }
184
+ };
185
+ }
186
+ /**
187
+ * Follow a resource: read it, then read again only when the stream says it
188
+ * changed (`wake`) or after a reconnect, until `done`. Reads made while the
189
+ * stream is not live never settle it, so quotes read while disconnected are stale.
190
+ * A read that fails transiently is retried with `retry`'s backoff, not
191
+ * rejected; a wake during the backoff reads at once.
192
+ */
193
+ export function follow(stream, read, done, wake, signal, retry) {
194
+ return followUntil(stream, read, done, wake, () => undefined, signal, retry);
195
+ }
196
+ /** `follow` of a command already sent: a stream closed for good rejects with `commandSent`, as the command may still execute. */
197
+ function followSent(stream, read, done, wake, signal, retry) {
198
+ return followUntil(stream, read, done, wake, () => undefined, signal, retry, true);
199
+ }
200
+ function followUntil(stream, read, done, wake, recheckMs, signal, retry, commandSent = false) {
201
+ stream.start();
202
+ return new Promise((resolve, reject) => {
203
+ const stop = new AbortController();
204
+ const offs = [];
205
+ let recheck;
206
+ const finish = (settle) => {
207
+ if (stop.signal.aborted)
208
+ return;
209
+ stop.abort();
210
+ clearTimeout(recheck);
211
+ for (const off of offs)
212
+ off();
213
+ signal?.removeEventListener("abort", abort);
214
+ settle();
215
+ };
216
+ const check = recoveryReads(stream, read, (value, superseded) => {
217
+ if (superseded || stream.status !== "live")
218
+ return;
219
+ if (done(value))
220
+ return finish(() => resolve(value));
221
+ const delay = recheckMs(value);
222
+ clearTimeout(recheck);
223
+ if (delay !== undefined)
224
+ recheck = setTimeout(() => void check(), Math.max(delay, 0));
225
+ }, (error) => finish(() => reject(error)), stop.signal, retry);
226
+ const abort = () => finish(() => reject(signal?.reason));
227
+ if (signal?.aborted)
228
+ return abort();
229
+ // A stream already closed for good never reports it again.
230
+ if (stream.status === "closed")
231
+ return finish(() => reject(stream.closeError(commandSent)));
232
+ signal?.addEventListener("abort", abort, { once: true });
233
+ offs.push(stream.on("event", (event) => void (wake(event) && check())), stream.on("status", (status) => {
234
+ if (status === "live")
235
+ void check();
236
+ else if (status === "closed")
237
+ finish(() => reject(stream.closeError(commandSent)));
238
+ }));
239
+ void check();
240
+ });
241
+ }
242
+ // An RFQ that ends drops its quotes too, so rendered quotes empty with it.
243
+ const rfqEnded = (event) => event.event_type === "RFQ_CANCELLED" || event.event_type === "RFQ_FAILED" || event.event_type === "RFQ_ACCEPTED" || event.event_type === "RFQ_DECLINED";
244
+ const quotesChanged = (rfqId) => (event) => (event.event_type === "QUOTE_CREATED" || event.event_type === "QUOTE_REMOVED" || rfqEnded(event)) && event.payload.rfq_id === rfqId;
245
+ /** The venue refuses to accept a cash-out quote with this much signed life left or less. */
246
+ const cashoutAcceptLifeMs = 22_000;
247
+ /** When the quote stops being acceptable, in ms on the venue clock. */
248
+ const acceptableUntil = (offer) => offer.kind === "ENTRY" ? acceptBefore(offer) : Number(termsOf(offer).expiry) * 1000 - cashoutAcceptLifeMs;
249
+ /**
250
+ * The account's live quotes, kept from the stream: the snapshot's `quotes`,
251
+ * then QUOTE_CREATED and QUOTE_REMOVED. The quotes GET is read only for an RFQ
252
+ * someone follows after a snapshot that truncated `quotes`.
253
+ */
254
+ function streamQuotes(stream, read) {
255
+ const books = new Map();
256
+ const removed = new Set();
257
+ const recovered = new Set();
258
+ let truncated = false;
259
+ const put = (offer) => {
260
+ if (removed.has(offer.quote_id))
261
+ return;
262
+ let book = books.get(offer.rfq_id);
263
+ if (book === undefined)
264
+ books.set(offer.rfq_id, (book = new Map()));
265
+ book.set(offer.quote_id, offer);
266
+ };
267
+ const clear = () => {
268
+ books.clear();
269
+ removed.clear();
270
+ recovered.clear();
271
+ };
272
+ stream.on("reset", clear);
273
+ stream.on("snapshot", (snapshot) => {
274
+ clear();
275
+ for (const offer of snapshot.resources.quotes ?? [])
276
+ put(offer);
277
+ truncated = (snapshot.truncated ?? []).some((truncation) => truncation.resource === "quotes");
278
+ });
279
+ stream.on("event", (event) => {
280
+ if (event.event_type === "QUOTE_CREATED")
281
+ put(event.payload);
282
+ else if (event.event_type === "QUOTE_REMOVED") {
283
+ removed.add(event.payload.quote_id);
284
+ books.get(event.payload.rfq_id)?.delete(event.payload.quote_id);
285
+ }
286
+ else if (rfqEnded(event)) {
287
+ books.delete(event.payload.rfq_id);
288
+ }
289
+ });
290
+ return {
291
+ /**
292
+ * The acceptable quotes on an RFQ, oldest first. Expiry sends no event, so
293
+ * quotes past their accept life are dropped here. After a truncated
294
+ * snapshot the RFQ's quotes are read once from the quotes GET.
295
+ */
296
+ async offers(rfqId, signal) {
297
+ if (truncated && !recovered.has(rfqId)) {
298
+ for (const offer of await read(rfqId, signal))
299
+ put(offer);
300
+ recovered.add(rfqId);
301
+ }
302
+ const book = books.get(rfqId);
303
+ const now = stream.serverNow();
304
+ for (const [quoteId, offer] of book ?? [])
305
+ if (acceptableUntil(offer) <= now)
306
+ book.delete(quoteId);
307
+ return [...(book?.values() ?? [])].sort((a, b) => a.sequence - b.sequence);
308
+ },
309
+ };
310
+ }
311
+ /**
312
+ * The taker SDK: the same public client surface the Totalis app and third-party
313
+ * clients use. Quotes, operations and acceptances are followed on the account
314
+ * stream; HTTP reads recover after a reconnect or answer a doorbell.
315
+ */
316
+ export function createTotalis(options) {
317
+ const { environment, signer: signerInput, stream: streamOptions, streamBaseUrl, accessToken, ...clientOptions } = options;
318
+ const client = audienceClient(environment, { ...clientOptions, accessToken });
319
+ const keys = createApiKeyClient(environment, { ...clientOptions, accessToken });
320
+ const stream = new TotalisStream({
321
+ environment, accessToken, headers: headerRecord(clientOptions.headers), ...streamOptions,
322
+ subscription: { ...streamOptions?.subscription, type: "account" },
323
+ ...(clientOptions.baseUrl === undefined ? {} : { baseUrl: clientOptions.baseUrl }),
324
+ ...(streamBaseUrl === undefined ? {} : { streamBaseUrl }),
325
+ });
326
+ // Signing windows (deposit, withdrawal, exit) are judged by the venue, so they are set on its clock once the stream has synced. Signing never waits for the stream, so a window signed before its first sync is on the device clock.
327
+ const now = () => Math.floor(stream.serverNow() / 1000);
328
+ const retry = clientOptions.retry || undefined;
329
+ const recoveryFetch = singleFetch(clientOptions.fetch);
330
+ const countedFetch = (sending) => {
331
+ const base = clientOptions.fetch ?? ((request) => globalThis.fetch(request));
332
+ const counted = (request) => {
333
+ sending();
334
+ return base(request);
335
+ };
336
+ return clientOptions.retry === false ? counted : retryingFetch(counted, clientOptions.retry);
337
+ };
338
+ const signer = signerInput === undefined ? undefined : toTotalisSigner(signerInput);
339
+ const requireSigner = () => signer ?? Promise.reject(new Error("this flow needs a signer"));
340
+ let deploymentRead;
341
+ const deployment = () => (deploymentRead ??= client.GET("/v1/deployment").then(unwrap).catch((error) => {
342
+ deploymentRead = undefined;
343
+ throw error;
344
+ }));
345
+ async function cancelRfq(rfqId) {
346
+ await client.POST("/v1/rfqs/{rfq_id}/cancel", { params: { path: { rfq_id: rfqId }, header: idempotency() } }).catch(() => undefined);
347
+ }
348
+ const book = streamQuotes(stream, async (rfqId, signal) => unwrap(await client.GET("/v1/rfqs/{rfq_id}/quotes", { params: { path: { rfq_id: rfqId } }, ...(signal === undefined ? {} : { signal, fetch: recoveryFetch }) })).quotes);
349
+ async function createRfq(command) {
350
+ const header = idempotency();
351
+ return (await sendOnce(() => client.POST("/v1/rfqs", { params: { header }, body: command }))).rfq_id;
352
+ }
353
+ async function createCashout(positionId) {
354
+ const header = idempotency();
355
+ return (await sendOnce(() => client.POST("/v1/me/positions/{position_id}/cashout", { params: { path: { position_id: positionId }, header }, body: {} }))).rfq_id;
356
+ }
357
+ /** The live quotes rendered for a UI: `[]` while the stream is not live (stale). */
358
+ function watchQuotes(rfqId, listener) {
359
+ let stopped = false;
360
+ let expiry;
361
+ const refresh = async () => {
362
+ const offers = stream.status === "live" ? await book.offers(rfqId) : [];
363
+ if (stopped)
364
+ return;
365
+ const now = stream.serverNow();
366
+ listener(stream.status === "live" ? offers : [], now);
367
+ // Re-render when the soonest quote stops being acceptable; that sends no event.
368
+ clearTimeout(expiry);
369
+ const next = Math.min(...offers.map(acceptableUntil));
370
+ if (Number.isFinite(next))
371
+ expiry = setTimeout(() => void refresh().catch(() => undefined), Math.max(0, next - now) + 1);
372
+ };
373
+ const offs = [
374
+ stream.on("event", (event) => void (quotesChanged(rfqId)(event) && refresh().catch(() => undefined))),
375
+ stream.on("status", () => void refresh().catch(() => undefined)),
376
+ ];
377
+ stream.start();
378
+ void refresh().catch(() => undefined);
379
+ return () => {
380
+ stopped = true;
381
+ clearTimeout(expiry);
382
+ for (const off of offs)
383
+ off();
384
+ };
385
+ }
386
+ /**
387
+ * The quote `choose` picks once quotes have had `collectMs` to arrive after
388
+ * the first one it would take, re-chosen on every change to the RFQ's quotes
389
+ * and when the window ends. The window ends sooner when waiting would lose
390
+ * the quote `choose` picks now (it ages past `choose`'s rules or the venue's
391
+ * accept floor) or run past `timeoutMs`, so collecting never ends worse.
392
+ */
393
+ function awaitOffer(rfqId, eligible, choose, collectMs, timeoutMs, signal) {
394
+ const giveUpAt = performance.now() + timeoutMs - timerSlackMs;
395
+ let firstAt;
396
+ const pick = async (stop) => {
397
+ const offers = (await book.offers(rfqId, stop)).filter(eligible);
398
+ const now = stream.serverNow();
399
+ const offer = choose(offers, now);
400
+ if (offer === undefined)
401
+ return { offer, waitMs: undefined };
402
+ firstAt ??= performance.now();
403
+ const waitMs = Math.max(Math.min(firstAt + collectMs, giveUpAt) - performance.now(), 0);
404
+ const later = offers.filter((candidate) => acceptableUntil(candidate) > now + waitMs);
405
+ return { offer, waitMs: waitMs === 0 || choose(later, now + waitMs)?.quote_id !== offer.quote_id ? 0 : waitMs };
406
+ };
407
+ return followUntil(stream, pick, (picked) => picked.offer !== undefined && picked.waitMs === 0, quotesChanged(rfqId), (picked) => picked.waitMs, withTimeout(signal, timeoutMs), retry)
408
+ .then(({ offer }) => offer);
409
+ }
410
+ /** `spendableCash` of a fresh `GET /v1/me/balances`: free vault cash, wallet USDC not reserved by pending work, and the venue's total; `null` where unknown, never 0. */
411
+ async function readSpendableCash(signal) {
412
+ return spendableCash(unwrap(await client.GET("/v1/me/balances", signal === undefined ? {} : { signal })));
413
+ }
414
+ /** The wallet's USDC authorization of `value` into the vault; the venue fills `account` and `from` with the same wallet. */
415
+ async function depositAuthorization(chain, account, value) {
416
+ const authorization = { value: value.toString(), valid_after: String(now() - 60), valid_before: String(now() + 600), salt: randomBytes32() };
417
+ const authorization_signature = await (await requireSigner()).signTypedData(receiveWithAuthorizationTypedData(chain, { account, from: account, ...authorization }));
418
+ return { kind: "WALLET", ...authorization, authorization_signature };
419
+ }
420
+ /**
421
+ * Settle an acceptance whose accept response was lost, or a stored record
422
+ * after a restart: read it, and while the held request is still waiting for
423
+ * the maker, read once more after its deadline (the read retires an
424
+ * unanswered acceptance), unless `wait` is false. While `watching` the
425
+ * deadline, it reads every `watchMs` instead, so an early answer is seen
426
+ * before its `fund_by` passes. `absent` handles an accept the venue never
427
+ * received.
428
+ */
429
+ async function settleAcceptance(lost, read, absent, wait = true, watching) {
430
+ for (;;) {
431
+ // If the recovery read is lost too, surface the original command's error and its resend().
432
+ const acceptance = await read().catch((error) => Promise.reject(error instanceof NetworkError ? lost ?? error : error));
433
+ if (acceptance === "absent")
434
+ return absent();
435
+ if (!wait || acceptance.status !== "PENDING_CONFIRM" || acceptance.deadline === undefined)
436
+ return acceptance;
437
+ const deadline = Date.parse(acceptance.deadline);
438
+ await sleep(watching?.(deadline) ? watchMs : Math.max(0, deadline - Date.now()) + 1000);
439
+ }
440
+ }
441
+ /** A read whose 404 means the resource does not exist (yet). */
442
+ const orAbsent = (result) => (result.response.status === 404 ? "absent" : unwrap(result));
443
+ /**
444
+ * Read where an accept ended, for either kind of RFQ. An absent acceptance
445
+ * may still arrive, so cancel the RFQ first, then read once more: absent
446
+ * now is final. The cancel fences on 204, on `RFQ_NOT_CANCELLABLE` (the RFQ
447
+ * is no longer open or has expired) and on `NOT_FOUND` (no such RFQ for this
448
+ * account): the venue admits an accept only on an open, unexpired RFQ of the
449
+ * taker's. `STATE_CONFLICT` leaves the RFQ open: the acceptance is read
450
+ * once more, and still absent throws that error rather than `NOT_ADMITTED`.
451
+ * With `watch`, a pending acceptance is read every `watchMs` until its
452
+ * deadline plus `watchGraceMs` on the venue clock.
453
+ */
454
+ function fenceAcceptance(signed, clock, lost, wait = true, watch = false) {
455
+ const { rfqId, quoteId } = signed;
456
+ const watching = watch ? (deadline) => clock.latest() < deadline + watchGraceMs : undefined;
457
+ const read = async () => {
458
+ const stamped = clock.sending();
459
+ const acceptance = orAbsent(await client.GET("/v1/quotes/{quote_id}/acceptance", { params: { path: { quote_id: quoteId } } }));
460
+ if (acceptance === "absent")
461
+ return acceptance;
462
+ checkEcho(signed, acceptance, false);
463
+ stamped(acceptance.server_time);
464
+ return acceptance;
465
+ };
466
+ return settleAcceptance(lost, read, async () => {
467
+ const header = idempotency();
468
+ let open;
469
+ try {
470
+ await sendOnce(() => client.POST("/v1/rfqs/{rfq_id}/cancel", { params: { path: { rfq_id: rfqId }, header } }));
471
+ }
472
+ catch (error) {
473
+ if (error instanceof NetworkError)
474
+ throw lost ?? error;
475
+ if (!(error instanceof ApiError) || error.status !== 409 && error.status !== 404)
476
+ throw error;
477
+ if (error.code !== "RFQ_NOT_CANCELLABLE" && error.code !== "NOT_FOUND")
478
+ open = error;
479
+ }
480
+ // An unfenced RFQ may still show the acceptance; absent there proves nothing.
481
+ return settleAcceptance(lost, read, async () => {
482
+ if (open !== undefined)
483
+ throw open;
484
+ return { status: "NOT_ADMITTED", quote_id: quoteId, rfq_id: rfqId };
485
+ }, wait, watching);
486
+ }, wait, watching);
487
+ }
488
+ /**
489
+ * An acceptance must answer for this record's RFQ and quote; a direct accept
490
+ * answer must also echo its acceptance ID. A read answers for the quote
491
+ * whichever acceptance took it, as a quote has at most one.
492
+ */
493
+ function checkEcho(signed, acceptance, direct) {
494
+ if (acceptance.rfq_id !== signed.rfqId || acceptance.quote_id.toLowerCase() !== signed.quoteId.toLowerCase() || (direct && acceptance.acceptance_id !== signed.acceptanceId)) {
495
+ throw new Error(`the venue answered for acceptance ${acceptance.acceptance_id} on quote ${acceptance.quote_id}, not this record's`);
496
+ }
497
+ }
498
+ const resultOf = (signed, outcome, bridge) => {
499
+ const result = {
500
+ acceptanceId: signed.acceptanceId, rfqId: signed.rfqId, quoteId: signed.quoteId,
501
+ positionId: signed.kind === "ENTRY" ? signed.quoteId : signed.positionId,
502
+ status: outcome.status, operationId: outcome.status === "NOT_ADMITTED" ? undefined : outcome.operation_id, outcome,
503
+ ...(bridge.bridging === undefined ? {} : { bridging: bridge.bridging }),
504
+ };
505
+ return bridge.bridged?.() === undefined ? result : Object.defineProperty(result, "bridged", { get: bridge.bridged, enumerable: true });
506
+ };
507
+ /** A bridge this SDK signed and never sent, which may go only before `fund_by`. */
508
+ const bridgeUnsent = (signed) => signed.kind === "ENTRY" && "version" in signed && signed.bridge !== undefined && signed.bridgeState === undefined;
509
+ /** Record a never-sent bridge as discarded. Recovery derives the discard from the acceptance again, so a failed save loses nothing. */
510
+ async function discardBridge(signed, onRecord) {
511
+ if (signed.kind !== "ENTRY" || signed.bridge === undefined || signed.bridgeState !== undefined)
512
+ return;
513
+ await saveDiscard(signed, onRecord);
514
+ }
515
+ async function saveDiscard(signed, onRecord) {
516
+ try {
517
+ await onRecord?.({ ...signed, bridgeState: "DISCARDED" });
518
+ }
519
+ catch {
520
+ return;
521
+ }
522
+ }
523
+ /**
524
+ * Move an entry's bridge on from where its acceptance stands. Only this
525
+ * record's own `ACCEPTED` acceptance, while `clock` proves its `fund_by`
526
+ * is still ahead, sends it: first the record says `SENT`, then the same
527
+ * bytes go out, resent while unanswered until `fund_by`. A bridge never sent is discarded
528
+ * once the acceptance is answered any other way, another record's
529
+ * acceptance took the quote, or `fund_by` passed before the first send; one
530
+ * sent without an answer stays `SENT`. A legacy record is only read.
531
+ */
532
+ async function moveBridge(signed, outcome, clock, options) {
533
+ if (signed.kind !== "ENTRY" || signed.bridge === undefined)
534
+ return {};
535
+ if (!("version" in signed))
536
+ return { bridged: () => bridgedCash(signed) };
537
+ const { bridge, bridgeState } = signed;
538
+ if (bridgeState !== undefined && bridgeState !== "SENT")
539
+ return { bridged: () => bridgedCash(signed) };
540
+ const ours = outcome.status === "NOT_ADMITTED" || outcome.acceptance_id === signed.acceptanceId;
541
+ if (!ours && bridgeState === "SENT")
542
+ return { bridged: () => bridgedCash(signed) };
543
+ const until = ours && outcome.status === "ACCEPTED" ? fundBy(outcome) : undefined;
544
+ if (until === undefined || clock.latest() >= until) {
545
+ if (ours && outcome.status === "PENDING_CONFIRM")
546
+ return {};
547
+ await discardBridge(signed, options.onRecord);
548
+ return { bridged: () => bridgedCash(signed) };
549
+ }
550
+ const sent = { ...signed, bridgeState: "SENT" };
551
+ if (bridgeState === undefined) {
552
+ try {
553
+ await options.onRecord?.(sent);
554
+ }
555
+ catch (error) {
556
+ const unsent = Promise.reject(error);
557
+ unsent.catch(() => undefined);
558
+ return { bridging: unsent };
559
+ }
560
+ if (clock.latest() >= until) {
561
+ await saveDiscard(signed, options.onRecord);
562
+ return {};
563
+ }
564
+ }
565
+ const transport = options.transport ?? defaultTransport;
566
+ let attempted = false;
567
+ let latest = sent;
568
+ const bridging = settleHypercore(bridge, { transport: (action, signal) => { attempted = true; return transport(action, signal); }, deadline: until, now: clock.latest })
569
+ .then(async (answer) => {
570
+ if (answer.kind !== "unknown") {
571
+ latest = { ...sent, bridgeState: answered[answer.kind] };
572
+ await options.onRecord?.(latest);
573
+ }
574
+ else if (!attempted && bridgeState === undefined) {
575
+ latest = { ...signed, bridgeState: "DISCARDED" };
576
+ await saveDiscard(signed, options.onRecord);
577
+ }
578
+ return answer;
579
+ });
580
+ bridging.catch(() => undefined);
581
+ return { bridged: () => bridgedCash(latest), bridging };
582
+ }
583
+ /** The signer, account and deployment a new record is signed under. */
584
+ async function recordScope() {
585
+ const signer = await requireSigner();
586
+ const account = await signer.address();
587
+ const chain = await deployment();
588
+ return { signer, account, chain, scope: { account, chainId: chain.chain_id, vault: address(chain.vault_address.toLowerCase()) } };
589
+ }
590
+ /** A stored record signs nothing more, so without a signer the session's wallet (GET /v1/me) stands for the account. */
591
+ async function checkScope(signed) {
592
+ const account = signer === undefined ? unwrap(await client.GET("/v1/me")).wallet : await signer.address();
593
+ const chain = await deployment();
594
+ if (account !== signed.account || chain.chain_id !== signed.chainId || address(chain.vault_address.toLowerCase()) !== signed.vault) {
595
+ throw new Error("this acceptance was signed by another wallet or on another deployment");
596
+ }
597
+ }
598
+ /**
599
+ * Plan an entry's funding from venue balances: free vault cash
600
+ * first, then the wallet, then a bridge from Core for what the wallet lacks.
601
+ * A wallet without a current read is authorized for the whole shortfall
602
+ * (the venue checks the exact amount) and never bridges; a vault without
603
+ * one, or unread Core (`null`) the plan needs, is never taken as a balance.
604
+ * No `core` plans from the wallet alone, so a wallet known to fall short
605
+ * throws INSUFFICIENT_FUNDS.
606
+ */
607
+ function planEntry(stake, core, cash) {
608
+ const vault = cash.vault === null ? null : BigInt(cash.vault);
609
+ const wallet = cash.wallet === null ? null : BigInt(cash.wallet);
610
+ if (vault === null)
611
+ throw new EntryError("BALANCE_UNKNOWN", "the free vault balance is unknown; read it again");
612
+ const shortfall = stake - vault;
613
+ if (shortfall <= 0n)
614
+ return { wallet: 0n, bridge: 0n };
615
+ if (wallet === null || wallet >= shortfall)
616
+ return { wallet: shortfall, bridge: 0n };
617
+ const bridge = shortfall - wallet;
618
+ if (core === undefined)
619
+ throw new EntryError("INSUFFICIENT_FUNDS", `${bridge} USDC base units short for this stake; pass core to bridge it from HyperCore`);
620
+ if (core === null)
621
+ throw new EntryError("BALANCE_UNKNOWN", "the wallet falls short and the Core balance is unknown; read it again");
622
+ const routable = core - bridgeGasBuffer;
623
+ if (routable < bridge)
624
+ throw new EntryError("INSUFFICIENT_FUNDS", `${bridge - (routable > 0n ? routable : 0n)} USDC base units short for this stake`);
625
+ return { wallet: shortfall, bridge };
626
+ }
627
+ async function signEntryWith(request, funding) {
628
+ const { quote, cash } = request;
629
+ const { signer, account, chain, scope } = await recordScope();
630
+ if (quote.entry.taker.toLowerCase() !== account)
631
+ throw new Error("the quote is for another taker");
632
+ const stake = BigInt(quote.entry.stake);
633
+ const core = request.core === undefined || request.core === null ? request.core : BigInt(request.core);
634
+ // Known cash is planned before any prompt, so a plan that fails asks the wallet for nothing.
635
+ let plan = funding === "plan" && cash !== undefined ? planEntry(stake, core, cash) : undefined;
636
+ const signing = signer.signTypedData(takerAcceptTypedData(chain, quote.entry));
637
+ if (funding === "plan" && cash === undefined) {
638
+ // The quote signature names no amount, so the wallet signs it while the balances are read; a refusal stops the read and is the call's error.
639
+ const reading = new AbortController();
640
+ signing.catch((error) => reading.abort(error));
641
+ plan = await readSpendableCash(reading.signal).then((read) => planEntry(stake, core, read)).catch(async (error) => {
642
+ await signing;
643
+ throw error;
644
+ });
645
+ }
646
+ const bridgeAmount = plan?.bridge ?? 0n;
647
+ const walletAmount = plan?.wallet ?? 0n;
648
+ const [taker_signature, bridge, walletFunding] = await Promise.all([
649
+ signing,
650
+ bridgeAmount > 0n ? signSendAsset(signer, bridgeAction(hyperliquidNetwork(chain.chain_id), bridgeAmount, {
651
+ ...(request.hypercoreNonce === undefined ? {} : { nonce: request.hypercoreNonce }),
652
+ ...(request.sourceDex === undefined ? {} : { sourceDex: request.sourceDex }),
653
+ })) : undefined,
654
+ walletAmount > 0n ? depositAuthorization(chain, account, walletAmount) : undefined,
655
+ ]);
656
+ const fundingCommand = plan === undefined ? (funding === "none" || funding === "plan" ? undefined : funding) : walletFunding;
657
+ return {
658
+ kind: "ENTRY", version: 2, acceptanceId: request.acceptanceId ?? uuidv7(), rfqId: quote.rfq_id, quoteId: quote.quote_id, ...scope, expiry: quote.entry.expiry,
659
+ command: { taker_signature, ...(fundingCommand === undefined ? {} : { funding: fundingCommand }) },
660
+ ...(bridge === undefined ? {} : { bridge }),
661
+ };
662
+ }
663
+ /**
664
+ * Sign a Combo entry for a quote without sending it. Funding is planned
665
+ * from `cash`, the free vault cash and available wallet USDC you already
666
+ * read (`spendableCash`), or from a fresh balances read when it is left out:
667
+ * free vault cash, then a USDC authorization from the wallet, then a bridge
668
+ * of `core` (the Core USDC you offer, in USDC base units; `bridgeGasBuffer`
669
+ * stays behind) to the wallet for the rest. Pass `core: null` when your Core
670
+ * read failed: an entry that needs the bridge then throws `EntryError`
671
+ * `BALANCE_UNKNOWN` instead of asking the wallet for it all. Leave `core`
672
+ * out to fund from the wallet alone. The quote, the bridge and the wallet
673
+ * authorization are asked of the signer at once; a wallet that prompts
674
+ * shows them in turn. `sourceDex: ""` bridges from perps instead of spot.
675
+ * Persist the record, then `sendAcceptance` it; after a restart,
676
+ * `recoverAcceptance` it. Pass `hypercoreNonce` if your app reserves
677
+ * Hyperliquid nonces itself.
678
+ */
679
+ function signEntry(request) {
680
+ return signEntryWith(request, "plan");
681
+ }
682
+ /** Sign a cash-out quote as the position's owner without sending it: the same SellBack or CashoutTransfer digest the maker signs. */
683
+ async function signCashout(request) {
684
+ const { quote } = request;
685
+ const { signer, account, chain, scope } = await recordScope();
686
+ const seller = quote.kind === "SELL_BACK" ? quote.sell_back.owner : quote.transfer.seller;
687
+ if (seller.toLowerCase() !== account)
688
+ throw new Error("the quote sells another owner's position");
689
+ const typedData = quote.kind === "SELL_BACK" ? sellBackTypedData(chain, quote.sell_back) : cashoutTransferTypedData(chain, quote.transfer);
690
+ const taker_signature = await signer.signTypedData(typedData);
691
+ return {
692
+ kind: "CASHOUT", acceptanceId: request.acceptanceId ?? uuidv7(), rfqId: quote.rfq_id, quoteId: quote.quote_id, ...scope,
693
+ positionId: termsOf(quote).position_id, command: { taker_signature },
694
+ };
695
+ }
696
+ /**
697
+ * Send a signed entry or cash-out: one synchronous accept under the
698
+ * record's acceptance ID, whose answer must echo the record's acceptance,
699
+ * RFQ and quote IDs; a lost answer is read and fenced, never resent blind.
700
+ * An entry's bridge goes only after the accept answers `ACCEPTED` with a
701
+ * `fund_by` still ahead, and never holds up the result (see `bridging`);
702
+ * an accept answered any other way discards it, so nothing moved.
703
+ * `onRecord` is awaited with the record before the bridge is first sent,
704
+ * and gets it again with Hyperliquid's answer or the discard. An entry
705
+ * record from SDK 0.63 or older is refused: `recoverAcceptance` it.
706
+ */
707
+ async function sendAcceptance(signed, options = {}) {
708
+ if (signed.kind === "ENTRY" && signed.version !== 2)
709
+ throw new Error("this entry was signed by an older SDK, which sent its bridge before the accept; recoverAcceptance it instead");
710
+ await checkScope(signed);
711
+ options.signal?.throwIfAborted();
712
+ const clock = venueClock();
713
+ if (signed.kind === "ENTRY" && signed.bridge !== undefined) {
714
+ // The held accept's own stamp runs late by the maker's wait; a read sent alongside bounds the venue clock tightly.
715
+ const probed = clock.sending();
716
+ void client.GET("/v1/rfqs/{rfq_id}/quotes", { params: { path: { rfq_id: signed.rfqId } }, fetch: recoveryFetch }).then((read) => {
717
+ if (read.data !== undefined)
718
+ probed(read.data.server_time);
719
+ }, () => undefined);
720
+ }
721
+ let outcome;
722
+ let sends = 0;
723
+ const fetch = countedFetch(() => { sends += 1; });
724
+ const post = () => client.POST("/v1/quotes/{quote_id}/accept", { params: { path: { quote_id: signed.quoteId }, header: idempotency(signed.acceptanceId) }, body: signed.command, fetch });
725
+ try {
726
+ const stamped = clock.sending();
727
+ const answer = await sendOnce(post);
728
+ checkEcho(signed, answer, true);
729
+ stamped(answer.server_time);
730
+ outcome = answer;
731
+ }
732
+ catch (error) {
733
+ // A resend's refusal answers for an earlier send whose answer was lost only if the venue checks it after replaying a stored acceptance (a gone price, a bad signature). The session (401, 403) and the quota (429) are checked before the replay, and a 5xx or a cut-off body is no answer, so those read the acceptance.
734
+ const replayed = error instanceof ApiError && error.status >= 400 && error.status < 500 && ![401, 403, 429].includes(error.status);
735
+ if (!(error instanceof NetworkError) && (sends <= 1 || replayed)) {
736
+ if (!normalizeError(error).mayStillRun)
737
+ await discardBridge(signed, options.onRecord);
738
+ throw error;
739
+ }
740
+ const lost = error instanceof NetworkError ? error : Object.assign(new NetworkError({ method: "POST", url: `/v1/quotes/${signed.quoteId}/accept` }, { cause: error }), { resend: () => sendOnce(post) });
741
+ // A read refused too (the same lapsed session) leaves the outcome unknown: the bridge stays unsent and the error says the accept may still run.
742
+ outcome = await fenceAcceptance(signed, clock, lost, true, bridgeUnsent(signed)).catch((failed) => {
743
+ if (failed instanceof ApiError && failed.resend === undefined)
744
+ failed.resend = () => sendOnce(post);
745
+ throw failed;
746
+ });
747
+ }
748
+ return resultOf(signed, outcome, await moveBridge(signed, outcome, clock, options));
749
+ }
750
+ /**
751
+ * Finish a stored record after a lost answer or a restart, signing nothing:
752
+ * read the acceptance, fencing an absent one so it ends `NOT_ADMITTED`. An
753
+ * entry's bridge never sent goes now only if the acceptance is `ACCEPTED`
754
+ * and its `fund_by` is still ahead, and is discarded otherwise; one sent
755
+ * without an answer is resent, the same bytes, within the same window. A
756
+ * record from SDK 0.63 or older is only read. One still `PENDING_CONFIRM`
757
+ * is read again after the maker's deadline, or every 250 ms until 2 s past
758
+ * it while a bridge is unsent; `wait: false` returns it as it stands instead.
759
+ */
760
+ async function recoverAcceptance(signed, options = {}) {
761
+ await checkScope(signed);
762
+ const clock = venueClock();
763
+ const outcome = await fenceAcceptance(signed, clock, undefined, options.wait ?? true, bridgeUnsent(signed));
764
+ return resultOf(signed, outcome, await moveBridge(signed, outcome, clock, options));
765
+ }
766
+ /** Cancel a fresh RFQ when its record fails before the accept is posted. */
767
+ async function signedOrCancel(rfqId, sign) {
768
+ try {
769
+ return await sign();
770
+ }
771
+ catch (error) {
772
+ await cancelRfq(rfqId);
773
+ throw error;
774
+ }
775
+ }
776
+ /**
777
+ * Open a combo position: create the RFQ, take a quote from those the stream
778
+ * pushes, sign it (`signEntry`), hand the record to `onSigned` to persist,
779
+ * then `sendAcceptance`. Returns the acceptance's status (`ACCEPTED` with
780
+ * `operationId`; `FILLED` or `FAILED`, also with it, when a lost answer was
781
+ * read after the trade settled; `DECLINED` / `TIMED_OUT` / `CANCELLED`; or
782
+ * `NOT_ADMITTED` when a lost accept never arrived). The position opens
783
+ * under `positionId`, the quote's ID, once `waitForOperation(operationId)`
784
+ * resolves `COMMITTED`; pass its operation and the result's `record` to
785
+ * `entryFailure` to name a failure. Any vault shortfall is funded from the
786
+ * wallet, and from `core` by bridge, unless `funding` says otherwise. An accept refused with
787
+ * `QUOTE_EXPIRED` or `QUOTE_REPLACED` takes and signs a fresh quote once
788
+ * (`onSigned` gets the new record); a second refusal is thrown.
789
+ */
790
+ async function openPosition(request) {
791
+ const { legs, stake, choose = bestQuote(), collectMs = defaultCollectMs, funding = "wallet", intent, quoteTimeoutMs = 20_000, signal } = request;
792
+ const reading = funding === "wallet" && request.cash === undefined ? readSpendableCash(signal) : undefined;
793
+ reading?.catch(() => undefined);
794
+ const account = await (await requireSigner()).address();
795
+ await deployment();
796
+ await stream.live(signal);
797
+ const wanted = { taker: account, stake: String(stake), legs: [...legs].sort((a, b) => Number(a.outcome_id) - Number(b.outcome_id)).map(({ outcome_id, side }) => ({ outcome_id, side })) };
798
+ const entry = { stake: wanted.stake, legs: wanted.legs };
799
+ const rfqId = await createRfq(intent === undefined ? entry : { ...entry, ...intent });
800
+ // Only quotes for exactly this position are offered to the chooser, and the chosen one is checked again before signing.
801
+ const matches = (quote) => quote.kind === "ENTRY" && quote.rfq_id === rfqId &&
802
+ quote.entry.taker.toLowerCase() === wanted.taker && quote.entry.stake === wanted.stake &&
803
+ JSON.stringify(quote.entry.legs.map(({ outcome_id, side }) => ({ outcome_id, side }))) === JSON.stringify(wanted.legs);
804
+ const take = (refused) => signedOrCancel(rfqId, async () => {
805
+ const offered = (quote) => matches(quote) && quote.quote_id !== refused;
806
+ const selected = await awaitOffer(rfqId, offered, choose, collectMs, quoteTimeoutMs, signal);
807
+ if (!offered(selected))
808
+ throw new Error("the chosen quote is not for the requested position");
809
+ const signed = await signEntryWith({ quote: selected, core: request.core, cash: request.cash ?? await reading, sourceDex: request.sourceDex }, funding === "wallet" ? "plan" : funding);
810
+ await request.onSigned?.(signed);
811
+ return { selected, signed };
812
+ });
813
+ let { selected, signed } = await take();
814
+ let record = signed;
815
+ const send = () => sendAcceptance(signed, {
816
+ ...(request.transport === undefined ? {} : { transport: request.transport }),
817
+ ...(signal === undefined ? {} : { signal }),
818
+ onRecord: async (next) => {
819
+ record = next;
820
+ await request.onSigned?.(next);
821
+ },
822
+ });
823
+ let result;
824
+ try {
825
+ result = await send();
826
+ }
827
+ catch (error) {
828
+ if (!(error instanceof ApiError) || error.code !== "QUOTE_EXPIRED" && error.code !== "QUOTE_REPLACED")
829
+ throw error;
830
+ // The quote went away before the accept (sendAcceptance discarded its bridge): sign a fresh one, once. No fresh quote in time is still that refusal.
831
+ ({ selected, signed } = await take(signed.quoteId).catch((retried) => Promise.reject(retried instanceof Error && retried.name === "TimeoutError" ? error : retried)));
832
+ record = signed;
833
+ result = await send();
834
+ }
835
+ const { bridged: _bridged, ...settled } = result;
836
+ return {
837
+ ...settled,
838
+ entry: selected.entry,
839
+ /** As in `AcceptanceResult`, read as the bridge stands now. */
840
+ get bridged() {
841
+ return result.bridged;
842
+ },
843
+ /** The entry record as it stands now, with each bridge update `onSigned` gets: pass it to `entryFailure`. */
844
+ get record() {
845
+ return record;
846
+ },
847
+ };
848
+ }
849
+ /**
850
+ * Cash out a position: create its cash-out RFQ
851
+ * (`POST /v1/me/positions/{position_id}/cashout`), take a quote from those
852
+ * the stream pushes, sign it as its owner (`signCashout`), hand the record to
853
+ * `onSigned`, then `sendAcceptance`. `ACCEPTED` means the sell-back or
854
+ * transfer is queued as `operationId` (follow it with `waitForOperation`);
855
+ * otherwise the status says why not (`DECLINED`, `TIMED_OUT`, `CANCELLED`, `NOT_ADMITTED`).
856
+ */
857
+ async function cashOut(request) {
858
+ const { positionId, choose = bestBid(), collectMs = defaultCollectMs, quoteTimeoutMs = 20_000, signal } = request;
859
+ const owner = await (await requireSigner()).address();
860
+ await deployment();
861
+ await stream.live(signal);
862
+ const rfqId = await createCashout(positionId);
863
+ // Only quotes for this position, sold by this owner, are offered to the chooser and signed.
864
+ const matches = (quote) => {
865
+ if (quote.kind === "ENTRY" || quote.rfq_id !== rfqId)
866
+ return false;
867
+ const seller = quote.kind === "SELL_BACK" ? quote.sell_back.owner : quote.transfer.seller;
868
+ return termsOf(quote).position_id.toLowerCase() === positionId.toLowerCase() && seller === owner;
869
+ };
870
+ const signed = await signedOrCancel(rfqId, async () => {
871
+ const bid = await awaitOffer(rfqId, matches, choose, collectMs, quoteTimeoutMs, signal);
872
+ if (!matches(bid))
873
+ throw new Error("the chosen quote is not for this position and owner");
874
+ const signed = await signCashout({ quote: bid });
875
+ await request.onSigned?.(signed);
876
+ return signed;
877
+ });
878
+ return { ...(await sendAcceptance(signed, signal === undefined ? {} : { signal })), positionId };
879
+ }
880
+ /**
881
+ * Follow an operation to a terminal status (COMMITTED, REVERTED or
882
+ * EXPIRED_UNEXECUTED) from OPERATION_UPDATED, rereading it with
883
+ * `GET /v1/me/operations/{operation_id}` on VAULT_BALANCE_CHANGED too.
884
+ * Resolves at every terminal status: only COMMITTED executed, and a failed
885
+ * one's `failure_reason` says why when the venue knows (`entryFailure`
886
+ * words it for an entry).
887
+ */
888
+ function waitForOperation(operationId, signal) {
889
+ const terminal = new Set(["COMMITTED", "REVERTED", "EXPIRED_UNEXECUTED"]);
890
+ return followSent(stream, async (stop) => unwrap(await client.GET("/v1/me/operations/{operation_id}", { params: { path: { operation_id: operationId } }, signal: stop, fetch: recoveryFetch })).operation, (operation) => terminal.has(operation.status), (event) => event.event_type === "VAULT_BALANCE_CHANGED" ||
891
+ (event.event_type === "OPERATION_UPDATED" && event.payload.operation_id === operationId && terminal.has(event.payload.status)), signal, retry);
892
+ }
893
+ /**
894
+ * Sign a withdrawal to an external address without sending it: the Exit for
895
+ * any wallet shortfall and the USDC payout, whose nonce derives from
896
+ * `idempotencyKey`. Keep `{ idempotencyKey, account, body }` before sending it with
897
+ * `sendWithdrawal`, so a lost answer or a restart resends the same bytes
898
+ * under the same key instead of signing a second withdrawal.
899
+ */
900
+ async function signWithdrawal(request) {
901
+ const signer = await requireSigner();
902
+ const account = await signer.address();
903
+ const chain = await deployment();
904
+ const idempotencyKey = request.idempotencyKey ?? uuidv7();
905
+ const amount = BigInt(request.amount);
906
+ const destination = address(request.destination.toLowerCase());
907
+ const { wallet } = await readSpendableCash();
908
+ if (wallet === null)
909
+ throw new BalanceUnknownError("WALLET_USDC", "the Totalis wallet balance is unknown; read it again");
910
+ const shortfall = amount - wallet;
911
+ const issued = now();
912
+ let deadline = issued;
913
+ let vault_withdrawal;
914
+ if (shortfall > 0n) {
915
+ const exit = { amount: shortfall.toString(), expiry: String(issued + 600), salt: BigInt(randomBytes32()).toString() };
916
+ vault_withdrawal = { ...exit, signature: await signer.signTypedData(exitTypedData(chain, { account, to: account, ...exit })) };
917
+ deadline = issued + 600;
918
+ }
919
+ // The venue requires the payout to outlive the vault leg by 300 s; a minute more absorbs clock skew.
920
+ const window = { valid_after: String(issued - 60), valid_before: String(deadline + 360) };
921
+ const nonce = payoutNonce(chain, account, idempotencyKey);
922
+ const authorization_signature = await signer.signTypedData(transferWithAuthorizationTypedData(chain, { from: account, to: destination, value: amount.toString(), ...window, nonce }));
923
+ const body = {
924
+ destination, amount: amount.toString(), ...(vault_withdrawal === undefined ? {} : { vault_withdrawal }),
925
+ payout_authorization: { ...window, authorization_signature },
926
+ };
927
+ return { idempotencyKey, account, body };
928
+ }
929
+ /** POST a signed withdrawal to `/v1/me/withdrawals`, resending the same bytes on a lost answer. 202 is admission, not payment; a replay returns it as it stands. */
930
+ async function startWithdrawal(signed) {
931
+ const { account, body: { destination, amount } } = signed;
932
+ const started = await sendOnce(() => client.POST("/v1/me/withdrawals", { params: { header: { "Idempotency-Key": signed.idempotencyKey } }, body: signed.body }));
933
+ if (started.account !== account || started.destination !== destination || started.amount !== amount) {
934
+ throw new Error("the venue returned a withdrawal with different terms");
935
+ }
936
+ return started;
937
+ }
938
+ const withdrawalUpdate = (event) => event.event_type === "WITHDRAWAL_UPDATED" ? event.payload : undefined;
939
+ /**
940
+ * Resolve at PAYOUT_COMMITTED (reject at FAILED) from the account stream,
941
+ * whose WITHDRAWAL_UPDATED carries the whole withdrawal. The recovery read,
942
+ * `GET /v1/me/withdrawals/{withdrawal_id}`, runs only when the stream
943
+ * (re)connects, or at once when `known` is not given; nothing polls. A
944
+ * recovery read that fails transiently is retried with backoff until it
945
+ * answers, the stream settles the withdrawal, or the follow ends.
946
+ */
947
+ function followWithdrawal(withdrawalId, known, signal) {
948
+ stream.start();
949
+ return new Promise((resolve, reject) => {
950
+ const stop = new AbortController();
951
+ const offs = [];
952
+ const finish = (settle) => {
953
+ if (stop.signal.aborted)
954
+ return;
955
+ stop.abort();
956
+ for (const off of offs)
957
+ off();
958
+ signal?.removeEventListener("abort", abort);
959
+ settle();
960
+ };
961
+ const settle = (withdrawal) => {
962
+ if (withdrawal.withdrawal_id !== withdrawalId)
963
+ return;
964
+ if (withdrawal.status === "PAYOUT_COMMITTED")
965
+ finish(() => resolve(withdrawal));
966
+ else if (withdrawal.status === "FAILED")
967
+ finish(() => reject(new Error(`withdrawal ${withdrawalId} failed`)));
968
+ };
969
+ const recover = recoveryReads(stream, async (stop) => unwrap(await client.GET("/v1/me/withdrawals/{withdrawal_id}", { params: { path: { withdrawal_id: withdrawalId } }, signal: stop, fetch: recoveryFetch })), settle, (error) => finish(() => reject(error)), stop.signal, retry);
970
+ const abort = () => finish(() => reject(signal?.reason));
971
+ if (signal?.aborted)
972
+ return abort();
973
+ signal?.addEventListener("abort", abort, { once: true });
974
+ let live = stream.status === "live";
975
+ offs.push(stream.on("event", (event) => {
976
+ const withdrawal = withdrawalUpdate(event);
977
+ if (withdrawal !== undefined)
978
+ settle(withdrawal);
979
+ }), stream.on("status", (status) => {
980
+ if (status === "live" && !live)
981
+ void recover();
982
+ else if (status === "closed")
983
+ finish(() => reject(stream.closeError(true)));
984
+ live = status === "live";
985
+ }));
986
+ for (const withdrawal of known)
987
+ settle(withdrawal);
988
+ if (known.length === 0 && live)
989
+ void recover();
990
+ });
991
+ }
992
+ /**
993
+ * Send a signed withdrawal and follow it to PAYOUT_COMMITTED on the stream.
994
+ * A resend of the same `{ idempotencyKey, account, body }` (after a lost answer or a
995
+ * restart) returns the same withdrawal, never a second one.
996
+ */
997
+ async function sendWithdrawal(signed, options = {}) {
998
+ await stream.live(options.signal);
999
+ // Updates can arrive before the answer; keep them for the follow below.
1000
+ const early = [];
1001
+ const off = stream.on("event", (event) => {
1002
+ const withdrawal = withdrawalUpdate(event);
1003
+ if (withdrawal !== undefined)
1004
+ early.push(withdrawal);
1005
+ });
1006
+ let started;
1007
+ let followed;
1008
+ try {
1009
+ started = await startWithdrawal(signed);
1010
+ followed = followWithdrawal(started.withdrawal_id, [...early, started], options.signal);
1011
+ }
1012
+ finally {
1013
+ off();
1014
+ }
1015
+ // Settled or not, the follow is returned below; do not report it unhandled meanwhile.
1016
+ followed.catch(() => undefined);
1017
+ await options.onStarted?.(started.withdrawal_id);
1018
+ return followed;
1019
+ }
1020
+ /**
1021
+ * Withdraw to an external address in one request: any wallet shortfall
1022
+ * leaves the vault through a signed Exit and the signed payout follows it,
1023
+ * both relayed by Totalis. Resolves at PAYOUT_COMMITTED from the stream.
1024
+ */
1025
+ async function withdraw(request) {
1026
+ await stream.live(request.signal);
1027
+ return sendWithdrawal(await signWithdrawal(request), request);
1028
+ }
1029
+ /** Follow a withdrawal after a restart: one recovery read (retried while it fails transiently), then the stream. */
1030
+ async function waitForWithdrawal(withdrawalId, signal) {
1031
+ // The withdrawal was admitted, so a stream closed for good may still leave it to pay out.
1032
+ await stream.live(signal).catch((error) => Promise.reject(stream.status === "closed" ? stream.closeError(true) : error));
1033
+ return followWithdrawal(withdrawalId, [], signal);
1034
+ }
1035
+ /**
1036
+ * Sign an exit of free vault cash to the account's own HyperCore spot
1037
+ * account without sending it, for example before a native HyperCore order.
1038
+ * A Privy account's cash is swept to Core in the background; an external
1039
+ * wallet has no sweep (TT-1790), so its app does this when Core needs the
1040
+ * cash. The wallet signs an Exit to the CoreExit router (valid up to 10
1041
+ * minutes) and Totalis relays it, paying the gas. Persist
1042
+ * `{ exitId, body }`, then `sendCoreExit` it; a resend sends the same bytes.
1043
+ * One exit at a time per account (CORE_MOVE_IN_FLIGHT); an account that
1044
+ * isn't an embedded wallet moves at least 1 USDC, at most 10 times an hour
1045
+ * with its withdrawals. An account with no HyperCore account yet gets
1046
+ * CORE_ACCOUNT_ACTIVATION_REQUIRED until the user accepts Hyperliquid's
1047
+ * one-time 1 USDC activation fee, sent as `activationFeeAccepted` on an
1048
+ * exit of at least 2 USDC. A deployment without exits to HyperCore (staging)
1049
+ * throws `ExitUnavailableError` before anything is signed.
1050
+ */
1051
+ async function signCoreExit(request) {
1052
+ return signExit(request, await coreExitRoute());
1053
+ }
1054
+ async function coreExitRoute() {
1055
+ const route = unwrap(await client.GET("/v1/me/core-exits")).deployment;
1056
+ if (route === null)
1057
+ throw new ExitUnavailableError();
1058
+ return route;
1059
+ }
1060
+ async function signExit(request, route) {
1061
+ const signer = await requireSigner();
1062
+ const account = address((await signer.address()).toLowerCase());
1063
+ const chain = await deployment();
1064
+ const exitId = request.exitId ?? uuidv7();
1065
+ const to = address(route.router_address.toLowerCase());
1066
+ // A minute inside the venue's maximum life absorbs clock skew.
1067
+ const exit = { amount: BigInt(request.amount).toString(), expiry: String(now() + Math.min(route.maximum_life_seconds - 60, 600)), salt: BigInt(randomBytes32()).toString() };
1068
+ const signature = await signer.signTypedData(exitTypedData(chain, { account, to, ...exit }));
1069
+ const body = envelope({ account, to, ...exit, signature, purpose: request.purpose ?? "ORDER", ...(request.activationFeeAccepted ? { activation_fee_accepted: true } : {}) });
1070
+ return { exitId, account, body };
1071
+ }
1072
+ /**
1073
+ * Sign a claim of the account's free cash in a vault running off (`run_off`
1074
+ * in `GET /v1/me/balances`) without sending it: one Exit for that vault's
1075
+ * domain to the account's own wallet, so the cash lands in Cash. Persist
1076
+ * `{ claimId, body }`, then `sendRunOffClaim` it; a resend sends the same
1077
+ * bytes. A Privy account's run-off cash moves on its own; a wallet-only
1078
+ * account moves at least 1 USDC. A vault with no listed cash throws
1079
+ * `ExitUnavailableError`.
1080
+ */
1081
+ async function signRunOffClaim(request) {
1082
+ const vault = request.vaultAddress.toLowerCase();
1083
+ const cash = unwrap(await client.GET("/v1/me/balances")).run_off.find((item) => item.vault_address === vault);
1084
+ if (cash === undefined || cash.vault_free === "0")
1085
+ throw new ExitUnavailableError();
1086
+ const signer = await requireSigner();
1087
+ const account = address((await signer.address()).toLowerCase());
1088
+ const runOff = { chain_id: (await deployment()).chain_id, vault_address: cash.vault_address };
1089
+ const claimId = request.claimId ?? uuidv7();
1090
+ const exit = { amount: cash.vault_free, expiry: String(now() + 600), salt: BigInt(randomBytes32()).toString() };
1091
+ const signature = await signer.signTypedData(exitTypedData(runOff, { account, to: account, ...exit }));
1092
+ const body = envelope({ vault_address: address(cash.vault_address), account, to: account, ...exit, signature });
1093
+ return { claimId, account, body };
1094
+ }
1095
+ /** POST a signed run-off claim under its claim ID, resending the same bytes on a lost answer. The 202 is admission: follow `operation` with `waitForOperation`. */
1096
+ async function sendRunOffClaim(signed) {
1097
+ return sendOnce(() => client.POST("/v1/me/run-off-claims", { params: { header: { "Idempotency-Key": signed.claimId } }, body: signed.body }));
1098
+ }
1099
+ /**
1100
+ * POST a signed exit under its exit ID, resending the same bytes on a lost
1101
+ * answer. The 202 is admission: follow it with `waitForCoreExit`.
1102
+ * The venue's `CORE_EXIT_UNAVAILABLE`, answered only on a deployment that
1103
+ * runs no exits (staging), throws `ExitUnavailableError`.
1104
+ */
1105
+ async function sendCoreExit(signed) {
1106
+ try {
1107
+ return await sendOnce(() => client.POST("/v1/core-exits", { params: { header: { "Idempotency-Key": signed.exitId } }, body: signed.body }));
1108
+ }
1109
+ catch (error) {
1110
+ if (error instanceof ApiError && error.code === "CORE_EXIT_UNAVAILABLE") {
1111
+ throw new ExitUnavailableError({ cause: error });
1112
+ }
1113
+ throw error;
1114
+ }
1115
+ }
1116
+ /** Sign and send an exit to HyperCore in one call (`signCoreExit`, then `sendCoreExit`). */
1117
+ async function exitToCore(request) {
1118
+ return sendCoreExit(await signCoreExit(request));
1119
+ }
1120
+ /**
1121
+ * Sign a sponsored deposit of wallet USDC into the vault without sending it:
1122
+ * a USDC ReceiveWithAuthorization to the vault, valid for 10 minutes, that
1123
+ * Totalis relays and pays gas for. At least 1 USDC. Persist
1124
+ * `{ depositId, body }`, then `sendDeposit` it.
1125
+ */
1126
+ async function signDeposit(request) {
1127
+ const account = address((await (await requireSigner()).address()).toLowerCase());
1128
+ const { kind: _kind, ...authorization } = await depositAuthorization(await deployment(), account, BigInt(request.amount));
1129
+ const body = envelope({ account, from: account, ...authorization });
1130
+ return { depositId: request.depositId ?? uuidv7(), account, body };
1131
+ }
1132
+ /** POST a signed deposit under its ID, resending the same bytes on a lost answer. The 202 names the operation: follow it with `waitForOperation`. */
1133
+ function sendDeposit(signed) {
1134
+ return sendOnce(() => client.POST("/v1/me/deposits", { params: { header: { "Idempotency-Key": signed.depositId } }, body: signed.body }));
1135
+ }
1136
+ /**
1137
+ * Sign the return of the account's Totalis cash to HyperCore without
1138
+ * sending it: a deposit of the available wallet USDC (when at least 1 USDC)
1139
+ * and one exit of that plus the free vault cash. Persist the record, then
1140
+ * `sendReturnToCore` it, again after a restart. Both legs expire after
1141
+ * about 10 minutes; past that, sign a new return. Only for an external
1142
+ * wallet: Totalis already moves an embedded wallet's vault cash to Core in
1143
+ * the background, so that account just `signDeposit`s its wallet USDC.
1144
+ * Throws `ExitUnavailableError` without exits to HyperCore and
1145
+ * `BalanceUnknownError` while a balance is unknown, before signing anything.
1146
+ */
1147
+ async function signReturnToCore(request = {}) {
1148
+ const { wallet_kind } = unwrap(await client.GET("/v1/me"));
1149
+ if (wallet_kind !== "EXTERNAL")
1150
+ throw new Error("Totalis moves an embedded wallet's cash to HyperCore itself; deposit its wallet USDC with signDeposit instead");
1151
+ const route = await coreExitRoute();
1152
+ const { vault, wallet } = await readSpendableCash();
1153
+ if (vault === null)
1154
+ throw new BalanceUnknownError("VAULT_FREE", "the free vault balance is unknown; read it again");
1155
+ if (wallet === null)
1156
+ throw new BalanceUnknownError("WALLET_USDC", "the Totalis wallet balance is unknown; read it again");
1157
+ const moved = wallet >= minimumDeposit ? wallet : 0n;
1158
+ const amount = vault + moved;
1159
+ if (amount <= 0n)
1160
+ throw new Error("there is no Totalis cash to return to HyperCore");
1161
+ const deposit = moved > 0n ? await signDeposit({ amount: moved, ...(request.depositId === undefined ? {} : { depositId: request.depositId }) }) : undefined;
1162
+ const { depositId: _depositId, ...exit } = request;
1163
+ return { ...(deposit === undefined ? {} : { deposit }), exit: await signExit({ ...exit, amount }, route) };
1164
+ }
1165
+ /**
1166
+ * Send a signed return: the deposit, then, once its operation COMMITTED, the
1167
+ * exit (an exit admitted before the deposit lands is refused for too little
1168
+ * free cash). Resolves with the admitted exit; follow it with `waitForCoreExit`.
1169
+ */
1170
+ async function sendReturnToCore(signed, options = {}) {
1171
+ if (signed.deposit !== undefined) {
1172
+ await stream.live(options.signal);
1173
+ const { operation_id } = await sendDeposit(signed.deposit);
1174
+ const operation = await waitForOperation(operation_id, options.signal);
1175
+ if (operation.status !== "COMMITTED")
1176
+ throw new Error(`deposit ${operation_id} ended ${operation.status}; nothing left the vault`);
1177
+ }
1178
+ return sendCoreExit(signed.exit);
1179
+ }
1180
+ /** Return wallet USDC and free vault cash to HyperCore in one call (`signReturnToCore`, then `sendReturnToCore`). */
1181
+ async function returnToCore(request = {}) {
1182
+ const { signal, ...terms } = request;
1183
+ return sendReturnToCore(await signReturnToCore(terms), signal === undefined ? {} : { signal });
1184
+ }
1185
+ /** Follow an exit on the stream until its cash is credited on HyperCore; it rejects if the exit ends any other way. */
1186
+ async function waitForCoreExit(exitId, signal) {
1187
+ const terminal = new Set(["CREDITED", "RECOVERED", "FAILED", "ABANDONED"]);
1188
+ const exit = await followSent(stream, async (stop) => unwrap(await client.GET("/v1/core-exits/{exit_id}", { params: { path: { exit_id: exitId } }, signal: stop, fetch: recoveryFetch })), (value) => terminal.has(value.status), (event) => event.event_type === "CORE_EXIT_UPDATED" && event.payload.exit_id === exitId, signal, retry);
1189
+ if (exit.status !== "CREDITED")
1190
+ throw new Error(`core exit ${exitId} ended ${exit.status}, not credited on HyperCore`);
1191
+ return exit;
1192
+ }
1193
+ /**
1194
+ * The transactions a wallet sends itself to deposit USDC from HyperEVM into
1195
+ * its vault balance, paying its own gas: approve the vault for `amount`, then
1196
+ * `depositUser(account, amount)`. Totalis indexes the deposit from its event;
1197
+ * no API call follows. Send them with the wallet's own provider, in order.
1198
+ */
1199
+ async function directDepositCalls(amount) {
1200
+ const account = address((await (await requireSigner()).address()).toLowerCase());
1201
+ const chain = await deployment();
1202
+ const value = BigInt(amount);
1203
+ if (value <= 0n || value >= 1n << 128n)
1204
+ throw new RangeError("deposit amount must be a positive uint128");
1205
+ const vault = address(chain.vault_address.toLowerCase());
1206
+ return [
1207
+ { to: address(chain.usdc.address), data: callData("approve(address,uint256)", vault, value) },
1208
+ { to: vault, data: callData("depositUser(address,uint128)", account, value) },
1209
+ ];
1210
+ }
1211
+ return {
1212
+ /** The typed HTTP client over the same credential, for reads the flows do not cover. */
1213
+ client,
1214
+ /** The account stream. Flows start it on first use; `close()` it when done. */
1215
+ stream,
1216
+ deployment,
1217
+ /** Spendable Totalis cash from a fresh balances read (`spendableCash`): `{ vault, wallet, total }`, each `null` while unknown, never 0. */
1218
+ spendableCash: readSpendableCash,
1219
+ openPosition,
1220
+ cashOut,
1221
+ signEntry,
1222
+ signCashout,
1223
+ sendAcceptance,
1224
+ recoverAcceptance,
1225
+ signWithdrawal,
1226
+ sendWithdrawal,
1227
+ withdraw,
1228
+ waitForWithdrawal,
1229
+ waitForOperation,
1230
+ signCoreExit,
1231
+ signRunOffClaim,
1232
+ sendRunOffClaim,
1233
+ sendCoreExit,
1234
+ exitToCore,
1235
+ waitForCoreExit,
1236
+ signDeposit,
1237
+ sendDeposit,
1238
+ signReturnToCore,
1239
+ sendReturnToCore,
1240
+ returnToCore,
1241
+ directDepositCalls,
1242
+ /** Render an RFQ's live quotes (entry or cash-out) from the stream; `[]` while the stream is down. */
1243
+ watchQuotes,
1244
+ /** Issue a personal `ttk_` key; the signer must be the account wallet (embedded, or a wallet-only account's own). */
1245
+ issueApiKey: async (request) => issueApiKey(keys, await requireSigner(), request),
1246
+ rotateApiKey: async (keyId, request) => rotateApiKey(keys, await requireSigner(), keyId, request),
1247
+ close: () => stream.close(),
1248
+ };
1249
+ }
1250
+ export { BalanceUnknownError, EntryError, ExitUnavailableError };
1251
+ export { ApiError, NetworkError };