@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/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
- /** The server closed the stream with a NEVER reason (for example SUBSCRIPTION_DENIED); it will not reconnect. */
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
- /** The server closed the stream with a NEVER reason (for example SUBSCRIPTION_DENIED); it will not reconnect. */
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(new StreamClosedError("closed", 1000)));
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", new StreamClosedError(reason, code));
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`; `NOT_ADMITTED`
119
- * means the venue never took the accept and nothing traded. `bridged` is set
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 = "INSUFFICIENT_CASH" | "CORE_MOVE_MISSED";
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` `INSUFFICIENT_CASH`. Pass the record as `onRecord` last
154
- * handed it back (or `openPosition`'s `record`): only `LANDED` shows the Core
155
- * cash moved before `fund_by`. `PROCESSED` only says the nonce is spent, which
156
- * a refused or expired send also does, so it does not show the move.
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
- /** The highest price with at least `minLifeSeconds` left: execution needs 20 s after confirmation. */
170
- export declare function bestBid(minLifeSeconds?: number): Chooser<CashoutBid>;
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 { StreamClosedError, TotalisStream } from "./stream.js";
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` `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.
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 undefined;
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
- /** The highest price with at least `minLifeSeconds` left: execution needs 20 s after confirmation. */
100
- export function bestBid(minLifeSeconds = 25) {
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
- function followUntil(stream, read, done, wake, recheckMs, signal, retry) {
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(new StreamClosedError("closed", 1000)));
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
- 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
- });
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
- await options.onRecord?.({ ...sent, bridgeState: answered[answer.kind] });
550
- else if (!attempted && bridgeState === undefined)
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(sent), bridging };
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 { scope } = await recordScope();
566
- if (scope.account !== signed.account || scope.chainId !== signed.chainId || scope.vault !== signed.vault) {
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(() => client.POST("/v1/quotes/{quote_id}/accept", { params: { path: { quote_id: signed.quoteId }, header: idempotency(signed.acceptanceId) }, body: signed.command }));
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 (!(error instanceof NetworkError)) {
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
- outcome = await fenceAcceptance(signed, clock, error, true, bridgeUnsent(signed));
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`, `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.
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 { selected, signed } = await signedOrCancel(rfqId, async () => {
763
- const selected = await awaitOffer(rfqId, matches, choose, collectMs, quoteTimeoutMs, signal);
764
- if (!matches(selected))
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 result = await sendAcceptance(signed, {
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
- ...result,
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 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" ||
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(new StreamClosedError("closed", 1000)));
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
- await stream.live(signal);
1031
+ // The withdrawal was admitted, so a stream closed for good may still leave it to pay out.
1032
+ await stream.live(signal).catch((error) => Promise.reject(stream.status === "closed" ? stream.closeError(true) : error));
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 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);
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,