@totalis/hip4-client 0.76.0 → 0.77.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 CHANGED
@@ -3,6 +3,43 @@
3
3
  `@totalis/hip4-client` follows [semantic versioning](https://semver.org).
4
4
  The contract version the generated types come from is `sdkVersion` in `./version`.
5
5
 
6
+ ## 0.77.0
7
+
8
+ `openPosition` chooses its quote last and moves on to the next quote when an
9
+ accept is refused, declined or times out (TT-2578, TT-2574). Makers replace
10
+ quotes as they renew them, and an accept that took seconds to sign often found
11
+ its quote gone (`QUOTE_REPLACED`). Additive: 0.76 code runs unchanged, but
12
+ `openPosition` now sends up to `maxAttempts` accepts where it sent one or two.
13
+
14
+ ### Added
15
+
16
+ - `prepareEntryFunding({ stake, core, cash, sourceDex, hypercoreNonce })`
17
+ plans and signs an entry's funding (the wallet's USDC authorization and any
18
+ bridge from Core) before a quote is chosen. `signEntry({ quote, prepared })`
19
+ then signs only the quote, so the accept can go straight out. A prepared
20
+ funding is for one accept.
21
+ - `SignedEntry.refused`: the code of an accept the venue refused outright.
22
+ `recoverAcceptance` answers such a record `NOT_ADMITTED` from the record
23
+ alone, without reading the acceptance or cancelling the RFQ.
24
+ - `openPosition` options `minPayout`, `refreshMs` (default 10 s) and
25
+ `maxAttempts` (default 4).
26
+
27
+ ### Changed
28
+
29
+ - `openPosition` signs the funding first and chooses the quote after, then
30
+ signs it and sends the accept at once. Signing the funding counts against
31
+ `collectMs`.
32
+ - On `QUOTE_REPLACED` or `QUOTE_EXPIRED`, `openPosition` hands `onSigned` the
33
+ record marked `refused`, then prepares the funding again and signs the best
34
+ other quote paying at least `minPayout` (left out: the first chosen quote's
35
+ payout). It keeps going until an accept is taken, `refreshMs` passes or
36
+ `maxAttempts` accepts were sent, and throws the last refusal. Before, it
37
+ retried once and took any payout.
38
+ - A `DECLINED` or `TIMED_OUT` accept is followed the same way, within the
39
+ same `refreshMs` and `maxAttempts`: the maker's answer admitted nothing and
40
+ the RFQ stays open. With no other quote at or above `minPayout` in time,
41
+ `openPosition` returns that answer, as before.
42
+
6
43
  ## 0.76.0
7
44
 
8
45
  Vault agents (TT-2554), one Combo leg per question (TT-2605) and no legs on
