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