@xochi/client 0.1.2 → 0.1.3

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
@@ -4,6 +4,25 @@ All notable changes to `@xochi/client` are documented here. Format follows [Keep
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ### Fixed
8
+
9
+ - **Transport**: a valid server-directed `Retry-After` is honored up to the existing 600-second safety ceiling instead of being shortened to `retry.maxDelayMs`; the fallback exponential-backoff cap applies only when no valid integer delta-seconds interval is supplied (zero included). Pending quote deduplication now lasts for the whole request, including long server-directed retry waits.
10
+
11
+ ## [0.1.3] - 2026-10-29
12
+
13
+ ### Fixed
14
+
15
+ - A live execute whose later status read returns `quoted` now enters the `not-sent` phase instead of remaining at "Still confirming". The first status read waits for the execute reply, so a pre-acceptance `quoted` row cannot produce that verdict. Status replies older than the latest answered attempt are ignored, preventing them from undoing a newer not-sent verdict.
16
+ - Returning to a visible tab restarts an exhausted in-flight status poll with a fresh budget. A live not-sent poll remains bounded by its original ceiling; only a resumed intent without a quote refills its budget on each `quoted` read.
17
+
18
+ ### Added
19
+
20
+ - Exported `NOT_SENT_SWEPT_ERROR` for hosts that need to identify the machine's expired-without-send Retry result.
21
+
22
+ ### Changed
23
+
24
+ - **BREAKING: `IntentState.legsWithheld` is `boolean | null`** (was `boolean`): `null` means no status reply has established whether the current view is anonymous or owner-authorized. Migration: test `legsWithheld === true` before offering SIWE recovery; do not interpret `null` as an owner read.
25
+
7
26
  ## [0.1.2] - 2026-10-27
8
27
 
9
28
  ### Added
package/README.md CHANGED
@@ -48,7 +48,7 @@ const quote = await xochi.getQuote(request);
48
48
 
49
49
  Not covered: an echo of a token no read-token key in the prefix names, and that is not a base64url run or a joinable split of one (its hex, its standard base64, its raw bytes as `\xNN`, a split piece under 8 characters or reading as a word, pieces that all read as names with no other piece in their chain, or pieces split by any other separator or by more than one character, such as `, `, `:` or a nested JSON string's `\\n`); and a piece of under 16 characters with other base64url text on both sides, or left at a cut. A 16-character window is 96 random bits, so what stays readable of a token is a piece shorter than that.
50
50
 
51
- - Once xochi#565 is deployed (above), a stealth intent read without the token (or an owner SIWE session) is the server's status-only view, and so is any read of a shielded intent without an owner SIWE session, token or not (SPEC.md §5.1, above); the status-only view carries `substatus` only when it is `not_executed`. `getIntentStatus` then carries `legsWithheld: true` and `settlementConfirmed` (the machine shows a confirmed fill as delivered, `substatus: "filled"`, without changing `status` or `terminal`, and sets `IntentState.legsWithheld` until an owner read clears it; when the flag counts is `withheldSettlementConfirmed`'s rule); `getIntent` returns a `WithheldIntentRecord` (`legsWithheld: true`, its status decoded like a status poll's, no tokens, amounts, fees or timestamps) rather than an `IntentRecord`. Recovery: the token (stealth only), else wallet SIWE (above).
51
+ - Once xochi#565 is deployed (above), a stealth intent read without the token (or an owner SIWE session) is the server's status-only view, and so is any read of a shielded intent without an owner SIWE session, token or not (SPEC.md §5.1, above); the status-only view carries `substatus` only when it is `not_executed`. `getIntentStatus` then carries `legsWithheld: true` and `settlementConfirmed` (the machine shows a confirmed fill as delivered, `substatus: "filled"`, without changing `status` or `terminal`, and sets `IntentState.legsWithheld` until an owner read clears it; the state is `null` before the first status reply, so hosts must test `legsWithheld === true` before offering recovery; when the flag counts is `withheldSettlementConfirmed`'s rule); `getIntent` returns a `WithheldIntentRecord` (`legsWithheld: true`, its status decoded like a status poll's, no tokens, amounts, fees or timestamps) rather than an `IntentRecord`. Recovery: the token (stealth only), else wallet SIWE (above).
52
52
  - 429/503 raise `ApiError` with `retryAfter` (absolute unix seconds, capped at 600 s, `MAX_RETRY_WAIT_SEC`), read from the `Retry-After` header (delta), else `X-RateLimit-Reset` (absolute), else a JSON body's camelCase `retryAfter` (absolute). The not-accepted answer below is the one exception.
53
53
  - `ApiError.notAccepted` is `true` only on the Worker's not-accepted answer to `execute`: a 503 whose body is a JSON object with `success` the boolean `false`, `status` `"quoted"`, `accepted` the boolean `false`, `intent_id` equal to the executed intent, and `retry_after` a positive finite number (delta seconds), and that is not in doubt (`ApiError.inDoubt`, below; the two are never both `true`). The Worker sends it only after undoing its own claim, on a solver refusal that provably ran before the intent was claimed or any pull sent, so the intent is `quoted` again and the same request may be sent again. Anything else is `false` and is never resent, classified exactly as without the marker: another status (429, 500, 502, 409, ...), a missing, string or non-boolean marker, any other `success` or `status` (the Worker's lost-race answer `status: "executing", reconciling: true` included), another intent's id, a missing, zero, negative or non-numeric `retry_after`, a body that is not a JSON object or is cut by the 8 KiB read. Its `retryAfter` is the wait the body's `retry_after` names (the Worker's choice, jitter included), counted from when the answer is read and rounded up to the next whole second so it never ends early; a readable numeric `Retry-After` header moves it only later, never earlier; `X-RateLimit-Reset` and a body `retryAfter` are not read for it; capped at 600 s. Pass the flag into the machine as `ErrorReport.notAccepted` and let the machine resend; `execute` itself never retries.
54
54
  - `ApiError.inDoubt` is `true` on a failed `execute` whose answer cannot vouch that nothing moved for the executed intent. It is `false` only on the three Worker answers that speak for the intent, not just for this request: the not-accepted 503 above (it undid its own claim, and the intent is `quoted` again); its 502 with `success: false`, `status: "failed"` and `substatus: "not_executed"` for the executed intent (it ended the intent with nothing moved; the marker's sources: `NOT_EXECUTED_SUBSTATUS`); and its 409 with `error_category: "promo_unavailable"` (the promo redemption is keyed by intent and idempotent, and every claim follows one, so a refused redemption means no request has claimed the intent). Every other answer refuses this request only, while another request for the same intent (another tab, a replayed request) may hold its claim, so it is in doubt: every 400, 401, 403, 404 and 422, from the Worker or from anything in front of it (the Worker answers its parse 400 and its 403 `api_key_intent_mismatch` before it reads the intent's status, and its other 400s and 401s, the signed-deadline 400 included, from a row it read before, which another request can claim in between; an edge never reads the intent); the 400 for an intent no longer `quoted` and any other 409 (another request holds or held the intent); every 429 (the Worker's plain 429 also answers an intent another request holds; its resendable rate limit is the not-accepted 503); any other 5xx, JSON or not (the Worker's catch-all 500 can follow its call to the solver); and an unmarked 502 `failed` (a solver failure nothing vouches for). No error text is read, so a reworded Worker message can only leave an answer in doubt. Any body carrying a `read_token` or `reconciling: true` is in doubt, whatever its status. A failed `execute` that is not an `ApiError` (a timeout or abort, a reset connection, a `RedirectRefused`, `Invalid response format`) is in doubt too. `false` says nothing had moved when the Worker answered, not that the order is over: unless the answer is the marked 502, the intent is still `quoted` and its signed request executable until it expires. Word an input error (a bad signature, an expired quote) from `code` and the message, not from this flag, which is for the host's own copy and logs; the machine does not take it, since it polls every thrown execute and only the poll's `failed` with `not_executed` (or, while "not sent yet", a 404) offers Retry ([Fund safety](#fund-safety)).
@@ -95,9 +95,9 @@ The machine checks every quote against the request (via `@xochi/signer`), the pu
95
95
  - **Quotes with an `escrow` envelope are refused.**
96
96
  - **A deposit address is never revealed with under 60 s of its attested window left.**
97
97
  - **On resume, run stored deposits through `verifyStoredDeposit`** and render and re-persist `result.stored`, not your input.
98
- - **The machine never sees a read token, and judges every execute reply itself.** `ExecuteReplied` carries the response without the token (`ExecuteReply`): `const { readToken, ...response } = await xochi.execute(request)`, retain `readToken` outside replayable state keyed by the intent id, and dispatch `{ ok: true, response }` (or a token-free `ErrorReport`); pass the retained token to `getIntentStatus` when performing a `status` effect. A thrown execute (whatever `ApiError.inDoubt` says), a reply it cannot read as an execute response for this intent (intent ids compare exactly), a status it does not model, and any reply with reconciling on (`reconciling` is the server's flag when it sends one, else on whenever success was withheld) leave the intent in flight, shown in doubt ("Still confirming"), and polled until it ends or the poll's ceiling stops it (`IntentState.pollExhausted`): `STATUS_POLL_FLAT_CEILING_ATTEMPTS` (150) intervals of 4 s with no status change, a read the intent cannot move to (`quoted` after an execute) included; in `executing` at least until the Worker's finish of an intent no execute claimed (`unexecutedFinishAt`: the later of the quote's `expiry` and the signed intent's `deadline`, plus `QUOTED_EXPIRY_GRACE_SECS`, 60 s) and one more poll, in `awaiting_deposit` until 10 minutes past the deposit deadline; never past 2 hours. An execute that never reached the Worker (a refused redirect, a timeout before any answer) reads `quoted` until that finish, then `failed` with `not_executed`: Retry.
98
+ - **The machine never sees a read token, and judges every execute reply itself.** `ExecuteReplied` carries the response without the token (`ExecuteReply`): `const { readToken, ...response } = await xochi.execute(request)`, retain `readToken` outside replayable state keyed by the intent id, and dispatch `{ ok: true, response }` (or a token-free `ErrorReport`); pass the retained token to `getIntentStatus` when performing a `status` effect. The first status read waits until that execute reply (success or failure), so a pre-acceptance `quoted` row cannot win a race with the execute response. A thrown execute (whatever `ApiError.inDoubt` says), a reply it cannot read as an execute response for this intent (intent ids compare exactly), a status it does not model, and any reply with reconciling on (`reconciling` is the server's flag when it sends one, else on whenever success was withheld) leave the intent in flight, shown in doubt ("Still confirming"), and polled until it ends or the poll's ceiling stops it (`IntentState.pollExhausted`): `STATUS_POLL_FLAT_CEILING_ATTEMPTS` (150) intervals of 4 s with no status change, in `executing` at least until the Worker's finish of an intent no execute claimed (`unexecutedFinishAt`: the later of the quote's `expiry` and the signed intent's `deadline`, plus `QUOTED_EXPIRY_GRACE_SECS`, 60 s) and one more poll, in `awaiting_deposit` until 10 minutes past the deposit deadline; never past 2 hours. Returning the tab to visible restarts an exhausted in-flight poll with a fresh budget. An execute that never reached the Worker (a refused redirect, a timeout before any answer) reads `quoted` as "not sent yet" until that finish, then `failed` with `not_executed`: Retry.
99
99
  - **After an execute, only the Worker's `not_executed` offers Retry.** A `failed` read after an execute or a revealed deposit address (any in-flight status: `executing`, `bridging`, `settling`, `awaiting_deposit`, where a deposit may have been sent unseen), from the poll, a `StreamStatus` frame or a final execute reply (`failed` with `success: false` and `reconciling` off), is the Retry view (phase `failed`) only when it carries `substatus: "not_executed"`: the Worker's mark on a `failed` where nothing moved (every source it has: `NOT_EXECUTED_SUBSTATUS`; xochi#686). Every other `failed`, bare or granular (`fill_failed`, `pull_failed`, `refund_failed`, ...), in doubt or not, ends at once in the terminal "may have moved" view: `IntentState.mayHaveMoved`, phase `may-have-moved`, the poll stopped, no Retry, and no quote, wallet reset, Refresh or cleared amount leaves it until Reset, which starts a new trade. Suggested copy, which promises no refund (a `refund_failed` ends here too): "This order did not complete. Funds may have moved. Check Activity before trading again." Against a Worker that does not send the marker (before xochi#686), every `failed` after an execute ends there. On a reload, read the record with `getIntent` and pass its `substatus` (`IntentRecord.substatus`) as `ResumeRequested.substatus`, never a stored copy: a resumed `failed` is Retry only with the marker, and the machine trusts it as it trusts every reply the host relays. A failure before any execute or revealed address (a wallet or quote failure) is the Retry view as before; the machine's own not-accepted give-up is not (below).
100
- - **Only an explicit not-accepted refusal is retried, and only the same signed request.** Build the thrown execute's `ErrorReport` with `notAccepted: error instanceof ApiError && error.notAccepted` and `value` the usual `StructuredError` (`code`, `retryAfter`); never set it from anything else. It is the only flag the machine reads from a thrown execute. `value.retryAfter` is the `ApiError`'s absolute `retryAfter`, never the body's delta `retry_after`: one more than a minute before `now` is no target (`stale_retry_after`), and the machine stops resending rather than resend at once. Exactly `true` makes the failure not in doubt (no reconciling): the receipt's signing slot reads `{ kind: "busy", retryAt, resend, of }` (phase `submitting`; suggested copy, which makes no funds claim since the signed request stays executable during the wait: "Solver busy. Resending in Ns (retry r of n)." and "Keep this tab open. If it closes, this tab will not resend the order."), and when the wait ends ([State machine](#state-machine)) the machine dispatches the identical `ExecuteRequest` (same signatures, same nonce). At most `MAX_EXECUTE_RESENDS` (3) resends, each only with a `retryAfter` and only if it lands at least `EXECUTE_RESEND_MARGIN_SECS` (5 s) before the earliest of the quote's `expiry`, the signed intent's `deadline` and the pull authorization's `validBefore` / `deadline` (`decideExecuteResend`, `executeDeadline`). Otherwise (a 4th refusal, which need not be the Worker's budget: its wallet rate limit counts none; no or a stale `retryAfter`; a resend past the deadline) the machine stops resending and shows "not sent yet": `IntentState.notSent`, phase `not-sent`, the refusal as its error, no Retry, since the Worker un-claimed the intent and its signed request stays executable until it expires. The Worker's execute answer `200 { success: false, status: "quoted", reconciling: true }` (it ended its own claim but could not finish the intent) is "not sent yet" the same way. Either is read in doubt instead, polled like any thrown execute, when the intent already reads in doubt (reconciling). The poll runs on (no "Still confirming" escalation) until the Worker ends the intent. Usually its quote sweep does, first: nearly every give-up follows an un-claim, and an intent the Worker claimed and un-claimed is finished `failed` with `not_executed` by the sweep's first run (every 5 minutes) past the quote's creation plus 90 s (its 30 s TTL plus 60 s), so 90 s to about 6.5 min after the quote. Otherwise (no request ever claimed it, as when only the Worker's own rate limit refused it) the first read past the signed deadline plus `QUOTED_EXPIRY_GRACE_SECS` (60 s) finishes it, about 6 min after the quote. `failed` with `not_executed` is Retry, and so is a 404 read past the Worker's finish (`now` later than `unexecutedFinishAt(quote)`, in ms): the sweep deletes an intent nothing ever claimed 30 minutes after its quote, so a tab that read nothing until then (asleep or frozen) finds it gone, and past the finish nothing can claim it, so nothing moved. Before the finish a 404 holds like any other (it may be a router's, a proxy's or a rollback's while the signed request is still claimable), and so does every 404 of an intent with no quote to compute it from (a resumed one: 404s then count against the poll ceiling, which ends in `pollExhausted` with Reset, never Retry). Only "not sent yet" reads a 404 so: anywhere else after an execute a 404 holds, since that execute may have been claimed. A resumed `executing` intent (no quote and no sign flow in this tab) that reads `quoted` is rebuilt as "not sent yet" (an intent already in doubt stays so), but only from a reply at least as new as every reply answered so far: status reads can answer out of order (a timer read and a returning tab's read in flight together), and a late `quoted` after a newer `executing` is no rebuild. With no quote to size the poll ceiling by, each `quoted` read refills it until the Worker ends the intent. Any other read ends "not sent yet" (`executing` or later: another execute reached it, shown in doubt; an unmarked `failed`: may have moved). Clearing the amount does not leave it; Reset does, and offering it is the host's call: the dropped intent's signed request stays executable until it expires, so a host that can replay it (a relay, a retry queue) and lets the user sign a new order may execute both. Such a host should not offer Reset here (xochi-widget does not). Suggested copy: "Not sent yet. The solver was busy. You can try again once this order expires." Those deadlines are the server's, compared with the host's clock (`now` on each message): a clock running fast gives up early ("not sent yet" while the Worker could still take the resend), one running slow resends into a refusal the Worker answers with its budget finish. The Worker keeps the same budget server-side and, past it, finishes the intent `failed` with `not_executed` and answers a 502: thrown like any execute, it reads "Still confirming" until the poll reads that `failed`, then Retry. If the tab closes during the wait, this tab does not resend the order; the signed request stays executable until it expires.
100
+ - **Only an explicit not-accepted refusal is retried, and only the same signed request.** Build the thrown execute's `ErrorReport` with `notAccepted: error instanceof ApiError && error.notAccepted` and `value` the usual `StructuredError` (`code`, `retryAfter`); never set it from anything else. It is the only flag the machine reads from a thrown execute. `value.retryAfter` is the `ApiError`'s absolute `retryAfter`, never the body's delta `retry_after`: one more than a minute before `now` is no target (`stale_retry_after`), and the machine stops resending rather than resend at once. Exactly `true` makes the failure not in doubt (no reconciling): the receipt's signing slot reads `{ kind: "busy", retryAt, resend, of }` (phase `submitting`; suggested copy, which makes no funds claim since the signed request stays executable during the wait: "Solver busy. Resending in Ns (retry r of n)." and "Keep this tab open. If it closes, this tab will not resend the order."), and when the wait ends ([State machine](#state-machine)) the machine dispatches the identical `ExecuteRequest` (same signatures, same nonce). At most `MAX_EXECUTE_RESENDS` (3) resends, each only with a `retryAfter` and only if it lands at least `EXECUTE_RESEND_MARGIN_SECS` (5 s) before the earliest of the quote's `expiry`, the signed intent's `deadline` and the pull authorization's `validBefore` / `deadline` (`decideExecuteResend`, `executeDeadline`). Otherwise (a 4th refusal, which need not be the Worker's budget: its wallet rate limit counts none; no or a stale `retryAfter`; a resend past the deadline) the machine stops resending and shows "not sent yet": `IntentState.notSent`, phase `not-sent`, the refusal as its error, no Retry, since the Worker un-claimed the intent and its signed request stays executable until it expires. The Worker's execute answer `200 { success: false, status: "quoted", reconciling: true }`, or a later status read of `quoted` after any execute reply, is "not sent yet" the same way unless the intent already reads in doubt. The poll runs on (no "Still confirming" escalation) until the Worker ends the intent. Usually its quote sweep does, first: nearly every give-up follows an un-claim, and an intent the Worker claimed and un-claimed is finished `failed` with `not_executed` by the sweep's first run (every 5 minutes) past the quote's creation plus 90 s (its 30 s TTL plus 60 s), so 90 s to about 6.5 min after the quote. Otherwise (no request ever claimed it, as when only the Worker's own rate limit refused it) the first read past the signed deadline plus `QUOTED_EXPIRY_GRACE_SECS` (60 s) finishes it, about 6 min after the quote. `failed` with `not_executed` is Retry, and so is a 404 read past the Worker's finish (`now` later than `unexecutedFinishAt(quote)`, in ms): the sweep deletes an intent nothing ever claimed 30 minutes after its quote, so a tab that read nothing until then (asleep or frozen) finds it gone, and past the finish nothing can claim it, so nothing moved. Before the finish a 404 holds like any other (it may be a router's, a proxy's or a rollback's while the signed request is still claimable), and so does every 404 of an intent with no quote to compute it from (a resumed one: 404s then count against the poll ceiling, which ends in `pollExhausted` with Reset, never Retry). Only "not sent yet" reads a 404 so: anywhere else after an execute a 404 holds, since that execute may have been claimed. A resumed `executing` intent that reads `quoted` follows the same verdict, but only from a reply at least as new as every reply answered so far: status reads can answer out of order, and a late `quoted` after a newer `executing` is no rebuild. With no quote to size the poll ceiling by, each `quoted` read refills it until the Worker ends the intent. Any other read ends "not sent yet" (`executing` or later: another execute reached it, shown in doubt; an unmarked `failed`: may have moved). Clearing the amount does not leave it; Reset does, and offering it is the host's call: the dropped intent's signed request stays executable until it expires, so a host that can replay it (a relay, a retry queue) and lets the user sign a new order may execute both. Such a host should not offer Reset here (xochi-widget does not). Suggested copy: "Not sent yet. The solver was busy. You can try again once this order expires." `NOT_SENT_SWEPT_ERROR` identifies the machine's expired-without-send Retry result. Those deadlines are the server's, compared with the host's clock (`now` on each message): a clock running fast gives up early ("not sent yet" while the Worker could still take the resend), one running slow resends into a refusal the Worker answers with its budget finish. The Worker keeps the same budget server-side and, past it, finishes the intent `failed` with `not_executed` and answers a 502: thrown like any execute, it reads "Still confirming" until the poll reads that `failed`, then Retry. If the tab closes during the wait, this tab does not resend the order; the signed request stays executable until it expires.
101
101
 
102
102
  ## Fee reference
103
103
 
@@ -7,7 +7,7 @@
7
7
  * Plus the wire decoders and the fund-safety gates the machine runs, exported
8
8
  * so the widget and headless integrators share one implementation of each.
9
9
  */
10
- export { init, update, QUOTE_DEBOUNCE_MS, QUOTE_REFRESH_BUFFER_MS, RECONCILE_AFTER_ATTEMPTS, SIGN_PROMPT_TIMEOUT_MS, type Model, type Msg, type MsgBody, type Effect, type ApiCall, type WalletCall, type TimerCall, type TimerName, type MachineEvent, type ErrorReport, type Context, type WalletContext, type Outcome, type StealthMetaAddress, type MachineConfig, type QuoteFlight, type SignFlow, type PollState, type ExecuteAttempt, } from "./machine.js";
10
+ export { init, update, QUOTE_DEBOUNCE_MS, QUOTE_REFRESH_BUFFER_MS, RECONCILE_AFTER_ATTEMPTS, SIGN_PROMPT_TIMEOUT_MS, NOT_SENT_SWEPT_ERROR, type Model, type Msg, type MsgBody, type Effect, type ApiCall, type WalletCall, type TimerCall, type TimerName, type MachineEvent, type ErrorReport, type Context, type WalletContext, type Outcome, type StealthMetaAddress, type MachineConfig, type QuoteFlight, type SignFlow, type PollState, type ExecuteAttempt, } from "./machine.js";
11
11
  export { view } from "./view.js";
12
12
  export { payoutOf, costOf, settlementDisclosure, type Receipt, type Served, type PayoutSlot, type CostSlot, type QuoteSlot, type SigningSlot, type SettlementDisclosure, } from "./receipt.js";
13
13
  export { phaseFromStatus, type SwapPhase } from "./phase.js";
@@ -23,7 +23,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
23
23
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
24
24
  };
25
25
  Object.defineProperty(exports, "__esModule", { value: true });
26
- exports.validateSwapParams = exports.applyStatusTransition = exports.phaseFromStatus = exports.settlementDisclosure = exports.costOf = exports.payoutOf = exports.view = exports.SIGN_PROMPT_TIMEOUT_MS = exports.RECONCILE_AFTER_ATTEMPTS = exports.QUOTE_REFRESH_BUFFER_MS = exports.QUOTE_DEBOUNCE_MS = exports.update = exports.init = void 0;
26
+ exports.validateSwapParams = exports.applyStatusTransition = exports.phaseFromStatus = exports.settlementDisclosure = exports.costOf = exports.payoutOf = exports.view = exports.NOT_SENT_SWEPT_ERROR = exports.SIGN_PROMPT_TIMEOUT_MS = exports.RECONCILE_AFTER_ATTEMPTS = exports.QUOTE_REFRESH_BUFFER_MS = exports.QUOTE_DEBOUNCE_MS = exports.update = exports.init = void 0;
27
27
  var machine_js_1 = require("./machine.js");
28
28
  Object.defineProperty(exports, "init", { enumerable: true, get: function () { return machine_js_1.init; } });
29
29
  Object.defineProperty(exports, "update", { enumerable: true, get: function () { return machine_js_1.update; } });
@@ -31,6 +31,7 @@ Object.defineProperty(exports, "QUOTE_DEBOUNCE_MS", { enumerable: true, get: fun
31
31
  Object.defineProperty(exports, "QUOTE_REFRESH_BUFFER_MS", { enumerable: true, get: function () { return machine_js_1.QUOTE_REFRESH_BUFFER_MS; } });
32
32
  Object.defineProperty(exports, "RECONCILE_AFTER_ATTEMPTS", { enumerable: true, get: function () { return machine_js_1.RECONCILE_AFTER_ATTEMPTS; } });
33
33
  Object.defineProperty(exports, "SIGN_PROMPT_TIMEOUT_MS", { enumerable: true, get: function () { return machine_js_1.SIGN_PROMPT_TIMEOUT_MS; } });
34
+ Object.defineProperty(exports, "NOT_SENT_SWEPT_ERROR", { enumerable: true, get: function () { return machine_js_1.NOT_SENT_SWEPT_ERROR; } });
34
35
  var view_js_1 = require("./view.js");
35
36
  Object.defineProperty(exports, "view", { enumerable: true, get: function () { return view_js_1.view; } });
36
37
  var receipt_js_1 = require("./receipt.js");
@@ -44,6 +44,8 @@ export declare const RECONCILE_AFTER_ATTEMPTS: number;
44
44
  * for a real human to review + sign, short enough to recover from a dead prompt.
45
45
  */
46
46
  export declare const SIGN_PROMPT_TIMEOUT_MS = 90000;
47
+ /** The error of a not-sent intent the poll finds gone (`onStatus`): the Retry view. */
48
+ export declare const NOT_SENT_SWEPT_ERROR = "This order expired without being sent. Nothing moved.";
47
49
  export interface StealthMetaAddress {
48
50
  spendingPubKey: string;
49
51
  viewingPubKey: string;
@@ -21,7 +21,7 @@
21
21
  * against `lib: ["ES2020"]` alone (tsconfig.machine.json).
22
22
  */
23
23
  Object.defineProperty(exports, "__esModule", { value: true });
24
- exports.SIGN_PROMPT_TIMEOUT_MS = exports.RECONCILE_AFTER_ATTEMPTS = exports.QUOTE_REFRESH_BUFFER_MS = exports.QUOTE_DEBOUNCE_MS = void 0;
24
+ exports.NOT_SENT_SWEPT_ERROR = exports.SIGN_PROMPT_TIMEOUT_MS = exports.RECONCILE_AFTER_ATTEMPTS = exports.QUOTE_REFRESH_BUFFER_MS = exports.QUOTE_DEBOUNCE_MS = void 0;
25
25
  exports.init = init;
26
26
  exports.update = update;
27
27
  exports.holdsCurrentQuote = holdsCurrentQuote;
@@ -65,7 +65,7 @@ const QUOTE_DEADLINE_SECS = 3600;
65
65
  /** Log prefix. The host hook is still `useIntent`, and its logs are grepped for it. */
66
66
  const LOG = "[useIntent]";
67
67
  /** The error of a not-sent intent the poll finds gone (`onStatus`): the Retry view. */
68
- const NOT_SENT_SWEPT_ERROR = "This order expired without being sent. Nothing moved.";
68
+ exports.NOT_SENT_SWEPT_ERROR = "This order expired without being sent. Nothing moved.";
69
69
  function init(config, ctx, hidden = false) {
70
70
  return {
71
71
  config,
@@ -226,10 +226,15 @@ function step(m, msg, fx) {
226
226
  // Mobile throttles timers while backgrounded (a wallet deep-link
227
227
  // round-trip), so snap the in-flight phase forward rather than wait out
228
228
  // the interval. Off-interval: it spends no give-up budget. A pending
229
- // resend reads no status; a return long past its time resends now.
229
+ // resend reads no status; a return long past its time resends now. A
230
+ // poll that exhausted its budget gets a fresh budget when the user
231
+ // returns instead of leaving a permanently stale receipt.
230
232
  if (awaitingResend(next))
231
233
  next = resendBackstop(next, fx, msg.now);
232
- else if (next.poll && !next.poll.stopped)
234
+ else if (next.poll?.stopped) {
235
+ next = { ...patch(next, { pollExhausted: false }), poll: null };
236
+ }
237
+ else if (next.poll)
233
238
  next = pollTick(next, fx, false);
234
239
  return next;
235
240
  }
@@ -1318,15 +1323,20 @@ function onStatus(m, fx, gen, attempt, result, now) {
1318
1323
  if (!poll || poll.gen !== gen || poll.stopped)
1319
1324
  return m;
1320
1325
  const id = poll.intentId;
1326
+ // Replies can race (an interval read and a visibility read). Once a newer
1327
+ // reply has answered, an older one cannot regress lifecycle or receipt
1328
+ // fields, including clearing a newer not-sent verdict.
1329
+ if (result.ok && attempt < poll.answered)
1330
+ return m;
1321
1331
  let next = m;
1322
- // A resumed `executing` intent (a reload: no quote in this tab, so no sign
1323
- // flow and no execute from it can be in flight or answer late) that reads
1324
- // `quoted`: the Worker's row is unclaimed and the signed request, held
1325
- // elsewhere, executes until its deadline. Not sent yet, as after a give-up,
1326
- // never "Still confirming" (whose Close invites a second signature). An
1327
- // intent in doubt stays so, and so does one a newer reply already answered:
1328
- // a late `quoted` is no answer after a newer `executing`.
1329
- const resumedUnsent = attempt >= poll.answered &&
1332
+ // An `executing` intent that reads `quoted` after /execute has answered is
1333
+ // unclaimed. Its signed request remains executable until the deadline, so
1334
+ // this is "not sent yet", never "Still confirming" (whose Close invites a
1335
+ // second signature). Do not use a status read while /execute is outstanding:
1336
+ // `armPoll` delays the first read until that reply, preventing a stale
1337
+ // pre-acceptance `quoted` answer from winning. An intent already in doubt
1338
+ // stays so, as does one whose newer status reply already answered.
1339
+ const unsentRead = attempt >= poll.answered &&
1330
1340
  result.ok &&
1331
1341
  !!result.status &&
1332
1342
  !("notFound" in result.status) &&
@@ -1334,14 +1344,14 @@ function onStatus(m, fx, gen, attempt, result, now) {
1334
1344
  result.status.unmapped !== true &&
1335
1345
  next.intent.intentId === id &&
1336
1346
  next.intent.status === "executing" &&
1337
- next.intent.quote === null &&
1347
+ next.sign === null &&
1338
1348
  !next.intent.reconciling;
1339
1349
  // A throw answers nothing, so only a reply (a 404 included) moves it.
1340
1350
  const answered = result.ok
1341
1351
  ? { ...poll, answered: Math.max(poll.answered, attempt) }
1342
1352
  : poll;
1343
- if (resumedUnsent && !next.intent.notSent) {
1344
- log(fx, "warn", `${LOG} Resumed intent ${id} reads quoted: not sent yet`);
1353
+ if (unsentRead && !next.intent.notSent) {
1354
+ log(fx, "warn", `${LOG} Intent ${id} reads quoted after execute: not sent yet`);
1345
1355
  next = patch(next, { notSent: true });
1346
1356
  }
1347
1357
  // A slow execute escalates to "Still confirming"; one not sent (`notSent`)
@@ -1372,7 +1382,7 @@ function onStatus(m, fx, gen, attempt, result, now) {
1372
1382
  // nothing while the signed request can still be claimed: hold, below.
1373
1383
  log(fx, "warn", `${LOG} A not-sent intent is gone (swept unexecuted); offering Retry`);
1374
1384
  next = transition(next, fx, "failed", {
1375
- error: NOT_SENT_SWEPT_ERROR,
1385
+ error: exports.NOT_SENT_SWEPT_ERROR,
1376
1386
  reconciling: false,
1377
1387
  mayHaveMoved: false,
1378
1388
  });
@@ -1423,15 +1433,15 @@ function onStatus(m, fx, gen, attempt, result, now) {
1423
1433
  next = patch(next, { legsWithheld });
1424
1434
  }
1425
1435
  // The server owns the active-polling decision; a phase change refills the
1426
- // give-up budget, so the ceiling bounds a swap that is not moving. A read
1427
- // the intent cannot move to (`quoted` after an execute) is no change: the
1428
- // ceiling, sized past the Worker's finish (`pollCeilingAttempts`), holds.
1429
- // A resumed not-sent intent has no quote to size it by, so each `quoted`
1430
- // read refills it: the Worker's read finish ends the row past its
1431
- // deadline, which this poll's own reads trigger.
1436
+ // give-up budget, so the ceiling bounds a swap that is not moving. A
1437
+ // resumed not-sent intent has no quote from which to size the Worker's
1438
+ // finish, so each `quoted` read refills its budget until the Worker ends
1439
+ // the row. A live intent has a quote and remains bounded by its fixed
1440
+ // ceiling even after it enters not-sent.
1441
+ const resumedNotSent = unsentRead && next.intent.notSent && next.intent.quote === null;
1432
1442
  verdict = s.terminal
1433
1443
  ? "stop"
1434
- : next.intent.status !== previous || (resumedUnsent && next.intent.notSent)
1444
+ : next.intent.status !== previous || resumedNotSent
1435
1445
  ? "progress"
1436
1446
  : undefined;
1437
1447
  }
@@ -1599,7 +1609,11 @@ function armExpiry(m, fx, now) {
1599
1609
  function armPoll(m, fx, now) {
1600
1610
  const { intentId, status } = m.intent;
1601
1611
  const waiting = awaitingResend(m);
1602
- const key = decisions_js_1.POLL_INFLIGHT_STATES.includes(status) && intentId
1612
+ // The execute reply establishes whether the request was accepted. Reading
1613
+ // status before it answers can return the pre-acceptance `quoted` row and
1614
+ // falsely decide "not sent"; the reply (including a timeout) arms the poll.
1615
+ const executePending = m.sign?.stage === "execute" && !waiting;
1616
+ const key = !executePending && decisions_js_1.POLL_INFLIGHT_STATES.includes(status) && intentId
1603
1617
  ? `${intentId}:${status}${waiting ? ":resend" : ""}`
1604
1618
  : null;
1605
1619
  if (key === (m.poll?.key ?? null))
@@ -66,10 +66,11 @@ export interface IntentState {
66
66
  /**
67
67
  * True while the latest status poll was the server's anonymous view of a
68
68
  * stealth or shielded intent (xochi#563): its on-chain legs were withheld, so
69
- * null hashes mean "not shown", not "not there". The host offers wallet SIWE
70
- * recovery on it; a later owner read (no marker) clears it. Never a token.
69
+ * null hashes mean "not shown", not "not there". False after an owner read.
70
+ * Null until the first status reply, so the host does not render an owner
71
+ * verdict before it has read one. Never a token.
71
72
  */
72
- legsWithheld: boolean;
73
+ legsWithheld: boolean | null;
73
74
  reconciling: boolean;
74
75
  /** Plain-language refund explanation; set only on a `refunded` status. */
75
76
  refundReason: string | null;
@@ -15,7 +15,7 @@ exports.INITIAL_INTENT_STATE = {
15
15
  settlementTxHash: null,
16
16
  settlementChainId: null,
17
17
  substatus: null,
18
- legsWithheld: false,
18
+ legsWithheld: null,
19
19
  reconciling: false,
20
20
  refundReason: null,
21
21
  quotedAddress: null,
@@ -391,9 +391,9 @@ exports.QuoteResponseSchema = zod_1.z
391
391
  // Optional: the POST omits it, and the GET omits it too on a pull-less route.
392
392
  nonce: zod_1.z.number().int().min(0).optional(),
393
393
  error: zod_1.z.string().optional(),
394
- // Present on the unsolvable-200 branch: `error_category` is a stable machine
395
- // key for the reject class, `detail` the solver's raw reason. Absent on
396
- // solvable quotes.
394
+ // Present on declined quotes: `error_category` is a stable machine key for
395
+ // the reject class, `detail` the solver's raw reason. Absent on solvable
396
+ // quotes.
397
397
  //
398
398
  // Enumerated so the generated spec documents the vocabulary agents branch on,
399
399
  // and so adding a category without declaring it fails the contract tests.
@@ -408,7 +408,7 @@ exports.QuoteResponseSchema = zod_1.z
408
408
  error_category: zod_1.z
409
409
  .string()
410
410
  .optional()
411
- .describe("Stable machine key for a declined quote. Branch on this rather than on `error`, whose prose may change. Known values: gas_floor_unpriced (transient, retry), origin_pull_amount_mismatch, served_payout_mismatch, settlement_type_unavailable, stealth_factory_untrusted, refund_address_invalid, amount_too_large, amount_too_small, no_liquidity, route_unsupported, invalid_request (the request shape was rejected, not the route -- see `detail` for the offending field), auth, unknown. Treat an unrecognized value as a non-retryable decline."),
411
+ .describe("Stable machine key for a declined quote. Branch on this rather than on `error`, whose prose may change. Known values: gas_floor_unpriced (transient, retry), conversion_unavailable (transient price verification, retry), inventory_unverified (transient, retry), quote_holds_exhausted (transient, retry), idempotency_key_in_flight (transient, retry with the same key), idempotency_unavailable (transient, retry with the same key), origin_pull_amount_mismatch, served_payout_mismatch, settlement_type_unavailable, stealth_factory_untrusted, refund_address_invalid, amount_too_large, amount_too_small, no_liquidity, route_unsupported, invalid_request (the request shape was rejected, not the route -- see `detail` for the offending field), auth, unknown. Treat an unrecognized value as a non-retryable decline."),
412
412
  detail: zod_1.z.string().optional(),
413
413
  // Present when `error_category` is `amount_too_small` because the route's
414
414
  // FIXED settlement cost consumed too much of the order: the smallest order
@@ -38,7 +38,7 @@ export interface RetryPolicy {
38
38
  maxAttempts: number;
39
39
  /** First exponential backoff step. */
40
40
  baseDelayMs: number;
41
- /** Clamp on a server-supplied `Retry-After` used for internal pacing. */
41
+ /** Clamp on client-generated exponential backoff delays. */
42
42
  maxDelayMs: number;
43
43
  }
44
44
  export declare const DEFAULT_TIMEOUTS: TransportTimeouts;
@@ -119,8 +119,9 @@ export declare function takeApiErrorReadToken(error: unknown): string | undefine
119
119
  /**
120
120
  * Parse a Retry-After header (our API sends integer seconds) into milliseconds,
121
121
  * clamped to [0, maxDelayMs] so a malformed or hostile value can't stall the
122
- * client. Returns null when absent/unparseable so the caller falls back to its
123
- * own exponential backoff.
122
+ * client. The default safety ceiling preserves server-directed waits up to
123
+ * MAX_RETRY_WAIT_SEC; a custom ceiling can narrow them. Returns null when
124
+ * absent/unparseable so the caller falls back to its own exponential backoff.
124
125
  */
125
126
  export declare function parseRetryAfterMs(headerValue: string | null, maxDelayMs?: number): number | null;
126
127
  /**
@@ -138,11 +138,19 @@ function isNotAcceptedExecuteBody(status, body, intentId) {
138
138
  * MAX_RETRY_WAIT_SEC.
139
139
  */
140
140
  function notAcceptedRetryAt(headers, retryAfter) {
141
- const header = headers.get("Retry-After");
142
- const headerDelta = header === null || header.trim() === "" ? NaN : Number(header);
143
- const wait = Number.isFinite(headerDelta) && headerDelta > retryAfter ? headerDelta : retryAfter;
141
+ const headerDelta = retryAfterDeltaSeconds(headers.get("Retry-After"));
142
+ const wait = headerDelta !== null && headerDelta > retryAfter ? headerDelta : retryAfter;
144
143
  return Math.ceil((Date.now() + Math.min(wait, exports.MAX_RETRY_WAIT_SEC) * 1000) / 1000);
145
144
  }
145
+ /** Retry-After's delta-seconds grammar: optional HTTP whitespace around 1*DIGIT. */
146
+ function retryAfterDeltaSeconds(headerValue) {
147
+ const value = headerValue?.trim();
148
+ if (!value || !/^\d+$/.test(value))
149
+ return null;
150
+ const seconds = Number(value);
151
+ // Syntactically valid integers beyond Number's range still take the 600 s cap.
152
+ return Number.isFinite(seconds) ? seconds : Number.MAX_SAFE_INTEGER;
153
+ }
146
154
  /**
147
155
  * Read tokens held for the errors `execute` rejected with, keyed by the error
148
156
  * so the token is never reachable from the error itself (a crash reporter or a
@@ -647,14 +655,13 @@ function readTokenFromPrefix(prefix, intentId) {
647
655
  /**
648
656
  * Parse a Retry-After header (our API sends integer seconds) into milliseconds,
649
657
  * clamped to [0, maxDelayMs] so a malformed or hostile value can't stall the
650
- * client. Returns null when absent/unparseable so the caller falls back to its
651
- * own exponential backoff.
658
+ * client. The default safety ceiling preserves server-directed waits up to
659
+ * MAX_RETRY_WAIT_SEC; a custom ceiling can narrow them. Returns null when
660
+ * absent/unparseable so the caller falls back to its own exponential backoff.
652
661
  */
653
- function parseRetryAfterMs(headerValue, maxDelayMs = exports.DEFAULT_RETRY.maxDelayMs) {
654
- if (!headerValue)
655
- return null;
656
- const seconds = Number(headerValue);
657
- if (!Number.isFinite(seconds) || seconds < 0)
662
+ function parseRetryAfterMs(headerValue, maxDelayMs = exports.MAX_RETRY_WAIT_SEC * 1000) {
663
+ const seconds = retryAfterDeltaSeconds(headerValue);
664
+ if (seconds === null)
658
665
  return null;
659
666
  return Math.min(seconds * 1000, maxDelayMs);
660
667
  }
@@ -702,12 +709,9 @@ function resolveRetryAfterSeconds(headers, body) {
702
709
  return Math.min(target, nowSec + exports.MAX_RETRY_WAIT_SEC);
703
710
  }
704
711
  function deriveRetryTarget(headers, body, nowSec) {
705
- const retryAfterHeader = headers.get("Retry-After");
706
- if (retryAfterHeader !== null) {
707
- const delta = Number(retryAfterHeader);
708
- if (Number.isFinite(delta) && delta >= 0)
709
- return nowSec + delta;
710
- }
712
+ const delta = retryAfterDeltaSeconds(headers.get("Retry-After"));
713
+ if (delta !== null)
714
+ return nowSec + delta;
711
715
  const reset = headers.get("X-RateLimit-Reset");
712
716
  if (reset !== null) {
713
717
  const abs = Number(reset);
@@ -1015,7 +1019,7 @@ function createXochiClient(options) {
1015
1019
  // A 503 is a transient solver blip (saturation / gas-oracle
1016
1020
  // unavailable); pace the retry off the server's Retry-After.
1017
1021
  if (response.status === 503) {
1018
- retryAfterMs = parseRetryAfterMs(response.headers.get("Retry-After"), retry.maxDelayMs);
1022
+ retryAfterMs = parseRetryAfterMs(response.headers.get("Retry-After"));
1019
1023
  }
1020
1024
  // A cut body does not parse, so it never carries the marker.
1021
1025
  const record = truncated ? undefined : parseJsonObject(errorBody);
@@ -1071,7 +1075,7 @@ function createXochiClient(options) {
1071
1075
  if (lastError.name === "AbortError")
1072
1076
  break;
1073
1077
  if (attempt < maxAttempts - 1) {
1074
- const delay = retryAfterMs ?? retry.baseDelayMs * Math.pow(2, attempt);
1078
+ const delay = retryAfterMs ?? Math.min(retry.baseDelayMs * Math.pow(2, attempt), retry.maxDelayMs);
1075
1079
  // A retry means the request already failed once: that is the context
1076
1080
  // for the error below if the retries run out.
1077
1081
  log("warn", `[xochi-client] Retry ${attempt + 1}/${maxAttempts} in ${delay}ms`);
@@ -1172,12 +1176,12 @@ function createXochiClient(options) {
1172
1176
  return cached.quote;
1173
1177
  }
1174
1178
  const pending = pendingQuotes.get(requestHash);
1175
- if (pending && Date.now() - pending.timestamp < timeouts.requestTimeoutMs) {
1179
+ if (pending) {
1176
1180
  log("debug", "[xochi-client] Deduplicating quote request");
1177
- return pending.promise;
1181
+ return pending;
1178
1182
  }
1179
1183
  const promise = fetchQuote(request);
1180
- pendingQuotes.set(requestHash, { promise, timestamp: Date.now() });
1184
+ pendingQuotes.set(requestHash, promise);
1181
1185
  try {
1182
1186
  const quote = await promise;
1183
1187
  // Cache successful quotes -- EXCEPT pull-bearing ones. A quote carrying a
@@ -1190,7 +1194,8 @@ function createXochiClient(options) {
1190
1194
  return quote;
1191
1195
  }
1192
1196
  finally {
1193
- pendingQuotes.delete(requestHash);
1197
+ if (pendingQuotes.get(requestHash) === promise)
1198
+ pendingQuotes.delete(requestHash);
1194
1199
  }
1195
1200
  },
1196
1201
  invalidateQuote(request) {
@@ -7,7 +7,7 @@
7
7
  * Plus the wire decoders and the fund-safety gates the machine runs, exported
8
8
  * so the widget and headless integrators share one implementation of each.
9
9
  */
10
- export { init, update, QUOTE_DEBOUNCE_MS, QUOTE_REFRESH_BUFFER_MS, RECONCILE_AFTER_ATTEMPTS, SIGN_PROMPT_TIMEOUT_MS, type Model, type Msg, type MsgBody, type Effect, type ApiCall, type WalletCall, type TimerCall, type TimerName, type MachineEvent, type ErrorReport, type Context, type WalletContext, type Outcome, type StealthMetaAddress, type MachineConfig, type QuoteFlight, type SignFlow, type PollState, type ExecuteAttempt, } from "./machine.js";
10
+ export { init, update, QUOTE_DEBOUNCE_MS, QUOTE_REFRESH_BUFFER_MS, RECONCILE_AFTER_ATTEMPTS, SIGN_PROMPT_TIMEOUT_MS, NOT_SENT_SWEPT_ERROR, type Model, type Msg, type MsgBody, type Effect, type ApiCall, type WalletCall, type TimerCall, type TimerName, type MachineEvent, type ErrorReport, type Context, type WalletContext, type Outcome, type StealthMetaAddress, type MachineConfig, type QuoteFlight, type SignFlow, type PollState, type ExecuteAttempt, } from "./machine.js";
11
11
  export { view } from "./view.js";
12
12
  export { payoutOf, costOf, settlementDisclosure, type Receipt, type Served, type PayoutSlot, type CostSlot, type QuoteSlot, type SigningSlot, type SettlementDisclosure, } from "./receipt.js";
13
13
  export { phaseFromStatus, type SwapPhase } from "./phase.js";
@@ -7,7 +7,7 @@
7
7
  * Plus the wire decoders and the fund-safety gates the machine runs, exported
8
8
  * so the widget and headless integrators share one implementation of each.
9
9
  */
10
- export { init, update, QUOTE_DEBOUNCE_MS, QUOTE_REFRESH_BUFFER_MS, RECONCILE_AFTER_ATTEMPTS, SIGN_PROMPT_TIMEOUT_MS, } from "./machine.js";
10
+ export { init, update, QUOTE_DEBOUNCE_MS, QUOTE_REFRESH_BUFFER_MS, RECONCILE_AFTER_ATTEMPTS, SIGN_PROMPT_TIMEOUT_MS, NOT_SENT_SWEPT_ERROR, } from "./machine.js";
11
11
  export { view } from "./view.js";
12
12
  export { payoutOf, costOf, settlementDisclosure, } from "./receipt.js";
13
13
  export { phaseFromStatus } from "./phase.js";
@@ -44,6 +44,8 @@ export declare const RECONCILE_AFTER_ATTEMPTS: number;
44
44
  * for a real human to review + sign, short enough to recover from a dead prompt.
45
45
  */
46
46
  export declare const SIGN_PROMPT_TIMEOUT_MS = 90000;
47
+ /** The error of a not-sent intent the poll finds gone (`onStatus`): the Retry view. */
48
+ export declare const NOT_SENT_SWEPT_ERROR = "This order expired without being sent. Nothing moved.";
47
49
  export interface StealthMetaAddress {
48
50
  spendingPubKey: string;
49
51
  viewingPubKey: string;
@@ -59,7 +59,7 @@ const QUOTE_DEADLINE_SECS = 3600;
59
59
  /** Log prefix. The host hook is still `useIntent`, and its logs are grepped for it. */
60
60
  const LOG = "[useIntent]";
61
61
  /** The error of a not-sent intent the poll finds gone (`onStatus`): the Retry view. */
62
- const NOT_SENT_SWEPT_ERROR = "This order expired without being sent. Nothing moved.";
62
+ export const NOT_SENT_SWEPT_ERROR = "This order expired without being sent. Nothing moved.";
63
63
  export function init(config, ctx, hidden = false) {
64
64
  return {
65
65
  config,
@@ -220,10 +220,15 @@ function step(m, msg, fx) {
220
220
  // Mobile throttles timers while backgrounded (a wallet deep-link
221
221
  // round-trip), so snap the in-flight phase forward rather than wait out
222
222
  // the interval. Off-interval: it spends no give-up budget. A pending
223
- // resend reads no status; a return long past its time resends now.
223
+ // resend reads no status; a return long past its time resends now. A
224
+ // poll that exhausted its budget gets a fresh budget when the user
225
+ // returns instead of leaving a permanently stale receipt.
224
226
  if (awaitingResend(next))
225
227
  next = resendBackstop(next, fx, msg.now);
226
- else if (next.poll && !next.poll.stopped)
228
+ else if (next.poll?.stopped) {
229
+ next = { ...patch(next, { pollExhausted: false }), poll: null };
230
+ }
231
+ else if (next.poll)
227
232
  next = pollTick(next, fx, false);
228
233
  return next;
229
234
  }
@@ -1312,15 +1317,20 @@ function onStatus(m, fx, gen, attempt, result, now) {
1312
1317
  if (!poll || poll.gen !== gen || poll.stopped)
1313
1318
  return m;
1314
1319
  const id = poll.intentId;
1320
+ // Replies can race (an interval read and a visibility read). Once a newer
1321
+ // reply has answered, an older one cannot regress lifecycle or receipt
1322
+ // fields, including clearing a newer not-sent verdict.
1323
+ if (result.ok && attempt < poll.answered)
1324
+ return m;
1315
1325
  let next = m;
1316
- // A resumed `executing` intent (a reload: no quote in this tab, so no sign
1317
- // flow and no execute from it can be in flight or answer late) that reads
1318
- // `quoted`: the Worker's row is unclaimed and the signed request, held
1319
- // elsewhere, executes until its deadline. Not sent yet, as after a give-up,
1320
- // never "Still confirming" (whose Close invites a second signature). An
1321
- // intent in doubt stays so, and so does one a newer reply already answered:
1322
- // a late `quoted` is no answer after a newer `executing`.
1323
- const resumedUnsent = attempt >= poll.answered &&
1326
+ // An `executing` intent that reads `quoted` after /execute has answered is
1327
+ // unclaimed. Its signed request remains executable until the deadline, so
1328
+ // this is "not sent yet", never "Still confirming" (whose Close invites a
1329
+ // second signature). Do not use a status read while /execute is outstanding:
1330
+ // `armPoll` delays the first read until that reply, preventing a stale
1331
+ // pre-acceptance `quoted` answer from winning. An intent already in doubt
1332
+ // stays so, as does one whose newer status reply already answered.
1333
+ const unsentRead = attempt >= poll.answered &&
1324
1334
  result.ok &&
1325
1335
  !!result.status &&
1326
1336
  !("notFound" in result.status) &&
@@ -1328,14 +1338,14 @@ function onStatus(m, fx, gen, attempt, result, now) {
1328
1338
  result.status.unmapped !== true &&
1329
1339
  next.intent.intentId === id &&
1330
1340
  next.intent.status === "executing" &&
1331
- next.intent.quote === null &&
1341
+ next.sign === null &&
1332
1342
  !next.intent.reconciling;
1333
1343
  // A throw answers nothing, so only a reply (a 404 included) moves it.
1334
1344
  const answered = result.ok
1335
1345
  ? { ...poll, answered: Math.max(poll.answered, attempt) }
1336
1346
  : poll;
1337
- if (resumedUnsent && !next.intent.notSent) {
1338
- log(fx, "warn", `${LOG} Resumed intent ${id} reads quoted: not sent yet`);
1347
+ if (unsentRead && !next.intent.notSent) {
1348
+ log(fx, "warn", `${LOG} Intent ${id} reads quoted after execute: not sent yet`);
1339
1349
  next = patch(next, { notSent: true });
1340
1350
  }
1341
1351
  // A slow execute escalates to "Still confirming"; one not sent (`notSent`)
@@ -1417,15 +1427,15 @@ function onStatus(m, fx, gen, attempt, result, now) {
1417
1427
  next = patch(next, { legsWithheld });
1418
1428
  }
1419
1429
  // The server owns the active-polling decision; a phase change refills the
1420
- // give-up budget, so the ceiling bounds a swap that is not moving. A read
1421
- // the intent cannot move to (`quoted` after an execute) is no change: the
1422
- // ceiling, sized past the Worker's finish (`pollCeilingAttempts`), holds.
1423
- // A resumed not-sent intent has no quote to size it by, so each `quoted`
1424
- // read refills it: the Worker's read finish ends the row past its
1425
- // deadline, which this poll's own reads trigger.
1430
+ // give-up budget, so the ceiling bounds a swap that is not moving. A
1431
+ // resumed not-sent intent has no quote from which to size the Worker's
1432
+ // finish, so each `quoted` read refills its budget until the Worker ends
1433
+ // the row. A live intent has a quote and remains bounded by its fixed
1434
+ // ceiling even after it enters not-sent.
1435
+ const resumedNotSent = unsentRead && next.intent.notSent && next.intent.quote === null;
1426
1436
  verdict = s.terminal
1427
1437
  ? "stop"
1428
- : next.intent.status !== previous || (resumedUnsent && next.intent.notSent)
1438
+ : next.intent.status !== previous || resumedNotSent
1429
1439
  ? "progress"
1430
1440
  : undefined;
1431
1441
  }
@@ -1593,7 +1603,11 @@ function armExpiry(m, fx, now) {
1593
1603
  function armPoll(m, fx, now) {
1594
1604
  const { intentId, status } = m.intent;
1595
1605
  const waiting = awaitingResend(m);
1596
- const key = POLL_INFLIGHT_STATES.includes(status) && intentId
1606
+ // The execute reply establishes whether the request was accepted. Reading
1607
+ // status before it answers can return the pre-acceptance `quoted` row and
1608
+ // falsely decide "not sent"; the reply (including a timeout) arms the poll.
1609
+ const executePending = m.sign?.stage === "execute" && !waiting;
1610
+ const key = !executePending && POLL_INFLIGHT_STATES.includes(status) && intentId
1597
1611
  ? `${intentId}:${status}${waiting ? ":resend" : ""}`
1598
1612
  : null;
1599
1613
  if (key === (m.poll?.key ?? null))
@@ -66,10 +66,11 @@ export interface IntentState {
66
66
  /**
67
67
  * True while the latest status poll was the server's anonymous view of a
68
68
  * stealth or shielded intent (xochi#563): its on-chain legs were withheld, so
69
- * null hashes mean "not shown", not "not there". The host offers wallet SIWE
70
- * recovery on it; a later owner read (no marker) clears it. Never a token.
69
+ * null hashes mean "not shown", not "not there". False after an owner read.
70
+ * Null until the first status reply, so the host does not render an owner
71
+ * verdict before it has read one. Never a token.
71
72
  */
72
- legsWithheld: boolean;
73
+ legsWithheld: boolean | null;
73
74
  reconciling: boolean;
74
75
  /** Plain-language refund explanation; set only on a `refunded` status. */
75
76
  refundReason: string | null;
@@ -12,7 +12,7 @@ export const INITIAL_INTENT_STATE = {
12
12
  settlementTxHash: null,
13
13
  settlementChainId: null,
14
14
  substatus: null,
15
- legsWithheld: false,
15
+ legsWithheld: null,
16
16
  reconciling: false,
17
17
  refundReason: null,
18
18
  quotedAddress: null,
@@ -388,9 +388,9 @@ export const QuoteResponseSchema = z
388
388
  // Optional: the POST omits it, and the GET omits it too on a pull-less route.
389
389
  nonce: z.number().int().min(0).optional(),
390
390
  error: z.string().optional(),
391
- // Present on the unsolvable-200 branch: `error_category` is a stable machine
392
- // key for the reject class, `detail` the solver's raw reason. Absent on
393
- // solvable quotes.
391
+ // Present on declined quotes: `error_category` is a stable machine key for
392
+ // the reject class, `detail` the solver's raw reason. Absent on solvable
393
+ // quotes.
394
394
  //
395
395
  // Enumerated so the generated spec documents the vocabulary agents branch on,
396
396
  // and so adding a category without declaring it fails the contract tests.
@@ -405,7 +405,7 @@ export const QuoteResponseSchema = z
405
405
  error_category: z
406
406
  .string()
407
407
  .optional()
408
- .describe("Stable machine key for a declined quote. Branch on this rather than on `error`, whose prose may change. Known values: gas_floor_unpriced (transient, retry), origin_pull_amount_mismatch, served_payout_mismatch, settlement_type_unavailable, stealth_factory_untrusted, refund_address_invalid, amount_too_large, amount_too_small, no_liquidity, route_unsupported, invalid_request (the request shape was rejected, not the route -- see `detail` for the offending field), auth, unknown. Treat an unrecognized value as a non-retryable decline."),
408
+ .describe("Stable machine key for a declined quote. Branch on this rather than on `error`, whose prose may change. Known values: gas_floor_unpriced (transient, retry), conversion_unavailable (transient price verification, retry), inventory_unverified (transient, retry), quote_holds_exhausted (transient, retry), idempotency_key_in_flight (transient, retry with the same key), idempotency_unavailable (transient, retry with the same key), origin_pull_amount_mismatch, served_payout_mismatch, settlement_type_unavailable, stealth_factory_untrusted, refund_address_invalid, amount_too_large, amount_too_small, no_liquidity, route_unsupported, invalid_request (the request shape was rejected, not the route -- see `detail` for the offending field), auth, unknown. Treat an unrecognized value as a non-retryable decline."),
409
409
  detail: z.string().optional(),
410
410
  // Present when `error_category` is `amount_too_small` because the route's
411
411
  // FIXED settlement cost consumed too much of the order: the smallest order
@@ -38,7 +38,7 @@ export interface RetryPolicy {
38
38
  maxAttempts: number;
39
39
  /** First exponential backoff step. */
40
40
  baseDelayMs: number;
41
- /** Clamp on a server-supplied `Retry-After` used for internal pacing. */
41
+ /** Clamp on client-generated exponential backoff delays. */
42
42
  maxDelayMs: number;
43
43
  }
44
44
  export declare const DEFAULT_TIMEOUTS: TransportTimeouts;
@@ -119,8 +119,9 @@ export declare function takeApiErrorReadToken(error: unknown): string | undefine
119
119
  /**
120
120
  * Parse a Retry-After header (our API sends integer seconds) into milliseconds,
121
121
  * clamped to [0, maxDelayMs] so a malformed or hostile value can't stall the
122
- * client. Returns null when absent/unparseable so the caller falls back to its
123
- * own exponential backoff.
122
+ * client. The default safety ceiling preserves server-directed waits up to
123
+ * MAX_RETRY_WAIT_SEC; a custom ceiling can narrow them. Returns null when
124
+ * absent/unparseable so the caller falls back to its own exponential backoff.
124
125
  */
125
126
  export declare function parseRetryAfterMs(headerValue: string | null, maxDelayMs?: number): number | null;
126
127
  /**
@@ -130,11 +130,19 @@ function isNotAcceptedExecuteBody(status, body, intentId) {
130
130
  * MAX_RETRY_WAIT_SEC.
131
131
  */
132
132
  function notAcceptedRetryAt(headers, retryAfter) {
133
- const header = headers.get("Retry-After");
134
- const headerDelta = header === null || header.trim() === "" ? NaN : Number(header);
135
- const wait = Number.isFinite(headerDelta) && headerDelta > retryAfter ? headerDelta : retryAfter;
133
+ const headerDelta = retryAfterDeltaSeconds(headers.get("Retry-After"));
134
+ const wait = headerDelta !== null && headerDelta > retryAfter ? headerDelta : retryAfter;
136
135
  return Math.ceil((Date.now() + Math.min(wait, MAX_RETRY_WAIT_SEC) * 1000) / 1000);
137
136
  }
137
+ /** Retry-After's delta-seconds grammar: optional HTTP whitespace around 1*DIGIT. */
138
+ function retryAfterDeltaSeconds(headerValue) {
139
+ const value = headerValue?.trim();
140
+ if (!value || !/^\d+$/.test(value))
141
+ return null;
142
+ const seconds = Number(value);
143
+ // Syntactically valid integers beyond Number's range still take the 600 s cap.
144
+ return Number.isFinite(seconds) ? seconds : Number.MAX_SAFE_INTEGER;
145
+ }
138
146
  /**
139
147
  * Read tokens held for the errors `execute` rejected with, keyed by the error
140
148
  * so the token is never reachable from the error itself (a crash reporter or a
@@ -639,14 +647,13 @@ function readTokenFromPrefix(prefix, intentId) {
639
647
  /**
640
648
  * Parse a Retry-After header (our API sends integer seconds) into milliseconds,
641
649
  * clamped to [0, maxDelayMs] so a malformed or hostile value can't stall the
642
- * client. Returns null when absent/unparseable so the caller falls back to its
643
- * own exponential backoff.
650
+ * client. The default safety ceiling preserves server-directed waits up to
651
+ * MAX_RETRY_WAIT_SEC; a custom ceiling can narrow them. Returns null when
652
+ * absent/unparseable so the caller falls back to its own exponential backoff.
644
653
  */
645
- export function parseRetryAfterMs(headerValue, maxDelayMs = DEFAULT_RETRY.maxDelayMs) {
646
- if (!headerValue)
647
- return null;
648
- const seconds = Number(headerValue);
649
- if (!Number.isFinite(seconds) || seconds < 0)
654
+ export function parseRetryAfterMs(headerValue, maxDelayMs = MAX_RETRY_WAIT_SEC * 1000) {
655
+ const seconds = retryAfterDeltaSeconds(headerValue);
656
+ if (seconds === null)
650
657
  return null;
651
658
  return Math.min(seconds * 1000, maxDelayMs);
652
659
  }
@@ -694,12 +701,9 @@ export function resolveRetryAfterSeconds(headers, body) {
694
701
  return Math.min(target, nowSec + MAX_RETRY_WAIT_SEC);
695
702
  }
696
703
  function deriveRetryTarget(headers, body, nowSec) {
697
- const retryAfterHeader = headers.get("Retry-After");
698
- if (retryAfterHeader !== null) {
699
- const delta = Number(retryAfterHeader);
700
- if (Number.isFinite(delta) && delta >= 0)
701
- return nowSec + delta;
702
- }
704
+ const delta = retryAfterDeltaSeconds(headers.get("Retry-After"));
705
+ if (delta !== null)
706
+ return nowSec + delta;
703
707
  const reset = headers.get("X-RateLimit-Reset");
704
708
  if (reset !== null) {
705
709
  const abs = Number(reset);
@@ -1006,7 +1010,7 @@ export function createXochiClient(options) {
1006
1010
  // A 503 is a transient solver blip (saturation / gas-oracle
1007
1011
  // unavailable); pace the retry off the server's Retry-After.
1008
1012
  if (response.status === 503) {
1009
- retryAfterMs = parseRetryAfterMs(response.headers.get("Retry-After"), retry.maxDelayMs);
1013
+ retryAfterMs = parseRetryAfterMs(response.headers.get("Retry-After"));
1010
1014
  }
1011
1015
  // A cut body does not parse, so it never carries the marker.
1012
1016
  const record = truncated ? undefined : parseJsonObject(errorBody);
@@ -1062,7 +1066,7 @@ export function createXochiClient(options) {
1062
1066
  if (lastError.name === "AbortError")
1063
1067
  break;
1064
1068
  if (attempt < maxAttempts - 1) {
1065
- const delay = retryAfterMs ?? retry.baseDelayMs * Math.pow(2, attempt);
1069
+ const delay = retryAfterMs ?? Math.min(retry.baseDelayMs * Math.pow(2, attempt), retry.maxDelayMs);
1066
1070
  // A retry means the request already failed once: that is the context
1067
1071
  // for the error below if the retries run out.
1068
1072
  log("warn", `[xochi-client] Retry ${attempt + 1}/${maxAttempts} in ${delay}ms`);
@@ -1163,12 +1167,12 @@ export function createXochiClient(options) {
1163
1167
  return cached.quote;
1164
1168
  }
1165
1169
  const pending = pendingQuotes.get(requestHash);
1166
- if (pending && Date.now() - pending.timestamp < timeouts.requestTimeoutMs) {
1170
+ if (pending) {
1167
1171
  log("debug", "[xochi-client] Deduplicating quote request");
1168
- return pending.promise;
1172
+ return pending;
1169
1173
  }
1170
1174
  const promise = fetchQuote(request);
1171
- pendingQuotes.set(requestHash, { promise, timestamp: Date.now() });
1175
+ pendingQuotes.set(requestHash, promise);
1172
1176
  try {
1173
1177
  const quote = await promise;
1174
1178
  // Cache successful quotes -- EXCEPT pull-bearing ones. A quote carrying a
@@ -1181,7 +1185,8 @@ export function createXochiClient(options) {
1181
1185
  return quote;
1182
1186
  }
1183
1187
  finally {
1184
- pendingQuotes.delete(requestHash);
1188
+ if (pendingQuotes.get(requestHash) === promise)
1189
+ pendingQuotes.delete(requestHash);
1185
1190
  }
1186
1191
  },
1187
1192
  invalidateQuote(request) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xochi/client",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Xochi trade API client: transport, trade state machine, pre-sign fund-safety checks.",
5
5
  "keywords": [
6
6
  "xochi",