package/README.md CHANGED
@@ -66,8 +66,9 @@ if (opened.operationId) {
66
66
  totalis.close();
67
67
  ```
68
68
 
69
- `openPosition` creates the RFQ, takes the best quote, signs the acceptance and funds any shortfall from the
70
- wallet. Only `COMMITTED` means the trade happened. A decided position settles and pays out on its own.
69
+ `openPosition` creates the RFQ, signs the funding for any shortfall from the wallet, then takes the best quote
70
+ and signs the acceptance. If the maker replaced the quote first, declined it or didn't answer, it signs the best
71
+ other quote paying at least the first one's payout (`minPayout`) and tries again. Only `COMMITTED` means the trade happened. A decided position settles and pays out on its own.
71
72
 
72
73
  ## Make markets
73
74
 
package/dist/client.d.ts CHANGED
@@ -5,7 +5,7 @@ export type { components, operations, paths } from "./generated/client.js";
5
5
  export type { AccessToken, ApiClientOptions, PublicEnvironment } from "./base.js";
6
6
  /** The typed HTTP client for the `client` audience. `createTotalis` adds the flows. */
7
7
  export declare function createClient(environment: PublicEnvironment, options?: ApiClientOptions): import("openapi-fetch").Client<paths, `${string}/${string}`>;
8
- export { bestBid, bestQuote, createTotalis, EntryError, entryFailure, follow, type Acceptance, type AcceptanceResult, type Amount, type BridgeState, type CashoutBid, type Chooser, type Deployment, type DepositAccepted, type EntryCash, type EntryFailure, type EntryQuote, type CoreExit, type LegacySignedEntry, type Leg, type NotAdmitted, type RecordListener, type Operation, type Quote, type ResponseOf, type SellBackQuote, type SignedAcceptance, type SignedCashout, type SignedCoreExit, type SignedDeposit, type SignedEntry, type SignedReturnToCore, type Totalis, type TotalisOptions, type TransferQuote, type WalletCall, type Withdrawal, } from "./totalis.js";
8
+ export { bestBid, bestQuote, createTotalis, EntryError, entryFailure, follow, type Acceptance, type AcceptanceResult, type Amount, type BridgeState, type CashoutBid, type Chooser, type Deployment, type DepositAccepted, type EntryCash, type EntryFailure, type EntryQuote, type CoreExit, type LegacySignedEntry, type Leg, type PreparedEntryFunding, type NotAdmitted, type RecordListener, type Operation, type Quote, type ResponseOf, type SellBackQuote, type SignedAcceptance, type SignedCashout, type SignedCoreExit, type SignedDeposit, type SignedEntry, type SignedReturnToCore, type Totalis, type TotalisOptions, type TransferQuote, type WalletCall, type Withdrawal, } from "./totalis.js";
9
9
  export { bridgeAction, bridgeGasBuffer, classifyHypercoreAnswer, hyperevmUsdcBridge, hyperliquidExchange, hyperliquidNetwork, hypercoreNonce, hypercoreSignature, sendAssetTypedData, settleHypercore, signSendAsset, submitHypercore, usdcText, type BridgedCash, type HypercoreAnswer, type HypercoreSignature, type HypercoreTransport, type HyperliquidNetwork, type SendAssetAction, type SignedSendAsset, } from "./hypercore.js";
10
10
  export { ApiError, envelope, NetworkError, randomBytes32, retryingFetch, sendOnce, unwrap, uuidv7, type Fetch, type Repaired, type RetryPolicy } from "./request.js";
11
11
  export { items, pages, type Page, type PageQuery } from "./pagination.js";
package/dist/totalis.d.ts CHANGED
@@ -102,6 +102,27 @@ export type SignedEntry = RecordScope & {
102
102
  readonly command: AcceptCommand;
103
103
  readonly bridge?: SignedSendAsset;
104
104
  readonly bridgeState?: BridgeState;
105
+ /**
106
+ * Set when the venue refused this accept outright (`QUOTE_REPLACED`,
107
+ * `QUOTE_EXPIRED`): nothing was admitted, so `recoverAcceptance` answers
108
+ * `NOT_ADMITTED` without reading or cancelling the RFQ, which a later
109
+ * attempt may be using.
110
+ */
111
+ readonly refused?: string;
112
+ };
113
+ /**
114
+ * An entry's funding, signed before its quote is chosen
115
+ * (`prepareEntryFunding`). Pass it to `signEntry` as `prepared`, so only the
116
+ * quote is left to sign once it is chosen. It funds one accept: prepare again
117
+ * for another.
118
+ */
119
+ export type PreparedEntryFunding = {
120
+ readonly stake: string;
121
+ readonly account: Hex;
122
+ readonly chainId: string;
123
+ readonly vault: Hex;
124
+ readonly funding?: Funding;
125
+ readonly bridge?: SignedSendAsset;
105
126
  };
106
127
  /** An entry record from SDK 0.63 or older, which sent its bridge before the accept: `recoverAcceptance` reads it and sends nothing, and `sendAcceptance` refuses it. */
107
128
  export type LegacySignedEntry = Omit<SignedEntry, "version" | "bridgeState">;
@@ -246,7 +267,7 @@ export declare function createTotalis(options: TotalisOptions): {
246
267
  readonly legs: readonly Leg[];
247
268
  readonly stake: Amount;
248
269
  readonly choose?: Chooser<EntryQuote>;
249
- /** How long quotes may keep arriving after the first one `choose` would take, before it picks; 0 takes it at once. */
270
+ /** How long quotes may keep arriving after the first one `choose` would take, before it picks; 0 takes it at once. Time spent signing the funding counts. */
250
271
  readonly collectMs?: number;
251
272
  /** `"wallet"` (default) plans the shortfall from the wallet and `core`; `"none"` never funds; or pass a funding command. */
252
273
  readonly funding?: "wallet" | "none" | Funding;
@@ -254,9 +275,9 @@ export declare function createTotalis(options: TotalisOptions): {
254
275
  readonly core?: Amount | null;
255
276
  readonly sourceDex?: "spot" | "";
256
277
  readonly transport?: HypercoreTransport;
257
- /** The cash to plan from (see `signEntry`); left out, the balances are read while the quotes arrive. */
278
+ /** The cash to plan from (see `signEntry`); left out, the balances are read while the RFQ is created, and again for each refresh. */
258
279
  readonly cash?: EntryCash;
259
- /** Awaited with the signed record before anything is sent, then with each change to its bridge (`onRecord`): persist it to `recoverAcceptance` after a restart. */
280
+ /** Awaited with the signed record before anything is sent, then with each change to its bridge (`onRecord`) or its refusal: persist it to `recoverAcceptance` after a restart. */
260
281
  readonly onSigned?: RecordListener;
261
282
  /** Automatic-quote intent identity, as the Totalis app sends it. */
262
283
  readonly intent?: {
@@ -264,6 +285,12 @@ export declare function createTotalis(options: TotalisOptions): {
264
285
  readonly intent_generation: string;
265
286
  };
266
287
  readonly quoteTimeoutMs?: number;
288
+ /** The least payout a quote may pay, in USDC base units. Left out, it is the first chosen quote's: a refresh never takes less. */
289
+ readonly minPayout?: Amount;
290
+ /** How long after the first quote is chosen a refused, declined or timed-out accept may still be followed by another (default 10 s). */
291
+ readonly refreshMs?: number;
292
+ /** The most accepts sent, the first included (default 4); 1 never refreshes. */
293
+ readonly maxAttempts?: number;
267
294
  readonly signal?: AbortSignal;
268
295
  }) => Promise<{
269
296
  entry: {
@@ -297,6 +324,14 @@ export declare function createTotalis(options: TotalisOptions): {
297
324
  readonly quoteTimeoutMs?: number;
298
325
  readonly signal?: AbortSignal;
299
326
  }) => Promise<AcceptanceResult>;
327
+ prepareEntryFunding: (request: {
328
+ readonly stake: Amount;
329
+ readonly core?: Amount | null | undefined;
330
+ readonly cash?: EntryCash | undefined;
331
+ readonly sourceDex?: "spot" | "" | undefined;
332
+ readonly hypercoreNonce?: number | undefined;
333
+ readonly signal?: AbortSignal | undefined;
334
+ }) => Promise<PreparedEntryFunding>;
300
335
  signEntry: (request: {
301
336
  readonly quote: EntryQuote;
302
337
  readonly core?: Amount | null;
@@ -304,6 +339,7 @@ export declare function createTotalis(options: TotalisOptions): {
304
339
  readonly sourceDex?: "spot" | "";
305
340
  readonly acceptanceId?: string;
306
341
  readonly hypercoreNonce?: number;
342
+ readonly prepared?: PreparedEntryFunding;
307
343
  }) => Promise<SignedEntry>;
308
344
  signCashout: (request: {
309
345
  readonly quote: CashoutBid;
package/dist/totalis.js CHANGED
@@ -87,6 +87,11 @@ const minimumDeposit = 1000000n;
87
87
  * best of them win instead of the fastest.
88
88
  */
89
89
  const defaultCollectMs = 1_000;
90
+ const defaultRefreshMs = 10_000;
91
+ const defaultMaxAttempts = 4;
92
+ /** Accept refusals that mean the chosen quote went away before the accept, not that the position cannot open. */
93
+ const refreshCodes = new Set(["QUOTE_EXPIRED", "QUOTE_REPLACED"]);
94
+ const passedOn = new Set(["DECLINED", "TIMED_OUT"]);
90
95
  const timerSlackMs = 250;
91
96
  function termsOf(quote) {
92
97
  return quote.kind === "ENTRY" ? quote.entry : quote.kind === "SELL_BACK" ? quote.sell_back : quote.transfer;
@@ -641,11 +646,46 @@ export function createTotalis(options) {
641
646
  throw new EntryError("INSUFFICIENT_FUNDS", `${bridge - (routable > 0n ? routable : 0n)} USDC base units short for this stake`);
642
647
  return { wallet: shortfall, bridge };
643
648
  }
649
+ function signBridge(signer, chain, amount, request) {
650
+ return amount > 0n ? signSendAsset(signer, bridgeAction(hyperliquidNetwork(chain.chain_id), amount, {
651
+ ...(request.hypercoreNonce === undefined ? {} : { nonce: request.hypercoreNonce }),
652
+ ...(request.sourceDex === undefined ? {} : { sourceDex: request.sourceDex }),
653
+ })) : undefined;
654
+ }
655
+ /**
656
+ * Plan and sign an entry's funding before choosing its quote: the wallet's
657
+ * USDC authorization and any bridge from `core`, planned from `cash` or a
658
+ * fresh balances read as `signEntry` plans them. Neither names a quote, so
659
+ * once one is chosen `signEntry({ quote, prepared })` signs only the quote
660
+ * and the accept can go straight out.
661
+ */
662
+ async function prepareEntryFunding(request) {
663
+ const { signer, account, chain, scope } = await recordScope();
664
+ const stake = BigInt(request.stake);
665
+ const core = request.core === undefined || request.core === null ? request.core : BigInt(request.core);
666
+ const plan = planEntry(stake, core, request.cash ?? await readSpendableCash(request.signal));
667
+ const [bridge, funding] = await Promise.all([
668
+ signBridge(signer, chain, plan.bridge, request),
669
+ plan.wallet > 0n ? depositAuthorization(chain, account, plan.wallet) : undefined,
670
+ ]);
671
+ return { stake: stake.toString(), ...scope, ...(funding === undefined ? {} : { funding }), ...(bridge === undefined ? {} : { bridge }) };
672
+ }
644
673
  async function signEntryWith(request, funding) {
645
- const { quote, cash } = request;
674
+ const { quote, cash, prepared } = request;
646
675
  const { signer, vaultSigner, account, chain, scope } = await recordScope();
647
676
  if (quote.entry.taker.toLowerCase() !== account)
648
677
  throw new Error("the quote is for another taker");
678
+ const record = (taker_signature, fundingCommand, bridge) => ({
679
+ kind: "ENTRY", version: 2, acceptanceId: request.acceptanceId ?? uuidv7(), rfqId: quote.rfq_id, quoteId: quote.quote_id, ...scope, expiry: quote.entry.expiry,
680
+ command: { taker_signature, ...(fundingCommand === undefined ? {} : { funding: fundingCommand }) },
681
+ ...(bridge === undefined ? {} : { bridge }),
682
+ });
683
+ if (prepared !== undefined) {
684
+ if (prepared.stake !== quote.entry.stake || prepared.account !== scope.account || prepared.chainId !== scope.chainId || prepared.vault !== scope.vault) {
685
+ throw new Error("the prepared funding is for another stake, account or deployment");
686
+ }
687
+ return record(await vaultSigner.signTypedData(takerAcceptTypedData(chain, quote.entry)), prepared.funding, prepared.bridge);
688
+ }
649
689
  const stake = BigInt(quote.entry.stake);
650
690
  const core = request.core === undefined || request.core === null ? request.core : BigInt(request.core);
651
691
  // Known cash is planned before any prompt, so a plan that fails asks the wallet for nothing.
@@ -664,18 +704,11 @@ export function createTotalis(options) {
664
704
  const walletAmount = plan?.wallet ?? 0n;
665
705
  const [taker_signature, bridge, walletFunding] = await Promise.all([
666
706
  signing,
667
- bridgeAmount > 0n ? signSendAsset(signer, bridgeAction(hyperliquidNetwork(chain.chain_id), bridgeAmount, {
668
- ...(request.hypercoreNonce === undefined ? {} : { nonce: request.hypercoreNonce }),
669
- ...(request.sourceDex === undefined ? {} : { sourceDex: request.sourceDex }),
670
- })) : undefined,
707
+ signBridge(signer, chain, bridgeAmount, request),
671
708
  walletAmount > 0n ? depositAuthorization(chain, account, walletAmount) : undefined,
672
709
  ]);
673
710
  const fundingCommand = plan === undefined ? (funding === "none" || funding === "plan" ? undefined : funding) : walletFunding;
674
- return {
675
- kind: "ENTRY", version: 2, acceptanceId: request.acceptanceId ?? uuidv7(), rfqId: quote.rfq_id, quoteId: quote.quote_id, ...scope, expiry: quote.entry.expiry,
676
- command: { taker_signature, ...(fundingCommand === undefined ? {} : { funding: fundingCommand }) },
677
- ...(bridge === undefined ? {} : { bridge }),
678
- };
711
+ return record(taker_signature, fundingCommand, bridge);
679
712
  }
680
713
  /**
681
714
  * Sign a Combo entry for a quote without sending it. Funding is planned
@@ -691,7 +724,8 @@ export function createTotalis(options) {
691
724
  * shows them in turn. `sourceDex: ""` bridges from perps instead of spot.
692
725
  * Persist the record, then `sendAcceptance` it; after a restart,
693
726
  * `recoverAcceptance` it. Pass `hypercoreNonce` if your app reserves
694
- * Hyperliquid nonces itself.
727
+ * Hyperliquid nonces itself. With `prepared` (`prepareEntryFunding`), only
728
+ * the quote is signed, and its funding is the prepared one.
695
729
  */
696
730
  function signEntry(request) {
697
731
  return signEntryWith(request, "plan");
@@ -773,9 +807,13 @@ export function createTotalis(options) {
773
807
  * record from SDK 0.63 or older is only read. One still `PENDING_CONFIRM`
774
808
  * is read again after the maker's deadline, or every 250 ms until 2 s past
775
809
  * it while a bridge is unsent; `wait: false` returns it as it stands instead.
810
+ * A record marked `refused` is `NOT_ADMITTED`, read from the record alone.
776
811
  */
777
812
  async function recoverAcceptance(signed, options = {}) {
778
813
  await checkScope(signed);
814
+ if (signed.kind === "ENTRY" && "refused" in signed && signed.refused !== undefined) {
815
+ return resultOf(signed, { status: "NOT_ADMITTED", quote_id: signed.quoteId, rfq_id: signed.rfqId }, {});
816
+ }
779
817
  const clock = venueClock();
780
818
  const outcome = await fenceAcceptance(signed, clock, undefined, options.wait ?? true, bridgeUnsent(signed));
781
819
  return resultOf(signed, outcome, await moveBridge(signed, outcome, clock, options));
@@ -791,21 +829,30 @@ export function createTotalis(options) {
791
829
  }
792
830
  }
793
831
  /**
794
- * Open a combo position: create the RFQ, take a quote from those the stream
795
- * pushes, sign it (`signEntry`), hand the record to `onSigned` to persist,
796
- * then `sendAcceptance`. Returns the acceptance's status (`ACCEPTED` with
832
+ * Open a combo position: create the RFQ, sign its funding
833
+ * (`prepareEntryFunding`), then take a quote from those the stream pushes,
834
+ * sign it (`signEntry`), hand the record to `onSigned` to persist, and
835
+ * `sendAcceptance` at once: the quote is chosen last, so the one signed is
836
+ * as fresh as the book allows. Returns the acceptance's status (`ACCEPTED` with
797
837
  * `operationId`; `FILLED` or `FAILED`, also with it, when a lost answer was
798
838
  * read after the trade settled; `DECLINED` / `TIMED_OUT` / `CANCELLED`; or
799
839
  * `NOT_ADMITTED` when a lost accept never arrived). The position opens
800
840
  * under `positionId`, the quote's ID, once `waitForOperation(operationId)`
801
841
  * resolves `COMMITTED`; pass its operation and the result's `record` to
802
842
  * `entryFailure` to name a failure. Any vault shortfall is funded from the
803
- * wallet, and from `core` by bridge, unless `funding` says otherwise. An accept refused with
804
- * `QUOTE_EXPIRED` or `QUOTE_REPLACED` takes and signs a fresh quote once
805
- * (`onSigned` gets the new record); a second refusal is thrown.
843
+ * wallet, and from `core` by bridge, unless `funding` says otherwise.
844
+ *
845
+ * Makers replace their quotes as they renew them, so an accept can find its
846
+ * quote gone (`QUOTE_REPLACED`, `QUOTE_EXPIRED`); then `onSigned` gets the
847
+ * record marked `refused`. A maker can also turn its quote down (`DECLINED`)
848
+ * or not answer in time (`TIMED_OUT`). Either way nothing was admitted, so
849
+ * the best other quote paying at least `minPayout` is signed and sent,
850
+ * funding first, until one is taken, `refreshMs` has passed since the first
851
+ * quote was chosen, or `maxAttempts` accepts were sent. Then the last
852
+ * refusal is thrown, or the last decline or timeout returned.
806
853
  */
807
854
  async function openPosition(request) {
808
- const { legs, stake, choose = bestQuote(), collectMs = defaultCollectMs, funding = "wallet", intent, quoteTimeoutMs = 20_000, signal } = request;
855
+ const { legs, stake, choose = bestQuote(), collectMs = defaultCollectMs, funding = "wallet", intent, quoteTimeoutMs = 20_000, refreshMs = defaultRefreshMs, maxAttempts = defaultMaxAttempts, signal } = request;
809
856
  const reading = funding === "wallet" && request.cash === undefined ? readSpendableCash(signal) : undefined;
810
857
  reading?.catch(() => undefined);
811
858
  const account = await (await requireSigner()).address();
@@ -814,20 +861,32 @@ export function createTotalis(options) {
814
861
  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 })) };
815
862
  const entry = { stake: wanted.stake, legs: wanted.legs };
816
863
  const rfqId = await createRfq(intent === undefined ? entry : { ...entry, ...intent });
817
- // Only quotes for exactly this position are offered to the chooser, and the chosen one is checked again before signing.
818
- const matches = (quote) => quote.kind === "ENTRY" && quote.rfq_id === rfqId &&
864
+ const createdAt = performance.now();
865
+ let floor = request.minPayout === undefined ? undefined : BigInt(request.minPayout);
866
+ const refused = new Set();
867
+ const offered = (quote) => quote.kind === "ENTRY" && quote.rfq_id === rfqId &&
819
868
  quote.entry.taker.toLowerCase() === wanted.taker && quote.entry.stake === wanted.stake &&
820
- JSON.stringify(quote.entry.legs.map(({ outcome_id, side }) => ({ outcome_id, side }))) === JSON.stringify(wanted.legs);
821
- const take = (refused) => signedOrCancel(rfqId, async () => {
822
- const offered = (quote) => matches(quote) && quote.quote_id !== refused;
823
- const selected = await awaitOffer(rfqId, offered, choose, collectMs, quoteTimeoutMs, signal);
869
+ JSON.stringify(quote.entry.legs.map(({ outcome_id, side }) => ({ outcome_id, side }))) === JSON.stringify(wanted.legs) &&
870
+ !refused.has(quote.quote_id) && (floor === undefined || BigInt(quote.entry.payout) >= floor);
871
+ // Counted from the first choice, not from its signature, so a slow wallet cannot stretch the window.
872
+ let refreshUntil;
873
+ const take = () => signedOrCancel(rfqId, async () => {
874
+ const prepared = funding === "wallet"
875
+ ? await prepareEntryFunding({ stake: wanted.stake, core: request.core, cash: request.cash ?? await (refreshUntil === undefined ? reading : readSpendableCash(signal)), sourceDex: request.sourceDex, signal })
876
+ : undefined;
877
+ const selected = refreshUntil === undefined
878
+ ? await awaitOffer(rfqId, offered, choose, Math.max(0, Math.round(collectMs - (performance.now() - createdAt))), quoteTimeoutMs, signal)
879
+ : await awaitOffer(rfqId, offered, choose, 0, Math.max(0, Math.round(refreshUntil - performance.now())), signal);
880
+ refreshUntil ??= performance.now() + refreshMs;
824
881
  if (!offered(selected))
825
882
  throw new Error("the chosen quote is not for the requested position");
826
- const signed = await signEntryWith({ quote: selected, core: request.core, cash: request.cash ?? await reading, sourceDex: request.sourceDex }, funding === "wallet" ? "plan" : funding);
883
+ const signed = await signEntryWith({ quote: selected, prepared }, funding === "wallet" ? "plan" : funding);
827
884
  await request.onSigned?.(signed);
828
885
  return { selected, signed };
829
886
  });
830
887
  let { selected, signed } = await take();
888
+ floor ??= BigInt(selected.entry.payout);
889
+ const refreshEnds = refreshUntil ?? 0;
831
890
  let record = signed;
832
891
  const send = () => sendAcceptance(signed, {
833
892
  ...(request.transport === undefined ? {} : { transport: request.transport }),
@@ -837,18 +896,56 @@ export function createTotalis(options) {
837
896
  await request.onSigned?.(next);
838
897
  },
839
898
  });
840
- let result;
841
- try {
842
- result = await send();
843
- }
844
- catch (error) {
845
- if (!(error instanceof ApiError) || error.code !== "QUOTE_EXPIRED" && error.code !== "QUOTE_REPLACED")
846
- throw error;
847
- // 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.
848
- ({ selected, signed } = await take(signed.quoteId).catch((retried) => Promise.reject(retried instanceof Error && retried.name === "TimeoutError" ? error : retried)));
849
- record = signed;
850
- result = await send();
851
- }
899
+ const settle = async () => {
900
+ for (let attempt = 1;; attempt += 1) {
901
+ // The maker turned the quote down, or never answered: nothing was admitted and the RFQ stays open.
902
+ let lost;
903
+ try {
904
+ lost = await send();
905
+ if (!passedOn.has(lost.status))
906
+ return lost;
907
+ }
908
+ catch (error) {
909
+ if (!(error instanceof ApiError) || !refreshCodes.has(error.code))
910
+ throw error;
911
+ // Refused outright, so nothing was admitted and sendAcceptance discarded any bridge.
912
+ lost = error;
913
+ record = { ...record, refused: error.code };
914
+ await request.onSigned?.(record);
915
+ }
916
+ refused.add(signed.quoteId);
917
+ const last = record;
918
+ const end = async () => {
919
+ if (record !== last) {
920
+ record = last;
921
+ await request.onSigned?.(last);
922
+ }
923
+ if (lost instanceof ApiError)
924
+ throw lost;
925
+ return lost;
926
+ };
927
+ if (attempt >= maxAttempts || performance.now() >= refreshEnds)
928
+ return end();
929
+ let next;
930
+ try {
931
+ next = await take();
932
+ }
933
+ catch (retried) {
934
+ // No fresh quote at or above the floor in time leaves this outcome standing.
935
+ if (retried instanceof Error && retried.name === "TimeoutError")
936
+ return end();
937
+ throw retried;
938
+ }
939
+ // Signing and persisting can outlast the window: a record signed too late is never sent, and the last sent one is persisted again.
940
+ if (performance.now() >= refreshEnds) {
941
+ record = next.signed;
942
+ return end();
943
+ }
944
+ ({ selected, signed } = next);
945
+ record = signed;
946
+ }
947
+ };
948
+ const result = await settle();
852
949
  const { bridged: _bridged, ...settled } = result;
853
950
  return {
854
951
  ...settled,
@@ -1265,6 +1362,7 @@ export function createTotalis(options) {
1265
1362
  spendableCash: readSpendableCash,
1266
1363
  openPosition,
1267
1364
  cashOut,
1365
+ prepareEntryFunding,
1268
1366
  signEntry,
1269
1367
  signCashout,
1270
1368
  sendAcceptance,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@totalis/hip4-client",
3
- "version": "0.76.0",
3
+ "version": "0.77.0",
4
4
  "description": "Totalis HIP-4 SDK: typed HTTP clients, signer-agnostic typed data, a retrying request layer, pagination, a realtime stream and taker/maker flows",
5
5
  "type": "module",
6
6
  "files": [