@totalis/hip4-client 0.65.2 → 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.
- package/CHANGELOG.md +362 -0
- package/dist/controller.d.ts +5 -2
- package/dist/controller.js +5 -2
- package/dist/flow-errors.d.ts +4 -3
- package/dist/flow-errors.js +1 -1
- package/dist/generated/account-read.d.ts +11 -5
- package/dist/generated/api-keys.d.ts +1 -1
- package/dist/generated/browse.d.ts +38 -11
- package/dist/generated/client.d.ts +33 -2
- package/dist/generated/errors.d.ts +72 -9
- package/dist/generated/errors.js +456 -6
- package/dist/generated/openapi.d.ts +6590 -3767
- package/dist/generated/realtime.d.ts +85 -15
- package/dist/generated/vault-abi.d.ts +10 -0
- package/dist/generated/vault-abi.js +15 -1
- package/dist/generated/version.d.ts +15 -14
- package/dist/generated/version.js +55 -15
- package/dist/hypercore.d.ts +1 -1
- package/dist/hypercore.js +1 -1
- package/dist/maker.d.ts +1 -0
- package/dist/maker.js +1 -1
- package/dist/partner.d.ts +14 -2
- package/dist/partner.js +11 -2
- package/dist/request.d.ts +7 -4
- package/dist/request.js +6 -3
- package/dist/stream.d.ts +11 -2
- package/dist/stream.js +17 -4
- package/dist/totalis.d.ts +75 -12
- package/dist/totalis.js +141 -50
- package/dist/wallet-session.d.ts +8 -1
- package/dist/wallet-session.js +4 -2
- package/examples/onboard.mjs +2 -2
- package/package.json +2 -1
package/dist/stream.d.ts
CHANGED
|
@@ -64,12 +64,18 @@ type Listeners = {
|
|
|
64
64
|
frame: (frame: StreamFrame) => void;
|
|
65
65
|
error: (error: unknown) => void;
|
|
66
66
|
};
|
|
67
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* The stream closed for good: the server's NEVER reason (for example
|
|
69
|
+
* SUBSCRIPTION_DENIED or STREAM_REPLACED), or `closed` after `close()`. It
|
|
70
|
+
* will not reconnect. `commandSent`: it cut off a follow of a command already
|
|
71
|
+
* sent, which may still execute.
|
|
72
|
+
*/
|
|
68
73
|
export declare class StreamClosedError extends Error {
|
|
69
74
|
readonly reason: string;
|
|
70
75
|
readonly closeCode: number;
|
|
76
|
+
readonly commandSent: boolean;
|
|
71
77
|
readonly name = "StreamClosedError";
|
|
72
|
-
constructor(reason: string, closeCode: number);
|
|
78
|
+
constructor(reason: string, closeCode: number, commandSent?: boolean);
|
|
73
79
|
}
|
|
74
80
|
/** The account and maker stream subprotocol. */
|
|
75
81
|
export declare const realtimeProtocol = "totalis.realtime.v2";
|
|
@@ -95,6 +101,7 @@ export declare class TotalisStream {
|
|
|
95
101
|
/** The last applied cursor. Persist it to resume after a restart. */
|
|
96
102
|
cursor: RealtimeCursor | undefined;
|
|
97
103
|
private clockOffsetMs;
|
|
104
|
+
private closedWith;
|
|
98
105
|
private socket;
|
|
99
106
|
private attempt;
|
|
100
107
|
private nextDelayMs;
|
|
@@ -109,6 +116,8 @@ export declare class TotalisStream {
|
|
|
109
116
|
on<K extends keyof Listeners>(type: K, listener: Listeners[K]): () => void;
|
|
110
117
|
start(): this;
|
|
111
118
|
close(): void;
|
|
119
|
+
/** Why the stream closed for good, as an error; `commandSent` marks a follow of a command already sent. */
|
|
120
|
+
closeError(commandSent?: boolean): StreamClosedError;
|
|
112
121
|
/** The venue clock in ms, from the last subscription response or publication checkpoint's `server_time`. */
|
|
113
122
|
serverNow(): number;
|
|
114
123
|
/** Resolves once the stream is live (starting it if needed); rejects if it closes for good. */
|
package/dist/stream.js
CHANGED
|
@@ -4,15 +4,22 @@ import { backoffDelay } from "./request.js";
|
|
|
4
4
|
const inBrowser = () => typeof globalThis.document !== "undefined";
|
|
5
5
|
// Node's WebSocket (undici) and Bun accept `{ protocols, headers }` in place of protocols.
|
|
6
6
|
const openWebSocket = (url, protocols, headers) => inBrowser() ? new WebSocket(url, protocols) : new WebSocket(url, { protocols, headers });
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* The stream closed for good: the server's NEVER reason (for example
|
|
9
|
+
* SUBSCRIPTION_DENIED or STREAM_REPLACED), or `closed` after `close()`. It
|
|
10
|
+
* will not reconnect. `commandSent`: it cut off a follow of a command already
|
|
11
|
+
* sent, which may still execute.
|
|
12
|
+
*/
|
|
8
13
|
export class StreamClosedError extends Error {
|
|
9
14
|
reason;
|
|
10
15
|
closeCode;
|
|
16
|
+
commandSent;
|
|
11
17
|
name = "StreamClosedError";
|
|
12
|
-
constructor(reason, closeCode) {
|
|
18
|
+
constructor(reason, closeCode, commandSent = false) {
|
|
13
19
|
super(`stream closed: ${reason || closeCode}`);
|
|
14
20
|
this.reason = reason;
|
|
15
21
|
this.closeCode = closeCode;
|
|
22
|
+
this.commandSent = commandSent;
|
|
16
23
|
}
|
|
17
24
|
}
|
|
18
25
|
/** The account and maker stream subprotocol. */
|
|
@@ -50,6 +57,7 @@ export class TotalisStream {
|
|
|
50
57
|
/** The last applied cursor. Persist it to resume after a restart. */
|
|
51
58
|
cursor;
|
|
52
59
|
clockOffsetMs = 0;
|
|
60
|
+
closedWith;
|
|
53
61
|
socket;
|
|
54
62
|
attempt = 0;
|
|
55
63
|
nextDelayMs;
|
|
@@ -83,6 +91,10 @@ export class TotalisStream {
|
|
|
83
91
|
this.socket = undefined;
|
|
84
92
|
socket?.close(1000);
|
|
85
93
|
}
|
|
94
|
+
/** Why the stream closed for good, as an error; `commandSent` marks a follow of a command already sent. */
|
|
95
|
+
closeError(commandSent = false) {
|
|
96
|
+
return new StreamClosedError(this.closedWith?.reason ?? "closed", this.closedWith?.code ?? 1000, commandSent);
|
|
97
|
+
}
|
|
86
98
|
/** The venue clock in ms, from the last subscription response or publication checkpoint's `server_time`. */
|
|
87
99
|
serverNow() {
|
|
88
100
|
return Date.now() + this.clockOffsetMs;
|
|
@@ -115,7 +127,7 @@ export class TotalisStream {
|
|
|
115
127
|
if (value !== undefined)
|
|
116
128
|
done(() => resolve(value));
|
|
117
129
|
else if (this.status === "closed")
|
|
118
|
-
done(() => reject(
|
|
130
|
+
done(() => reject(this.closeError()));
|
|
119
131
|
};
|
|
120
132
|
const abort = () => done(() => reject(signal?.reason));
|
|
121
133
|
if (signal?.aborted)
|
|
@@ -281,8 +293,9 @@ export class TotalisStream {
|
|
|
281
293
|
// A renewable session may retry a revoked credential once with a fresh token.
|
|
282
294
|
const renew = reason === "AUTHORIZATION_REVOKED" && typeof this.options.accessToken === "function" && !this.reauthorized;
|
|
283
295
|
if (retry === "NEVER" && !renew) {
|
|
296
|
+
this.closedWith = { reason, code };
|
|
284
297
|
this.close();
|
|
285
|
-
this.emit("error",
|
|
298
|
+
this.emit("error", this.closeError());
|
|
286
299
|
return;
|
|
287
300
|
}
|
|
288
301
|
if (renew)
|
package/dist/totalis.d.ts
CHANGED
|
@@ -115,8 +115,11 @@ export type SignedAcceptance = SignedEntry | SignedCashout;
|
|
|
115
115
|
/** Awaited with an entry record whose `bridgeState` changed; persist it over the stored one. */
|
|
116
116
|
export type RecordListener = (record: SignedEntry) => void | Promise<void>;
|
|
117
117
|
/**
|
|
118
|
-
* Where an acceptance ended. `ACCEPTED` carries `operationId`;
|
|
119
|
-
*
|
|
118
|
+
* Where an acceptance ended. `ACCEPTED` carries `operationId`; so do
|
|
119
|
+
* `FILLED` (it committed) and `FAILED` (it reverted or expired unexecuted;
|
|
120
|
+
* the operation's `failure_reason` says why when it is known), which a later read, such as
|
|
121
|
+
* `recoverAcceptance`'s, can return. `NOT_ADMITTED` means the venue never
|
|
122
|
+
* took the accept and nothing traded. `bridged` is set
|
|
120
123
|
* for an entry whose bridge was sent: unless its operation `COMMITTED`, that
|
|
121
124
|
* cash is in the HyperEVM wallet, not on HyperCore. `bridging` is set when
|
|
122
125
|
* this call sent the bridge, which never holds up the result.
|
|
@@ -130,6 +133,7 @@ export type AcceptanceResult = {
|
|
|
130
133
|
readonly status: (Acceptance | NotAdmitted)["status"];
|
|
131
134
|
readonly operationId: string | undefined;
|
|
132
135
|
readonly outcome: Acceptance | NotAdmitted;
|
|
136
|
+
/** Read as the bridge stands now: `landed` turns `yes` once Hyperliquid's `ok` is recorded, and a bridge refused or discarded unsent reads undefined. */
|
|
133
137
|
readonly bridged?: BridgedCash;
|
|
134
138
|
/**
|
|
135
139
|
* Hyperliquid's answer to the bridge, once `onRecord` has recorded it;
|
|
@@ -145,15 +149,17 @@ export type AcceptanceResult = {
|
|
|
145
149
|
* `CORE_MOVE_MISSED`: it fell short with Core cash planned to cover it that
|
|
146
150
|
* Hyperliquid never confirmed moving in time (never sent, discarded, refused,
|
|
147
151
|
* or sent without an `ok`); that cash is on HyperCore or, if `bridged`, in
|
|
148
|
-
* the wallet.
|
|
152
|
+
* the wallet. Any other value is the operation's own `failure_reason`, such as
|
|
153
|
+
* `DEADLINE_BEFORE_CLAIM` when the venue reached it too late; nothing traded
|
|
154
|
+
* and its cash stayed where it was.
|
|
149
155
|
*/
|
|
150
|
-
export type EntryFailure = "
|
|
156
|
+
export type EntryFailure = "CORE_MOVE_MISSED" | NonNullable<Operation["failure_reason"]>;
|
|
151
157
|
/**
|
|
152
|
-
* An entry's `EntryFailure`; undefined unless its operation ended with
|
|
153
|
-
* `failure_reason
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
158
|
+
* An entry's `EntryFailure`; undefined unless its operation ended with a
|
|
159
|
+
* `failure_reason`. Pass the record as `onRecord` last handed it back (or
|
|
160
|
+
* `openPosition`'s `record`): only `LANDED` shows the Core cash moved before
|
|
161
|
+
* `fund_by`. `PROCESSED` only says the nonce is spent, which a refused or
|
|
162
|
+
* expired send also does, so it does not show the move.
|
|
157
163
|
*/
|
|
158
164
|
export declare function entryFailure(record: SignedEntry | LegacySignedEntry, operation: Pick<Operation, "failure_reason">): EntryFailure | undefined;
|
|
159
165
|
/** When an entry quote stops being acceptable, in ms on the venue clock: its `accept_before`. A quote without a readable one never is. */
|
|
@@ -166,8 +172,11 @@ export declare function acceptBefore(quote: EntryQuote): number;
|
|
|
166
172
|
export type Chooser<Offer> = (offers: readonly Offer[], serverNowMs: number) => Offer | undefined;
|
|
167
173
|
/** The highest payout among quotes still before their `accept_before` (ties go to the earliest). */
|
|
168
174
|
export declare function bestQuote(): Chooser<EntryQuote>;
|
|
169
|
-
/**
|
|
170
|
-
|
|
175
|
+
/**
|
|
176
|
+
* The highest price with at least `minLifeSeconds` left (execution needs 20 s
|
|
177
|
+
* after confirmation) and, given `minPrice` in USDC base units, no lower than it.
|
|
178
|
+
*/
|
|
179
|
+
export declare function bestBid(minLifeSeconds?: number, minPrice?: bigint): Chooser<CashoutBid>;
|
|
171
180
|
export type TotalisOptions = Omit<ApiClientOptions, "accessToken"> & {
|
|
172
181
|
readonly environment: PublicEnvironment;
|
|
173
182
|
/** The WebSocket origin for the stream. Defaults to `baseUrl` if set, else the environment's stream host (`wss://hip4-ws.totalis.trade` in production). */
|
|
@@ -259,6 +268,7 @@ export declare function createTotalis(options: TotalisOptions): {
|
|
|
259
268
|
readonly expiry: import("./generated/openapi.js").components["schemas"]["UInt64String"];
|
|
260
269
|
readonly salt: import("./generated/openapi.js").components["schemas"]["Uint256"];
|
|
261
270
|
};
|
|
271
|
+
bridged: BridgedCash | undefined;
|
|
262
272
|
record: SignedEntry;
|
|
263
273
|
acceptanceId: string;
|
|
264
274
|
rfqId: string;
|
|
@@ -267,7 +277,6 @@ export declare function createTotalis(options: TotalisOptions): {
|
|
|
267
277
|
status: (Acceptance | NotAdmitted)["status"];
|
|
268
278
|
operationId: string | undefined;
|
|
269
279
|
outcome: Acceptance | NotAdmitted;
|
|
270
|
-
bridged?: BridgedCash;
|
|
271
280
|
bridging?: Promise<HypercoreAnswer>;
|
|
272
281
|
}>;
|
|
273
282
|
cashOut: (request: {
|
|
@@ -365,6 +374,60 @@ export declare function createTotalis(options: TotalisOptions): {
|
|
|
365
374
|
};
|
|
366
375
|
};
|
|
367
376
|
}>;
|
|
377
|
+
signRunOffClaim: (request: {
|
|
378
|
+
readonly vaultAddress: string;
|
|
379
|
+
readonly claimId?: string;
|
|
380
|
+
}) => Promise<{
|
|
381
|
+
claimId: string;
|
|
382
|
+
account: `0x${string}`;
|
|
383
|
+
body: {
|
|
384
|
+
readonly schema_version: 1;
|
|
385
|
+
readonly request_id: import("./generated/openapi.js").components["schemas"]["Uuid"];
|
|
386
|
+
readonly sent_at: import("./generated/openapi.js").components["schemas"]["$defs-Timestamp"];
|
|
387
|
+
readonly command: {
|
|
388
|
+
readonly vault_address: import("./generated/openapi.js").components["schemas"]["LowercaseNonZeroAddress"];
|
|
389
|
+
readonly account: import("./generated/openapi.js").components["schemas"]["LowercaseNonZeroAddress"];
|
|
390
|
+
readonly to: import("./generated/openapi.js").components["schemas"]["LowercaseNonZeroAddress"];
|
|
391
|
+
readonly amount: import("./generated/openapi.js").components["schemas"]["$defs-Amount"];
|
|
392
|
+
readonly expiry: import("./generated/openapi.js").components["schemas"]["UInt64String"];
|
|
393
|
+
readonly salt: import("./generated/openapi.js").components["schemas"]["Uint"];
|
|
394
|
+
readonly signature: import("./generated/openapi.js").components["schemas"]["ContractSignature"];
|
|
395
|
+
};
|
|
396
|
+
};
|
|
397
|
+
}>;
|
|
398
|
+
sendRunOffClaim: (signed: Awaited<ReturnType<(request: {
|
|
399
|
+
readonly vaultAddress: string;
|
|
400
|
+
readonly claimId?: string;
|
|
401
|
+
}) => Promise<{
|
|
402
|
+
claimId: string;
|
|
403
|
+
account: `0x${string}`;
|
|
404
|
+
body: {
|
|
405
|
+
readonly schema_version: 1;
|
|
406
|
+
readonly request_id: import("./generated/openapi.js").components["schemas"]["Uuid"];
|
|
407
|
+
readonly sent_at: import("./generated/openapi.js").components["schemas"]["$defs-Timestamp"];
|
|
408
|
+
readonly command: {
|
|
409
|
+
readonly vault_address: import("./generated/openapi.js").components["schemas"]["LowercaseNonZeroAddress"];
|
|
410
|
+
readonly account: import("./generated/openapi.js").components["schemas"]["LowercaseNonZeroAddress"];
|
|
411
|
+
readonly to: import("./generated/openapi.js").components["schemas"]["LowercaseNonZeroAddress"];
|
|
412
|
+
readonly amount: import("./generated/openapi.js").components["schemas"]["$defs-Amount"];
|
|
413
|
+
readonly expiry: import("./generated/openapi.js").components["schemas"]["UInt64String"];
|
|
414
|
+
readonly salt: import("./generated/openapi.js").components["schemas"]["Uint"];
|
|
415
|
+
readonly signature: import("./generated/openapi.js").components["schemas"]["ContractSignature"];
|
|
416
|
+
};
|
|
417
|
+
};
|
|
418
|
+
}>>>) => Promise<{
|
|
419
|
+
readonly vault_address: import("./generated/openapi.js").components["schemas"]["LowercaseNonZeroAddress"];
|
|
420
|
+
readonly operation: {
|
|
421
|
+
readonly operation_id: import("./generated/openapi.js").components["schemas"]["Bytes32"];
|
|
422
|
+
readonly kind: string;
|
|
423
|
+
readonly amount: import("./generated/openapi.js").components["schemas"]["$defs-Amount"] | null;
|
|
424
|
+
readonly status: import("./generated/openapi.js").components["schemas"]["OperationState"];
|
|
425
|
+
readonly transaction_hash: import("./generated/openapi.js").components["schemas"]["Bytes32"] | null;
|
|
426
|
+
readonly failure_reason?: import("./generated/openapi.js").components["schemas"]["OperationFailureReason"];
|
|
427
|
+
readonly created_at: import("./generated/openapi.js").components["schemas"]["$defs-Timestamp"];
|
|
428
|
+
readonly updated_at: import("./generated/openapi.js").components["schemas"]["$defs-Timestamp"];
|
|
429
|
+
};
|
|
430
|
+
}>;
|
|
368
431
|
sendCoreExit: (signed: {
|
|
369
432
|
exitId: string;
|
|
370
433
|
account: `0x${string}`;
|
package/dist/totalis.js
CHANGED
|
@@ -4,9 +4,9 @@ import { audienceClient, headerRecord } from "./base.js";
|
|
|
4
4
|
import { bridgeAction, bridgeGasBuffer, hyperliquidExchange, hyperliquidNetwork, settleHypercore, signSendAsset, usdcUnits, } from "./hypercore.js";
|
|
5
5
|
import { BalanceUnknownError, EntryError, ExitUnavailableError, normalizeError } from "./flow-errors.js";
|
|
6
6
|
import { spendableCash } from "./money.js";
|
|
7
|
-
import { ApiError, backoffDelay, envelope, NetworkError, randomBytes32, resendable, sendOnce, singleFetch, sleep, unwrap, uuidv7 } from "./request.js";
|
|
7
|
+
import { ApiError, backoffDelay, envelope, NetworkError, randomBytes32, resendable, retryingFetch, sendOnce, singleFetch, sleep, unwrap, uuidv7 } from "./request.js";
|
|
8
8
|
import { toTotalisSigner } from "./signer.js";
|
|
9
|
-
import {
|
|
9
|
+
import { TotalisStream } from "./stream.js";
|
|
10
10
|
import { address, cashoutTransferTypedData, exitTypedData, receiveWithAuthorizationTypedData, sellBackTypedData, takerAcceptTypedData, payoutNonce, transferWithAuthorizationTypedData, } from "./typed-data.js";
|
|
11
11
|
/**
|
|
12
12
|
* What an entry's bridge may have moved to its wallet, by its state: nothing
|
|
@@ -21,15 +21,15 @@ function bridgedCash(signed) {
|
|
|
21
21
|
return landed === undefined ? undefined : { amount: usdcUnits(signed.bridge.action.amount).toString(), landed };
|
|
22
22
|
}
|
|
23
23
|
/**
|
|
24
|
-
* An entry's `EntryFailure`; undefined unless its operation ended with
|
|
25
|
-
* `failure_reason
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
29
|
*/
|
|
30
30
|
export function entryFailure(record, operation) {
|
|
31
31
|
if (operation.failure_reason !== "INSUFFICIENT_CASH")
|
|
32
|
-
return
|
|
32
|
+
return operation.failure_reason;
|
|
33
33
|
if (record.bridge === undefined || "version" in record && record.bridgeState === "LANDED")
|
|
34
34
|
return "INSUFFICIENT_CASH";
|
|
35
35
|
return "CORE_MOVE_MISSED";
|
|
@@ -96,13 +96,15 @@ export function bestQuote() {
|
|
|
96
96
|
.filter((quote) => acceptBefore(quote) > now)
|
|
97
97
|
.reduce((best, quote) => best === undefined || BigInt(quote.entry.payout) > BigInt(best.entry.payout) ? quote : best, undefined);
|
|
98
98
|
}
|
|
99
|
-
/**
|
|
100
|
-
|
|
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) {
|
|
101
104
|
return (bids, now) => bids
|
|
102
|
-
.filter((bid) => Number(termsOf(bid).expiry) * 1000 - now >= minLifeSeconds * 1000)
|
|
105
|
+
.filter((bid) => Number(termsOf(bid).expiry) * 1000 - now >= minLifeSeconds * 1000 && (minPrice === undefined || BigInt(termsOf(bid).price) >= minPrice))
|
|
103
106
|
.reduce((best, bid) => best === undefined || BigInt(termsOf(bid).price) > BigInt(termsOf(best).price) ? bid : best, undefined);
|
|
104
107
|
}
|
|
105
|
-
const now = () => Math.floor(Date.now() / 1000);
|
|
106
108
|
/** ABI calldata for a call whose arguments are all static words (addresses and integers). */
|
|
107
109
|
function callData(signature, ...words) {
|
|
108
110
|
const selector = Array.from(keccak_256(new TextEncoder().encode(signature)).slice(0, 4), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
@@ -191,7 +193,11 @@ function recoveryReads(stream, read, onValue, fail, stop, retry = {}) {
|
|
|
191
193
|
export function follow(stream, read, done, wake, signal, retry) {
|
|
192
194
|
return followUntil(stream, read, done, wake, () => undefined, signal, retry);
|
|
193
195
|
}
|
|
194
|
-
|
|
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) {
|
|
195
201
|
stream.start();
|
|
196
202
|
return new Promise((resolve, reject) => {
|
|
197
203
|
const stop = new AbortController();
|
|
@@ -220,18 +226,21 @@ function followUntil(stream, read, done, wake, recheckMs, signal, retry) {
|
|
|
220
226
|
const abort = () => finish(() => reject(signal?.reason));
|
|
221
227
|
if (signal?.aborted)
|
|
222
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)));
|
|
223
232
|
signal?.addEventListener("abort", abort, { once: true });
|
|
224
233
|
offs.push(stream.on("event", (event) => void (wake(event) && check())), stream.on("status", (status) => {
|
|
225
234
|
if (status === "live")
|
|
226
235
|
void check();
|
|
227
236
|
else if (status === "closed")
|
|
228
|
-
finish(() => reject(
|
|
237
|
+
finish(() => reject(stream.closeError(commandSent)));
|
|
229
238
|
}));
|
|
230
239
|
void check();
|
|
231
240
|
});
|
|
232
241
|
}
|
|
233
242
|
// 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";
|
|
243
|
+
const rfqEnded = (event) => event.event_type === "RFQ_CANCELLED" || event.event_type === "RFQ_FAILED" || event.event_type === "RFQ_ACCEPTED" || event.event_type === "RFQ_DECLINED";
|
|
235
244
|
const quotesChanged = (rfqId) => (event) => (event.event_type === "QUOTE_CREATED" || event.event_type === "QUOTE_REMOVED" || rfqEnded(event)) && event.payload.rfq_id === rfqId;
|
|
236
245
|
/** The venue refuses to accept a cash-out quote with this much signed life left or less. */
|
|
237
246
|
const cashoutAcceptLifeMs = 22_000;
|
|
@@ -314,8 +323,18 @@ export function createTotalis(options) {
|
|
|
314
323
|
...(clientOptions.baseUrl === undefined ? {} : { baseUrl: clientOptions.baseUrl }),
|
|
315
324
|
...(streamBaseUrl === undefined ? {} : { streamBaseUrl }),
|
|
316
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);
|
|
317
328
|
const retry = clientOptions.retry || undefined;
|
|
318
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
|
+
};
|
|
319
338
|
const signer = signerInput === undefined ? undefined : toTotalisSigner(signerInput);
|
|
320
339
|
const requireSigner = () => signer ?? Promise.reject(new Error("this flow needs a signer"));
|
|
321
340
|
let deploymentRead;
|
|
@@ -476,13 +495,15 @@ export function createTotalis(options) {
|
|
|
476
495
|
throw new Error(`the venue answered for acceptance ${acceptance.acceptance_id} on quote ${acceptance.quote_id}, not this record's`);
|
|
477
496
|
}
|
|
478
497
|
}
|
|
479
|
-
const resultOf = (signed, outcome, bridge) =>
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
+
};
|
|
486
507
|
/** A bridge this SDK signed and never sent, which may go only before `fund_by`. */
|
|
487
508
|
const bridgeUnsent = (signed) => signed.kind === "ENTRY" && "version" in signed && signed.bridge !== undefined && signed.bridgeState === undefined;
|
|
488
509
|
/** Record a never-sent bridge as discarded. Recovery derives the discard from the acceptance again, so a failed save loses nothing. */
|
|
@@ -512,19 +533,19 @@ export function createTotalis(options) {
|
|
|
512
533
|
if (signed.kind !== "ENTRY" || signed.bridge === undefined)
|
|
513
534
|
return {};
|
|
514
535
|
if (!("version" in signed))
|
|
515
|
-
return { bridged: bridgedCash(signed) };
|
|
536
|
+
return { bridged: () => bridgedCash(signed) };
|
|
516
537
|
const { bridge, bridgeState } = signed;
|
|
517
538
|
if (bridgeState !== undefined && bridgeState !== "SENT")
|
|
518
|
-
return { bridged: bridgedCash(signed) };
|
|
539
|
+
return { bridged: () => bridgedCash(signed) };
|
|
519
540
|
const ours = outcome.status === "NOT_ADMITTED" || outcome.acceptance_id === signed.acceptanceId;
|
|
520
541
|
if (!ours && bridgeState === "SENT")
|
|
521
|
-
return { bridged: bridgedCash(signed) };
|
|
542
|
+
return { bridged: () => bridgedCash(signed) };
|
|
522
543
|
const until = ours && outcome.status === "ACCEPTED" ? fundBy(outcome) : undefined;
|
|
523
544
|
if (until === undefined || clock.latest() >= until) {
|
|
524
545
|
if (ours && outcome.status === "PENDING_CONFIRM")
|
|
525
546
|
return {};
|
|
526
547
|
await discardBridge(signed, options.onRecord);
|
|
527
|
-
return { bridged: bridgedCash(signed) };
|
|
548
|
+
return { bridged: () => bridgedCash(signed) };
|
|
528
549
|
}
|
|
529
550
|
const sent = { ...signed, bridgeState: "SENT" };
|
|
530
551
|
if (bridgeState === undefined) {
|
|
@@ -543,16 +564,21 @@ export function createTotalis(options) {
|
|
|
543
564
|
}
|
|
544
565
|
const transport = options.transport ?? defaultTransport;
|
|
545
566
|
let attempted = false;
|
|
567
|
+
let latest = sent;
|
|
546
568
|
const bridging = settleHypercore(bridge, { transport: (action, signal) => { attempted = true; return transport(action, signal); }, deadline: until, now: clock.latest })
|
|
547
569
|
.then(async (answer) => {
|
|
548
|
-
if (answer.kind !== "unknown")
|
|
549
|
-
|
|
550
|
-
|
|
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" };
|
|
551
576
|
await saveDiscard(signed, options.onRecord);
|
|
577
|
+
}
|
|
552
578
|
return answer;
|
|
553
579
|
});
|
|
554
580
|
bridging.catch(() => undefined);
|
|
555
|
-
return { bridged: bridgedCash(
|
|
581
|
+
return { bridged: () => bridgedCash(latest), bridging };
|
|
556
582
|
}
|
|
557
583
|
/** The signer, account and deployment a new record is signed under. */
|
|
558
584
|
async function recordScope() {
|
|
@@ -561,9 +587,11 @@ export function createTotalis(options) {
|
|
|
561
587
|
const chain = await deployment();
|
|
562
588
|
return { signer, account, chain, scope: { account, chainId: chain.chain_id, vault: address(chain.vault_address.toLowerCase()) } };
|
|
563
589
|
}
|
|
590
|
+
/** A stored record signs nothing more, so without a signer the session's wallet (GET /v1/me) stands for the account. */
|
|
564
591
|
async function checkScope(signed) {
|
|
565
|
-
const
|
|
566
|
-
|
|
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) {
|
|
567
595
|
throw new Error("this acceptance was signed by another wallet or on another deployment");
|
|
568
596
|
}
|
|
569
597
|
}
|
|
@@ -691,20 +719,31 @@ export function createTotalis(options) {
|
|
|
691
719
|
}, () => undefined);
|
|
692
720
|
}
|
|
693
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 });
|
|
694
725
|
try {
|
|
695
726
|
const stamped = clock.sending();
|
|
696
|
-
const answer = await sendOnce(
|
|
727
|
+
const answer = await sendOnce(post);
|
|
697
728
|
checkEcho(signed, answer, true);
|
|
698
729
|
stamped(answer.server_time);
|
|
699
730
|
outcome = answer;
|
|
700
731
|
}
|
|
701
732
|
catch (error) {
|
|
702
|
-
if (
|
|
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)) {
|
|
703
736
|
if (!normalizeError(error).mayStillRun)
|
|
704
737
|
await discardBridge(signed, options.onRecord);
|
|
705
738
|
throw error;
|
|
706
739
|
}
|
|
707
|
-
|
|
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
|
+
});
|
|
708
747
|
}
|
|
709
748
|
return resultOf(signed, outcome, await moveBridge(signed, outcome, clock, options));
|
|
710
749
|
}
|
|
@@ -738,12 +777,15 @@ export function createTotalis(options) {
|
|
|
738
777
|
* Open a combo position: create the RFQ, take a quote from those the stream
|
|
739
778
|
* pushes, sign it (`signEntry`), hand the record to `onSigned` to persist,
|
|
740
779
|
* then `sendAcceptance`. Returns the acceptance's status (`ACCEPTED` with
|
|
741
|
-
* `operationId
|
|
742
|
-
*
|
|
743
|
-
*
|
|
744
|
-
*
|
|
745
|
-
*
|
|
746
|
-
*
|
|
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.
|
|
747
789
|
*/
|
|
748
790
|
async function openPosition(request) {
|
|
749
791
|
const { legs, stake, choose = bestQuote(), collectMs = defaultCollectMs, funding = "wallet", intent, quoteTimeoutMs = 20_000, signal } = request;
|
|
@@ -759,16 +801,18 @@ export function createTotalis(options) {
|
|
|
759
801
|
const matches = (quote) => quote.kind === "ENTRY" && quote.rfq_id === rfqId &&
|
|
760
802
|
quote.entry.taker.toLowerCase() === wanted.taker && quote.entry.stake === wanted.stake &&
|
|
761
803
|
JSON.stringify(quote.entry.legs.map(({ outcome_id, side }) => ({ outcome_id, side }))) === JSON.stringify(wanted.legs);
|
|
762
|
-
const
|
|
763
|
-
const
|
|
764
|
-
|
|
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))
|
|
765
808
|
throw new Error("the chosen quote is not for the requested position");
|
|
766
809
|
const signed = await signEntryWith({ quote: selected, core: request.core, cash: request.cash ?? await reading, sourceDex: request.sourceDex }, funding === "wallet" ? "plan" : funding);
|
|
767
810
|
await request.onSigned?.(signed);
|
|
768
811
|
return { selected, signed };
|
|
769
812
|
});
|
|
813
|
+
let { selected, signed } = await take();
|
|
770
814
|
let record = signed;
|
|
771
|
-
const
|
|
815
|
+
const send = () => sendAcceptance(signed, {
|
|
772
816
|
...(request.transport === undefined ? {} : { transport: request.transport }),
|
|
773
817
|
...(signal === undefined ? {} : { signal }),
|
|
774
818
|
onRecord: async (next) => {
|
|
@@ -776,9 +820,26 @@ export function createTotalis(options) {
|
|
|
776
820
|
await request.onSigned?.(next);
|
|
777
821
|
},
|
|
778
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;
|
|
779
836
|
return {
|
|
780
|
-
...
|
|
837
|
+
...settled,
|
|
781
838
|
entry: selected.entry,
|
|
839
|
+
/** As in `AcceptanceResult`, read as the bridge stands now. */
|
|
840
|
+
get bridged() {
|
|
841
|
+
return result.bridged;
|
|
842
|
+
},
|
|
782
843
|
/** The entry record as it stands now, with each bridge update `onSigned` gets: pass it to `entryFailure`. */
|
|
783
844
|
get record() {
|
|
784
845
|
return record;
|
|
@@ -826,7 +887,7 @@ export function createTotalis(options) {
|
|
|
826
887
|
*/
|
|
827
888
|
function waitForOperation(operationId, signal) {
|
|
828
889
|
const terminal = new Set(["COMMITTED", "REVERTED", "EXPIRED_UNEXECUTED"]);
|
|
829
|
-
return
|
|
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" ||
|
|
830
891
|
(event.event_type === "OPERATION_UPDATED" && event.payload.operation_id === operationId && terminal.has(event.payload.status)), signal, retry);
|
|
831
892
|
}
|
|
832
893
|
/**
|
|
@@ -919,7 +980,7 @@ export function createTotalis(options) {
|
|
|
919
980
|
if (status === "live" && !live)
|
|
920
981
|
void recover();
|
|
921
982
|
else if (status === "closed")
|
|
922
|
-
finish(() => reject(
|
|
983
|
+
finish(() => reject(stream.closeError(true)));
|
|
923
984
|
live = status === "live";
|
|
924
985
|
}));
|
|
925
986
|
for (const withdrawal of known)
|
|
@@ -967,7 +1028,8 @@ export function createTotalis(options) {
|
|
|
967
1028
|
}
|
|
968
1029
|
/** Follow a withdrawal after a restart: one recovery read (retried while it fails transiently), then the stream. */
|
|
969
1030
|
async function waitForWithdrawal(withdrawalId, signal) {
|
|
970
|
-
|
|
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));
|
|
971
1033
|
return followWithdrawal(withdrawalId, [], signal);
|
|
972
1034
|
}
|
|
973
1035
|
/**
|
|
@@ -1007,6 +1069,33 @@ export function createTotalis(options) {
|
|
|
1007
1069
|
const body = envelope({ account, to, ...exit, signature, purpose: request.purpose ?? "ORDER", ...(request.activationFeeAccepted ? { activation_fee_accepted: true } : {}) });
|
|
1008
1070
|
return { exitId, account, body };
|
|
1009
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
|
+
}
|
|
1010
1099
|
/**
|
|
1011
1100
|
* POST a signed exit under its exit ID, resending the same bytes on a lost
|
|
1012
1101
|
* answer. The 202 is admission: follow it with `waitForCoreExit`.
|
|
@@ -1096,7 +1185,7 @@ export function createTotalis(options) {
|
|
|
1096
1185
|
/** Follow an exit on the stream until its cash is credited on HyperCore; it rejects if the exit ends any other way. */
|
|
1097
1186
|
async function waitForCoreExit(exitId, signal) {
|
|
1098
1187
|
const terminal = new Set(["CREDITED", "RECOVERED", "FAILED", "ABANDONED"]);
|
|
1099
|
-
const exit = await
|
|
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);
|
|
1100
1189
|
if (exit.status !== "CREDITED")
|
|
1101
1190
|
throw new Error(`core exit ${exitId} ended ${exit.status}, not credited on HyperCore`);
|
|
1102
1191
|
return exit;
|
|
@@ -1139,6 +1228,8 @@ export function createTotalis(options) {
|
|
|
1139
1228
|
waitForWithdrawal,
|
|
1140
1229
|
waitForOperation,
|
|
1141
1230
|
signCoreExit,
|
|
1231
|
+
signRunOffClaim,
|
|
1232
|
+
sendRunOffClaim,
|
|
1142
1233
|
sendCoreExit,
|
|
1143
1234
|
exitToCore,
|
|
1144
1235
|
waitForCoreExit,
|