@xochi/client 0.1.1 → 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,35 @@ 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
+
26
+ ## [0.1.2] - 2026-10-27
27
+
28
+ ### Added
29
+
30
+ - Typed wire shapes and a zod decoder for Riddler's shielded terms preview (`POST /xochi/shielded/preview`, Riddler#1296): `ShieldedPreviewResponseSchema`, `ShieldedPreviewSchema`, `ShieldedFeeBreakdownSchema`, `ShieldedNetworkIdentitySchema`, `ShieldedCorridorSchema`, the `ShieldedPreviewRequestWire` / `ShieldedPreviewResponseWire` types and `decodeShieldedPreview(body)`, which also refuses a fee breakdown whose totals do not add up. Shapes only, with no transport method: the preview needs a `quote`-scoped Riddler credential, and the Xochi Worker proxy route that would expose it is a follow-up owned by xochi#635. A test holds the schemas' members to the vendored `wire/openapi.json`.
31
+
32
+ ### Fixed
33
+
34
+ - A wallet signature prompt can be cancelled immediately, and every prompt now times out after 90 seconds. A dismissed WalletConnect request previously left consumers in `signing` forever with no retry action; `CancelSignRequested` returns the unchanged quote to its confirm surface, timeout returns to retry or quote refresh as its expiry allows, and any late signature is ignored.
35
+
7
36
  ## [0.1.1] - 2026-10-26
8
37
 
9
38
  ### 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)).
@@ -83,6 +83,7 @@ Lifecycle rules the machine enforces, beyond the signer checks under [Fund safet
83
83
  - Confirm is gated on `canSignQuote` and `holdsCurrentQuote`: a held quote is signable (and a deposit address revealable) only while its quoted amount and trade match the form. The trade key is built from the same normalised fields as the quote request (`normalizeTradeFields`), so an edit that leaves the request unchanged keeps the quote.
84
84
  - A new amount, a trade/wallet context change, Reset, a resume, or entering `signing` / `awaiting_deposit` / `executing` drops in-flight quote flights. Refresh is ignored past `quoting`, Allocate outside `ALLOCATABLE_STATES` (idle / pending / quoting / quoted), and a quote reply that finds the trade past `quoting` or ended is dropped; a failed allocation never reopens a trade as `quoted`.
85
85
  - The sign ceremony survives `signing -> executing` only for the machine's own execute step (`sign.stage === "execute"`); a status-driven move abandons it, and a signature reply outside `signing` is ignored.
86
+ - Every wallet prompt arms the one-shot `signPrompt` timer for `SIGN_PROMPT_TIMEOUT_MS` (90 s). Schedule it like the other timers. A host should expose `CancelSignRequested` while `view(model).phase === "awaiting-wallet"`; cancellation returns to the unchanged quote, clears the live ceremony and ignores a late signature. A timeout does the same, then normal quote expiry may move the receipt to its refresh surface.
86
87
  - `StreamStatus` frames for another intent are ignored. Read stream frames with `mapStatusFrame`: it passes in-flight frames only, so every terminal arrives by the poll with its receipt and reason. A `failed` frame a host dispatches itself follows the poll's rule ([Fund safety](#fund-safety)).
87
88
  - An execute reported `notAccepted` (below) waits with no status read and resends from the `execute` timer under a new flow id; a status move off `executing`, Reset, or any end of the ceremony drops the pending resend (`clear:execute`). **Hosts must schedule the `execute` timer** like the others and render the `busy` signing slot. A host that drops the timer is slowed, not stranded: the poll timer stays armed through the wait, and the first poll tick (every `STATUS_POLL_MS`, 4 s) or returning tab (`VisibilityChanged`) at or after the resend time does the timer's work, so the resend goes up to 4 s late. The deadline check still decides: a resend that late can fall inside `EXECUTE_RESEND_MARGIN_SECS`, and then the machine stops resending ("not sent yet", [Fund safety](#fund-safety)) instead.
88
89
 
@@ -94,9 +95,9 @@ The machine checks every quote against the request (via `@xochi/signer`), the pu
94
95
  - **Quotes with an `escrow` envelope are refused.**
95
96
  - **A deposit address is never revealed with under 60 s of its attested window left.**
96
97
  - **On resume, run stored deposits through `verifyStoredDeposit`** and render and re-persist `result.stored`, not your input.
97
- - **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.
98
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).
99
- - **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.
100
101
 
101
102
  ## Fee reference
102
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;
@@ -219,6 +221,8 @@ export type MsgBody = {
219
221
  type: "SignRequested";
220
222
  } | {
221
223
  type: "PullSignRequested";
224
+ } | {
225
+ type: "CancelSignRequested";
222
226
  } | {
223
227
  type: "ResetRequested";
224
228
  } | {
@@ -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,
@@ -162,6 +162,10 @@ function step(m, msg, fx) {
162
162
  return m.amount ? startQuote(m, fx, msg.now, quoteOpts(m.amount)) : m;
163
163
  case "SignRequested":
164
164
  return beginSign(m, fx, msg.now);
165
+ case "CancelSignRequested":
166
+ if (m.intent.status !== "signing" || !m.sign)
167
+ return m;
168
+ return transition(abandonSign(m, fx), fx, "quoted", { error: "Signature cancelled" });
165
169
  case "PullSignRequested":
166
170
  return beginPullSign(m, fx, msg.now);
167
171
  case "ResetRequested":
@@ -222,10 +226,15 @@ function step(m, msg, fx) {
222
226
  // Mobile throttles timers while backgrounded (a wallet deep-link
223
227
  // round-trip), so snap the in-flight phase forward rather than wait out
224
228
  // the interval. Off-interval: it spends no give-up budget. A pending
225
- // 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.
226
232
  if (awaitingResend(next))
227
233
  next = resendBackstop(next, fx, msg.now);
228
- 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)
229
238
  next = pollTick(next, fx, false);
230
239
  return next;
231
240
  }
@@ -723,8 +732,9 @@ function onTimer(m, fx, timer, now) {
723
732
  return resendBackstop(m, fx, now);
724
733
  return m.poll && !m.poll.stopped ? pollTick(m, fx, true) : m;
725
734
  case "signPrompt":
726
- // A deep-link prompt that never re-opened the wallet.
727
- return m.sign?.stage === "pull"
735
+ // A wallet prompt that never resolved. The flow still owns the prompt;
736
+ // `signFlowFailed` makes any reply arriving after this timer inert.
737
+ return m.sign
728
738
  ? signFlowFailed(m, fx, {
729
739
  rejected: false,
730
740
  value: "wallet_prompt_timeout",
@@ -832,6 +842,10 @@ function beginSign(m, fx, now) {
832
842
  chainId: next.ctx.wallet.chainId,
833
843
  },
834
844
  });
845
+ // A wallet request can be dismissed without rejecting (mobile deep links in
846
+ // particular). Bound every prompt, including signature 1, so the ceremony
847
+ // always returns to an actionable surface.
848
+ setTimer(fx, "signPrompt", exports.SIGN_PROMPT_TIMEOUT_MS, false);
835
849
  return { ...next, sign: { id, origin: "sign", stage: "intent", intentSignature: null } };
836
850
  }
837
851
  function beginPullSign(m, fx, now) {
@@ -932,8 +946,7 @@ function onSigned(m, fx, flowId, purpose, result, now) {
932
946
  // open; its signature then belongs to no ceremony.
933
947
  if (m.intent.status !== "signing")
934
948
  return abandonSign(m, fx);
935
- if (purpose === "pull")
936
- clearTimer(fx, "signPrompt");
949
+ clearTimer(fx, "signPrompt");
937
950
  if (!result.ok)
938
951
  return signFlowFailed(m, fx, result.error);
939
952
  if (purpose === "pull") {
@@ -1216,7 +1229,7 @@ function endSignFlow(m, flow) {
1216
1229
  * affordance, no prompt timer. Nothing signed so far can execute after this.
1217
1230
  */
1218
1231
  function abandonSign(m, fx) {
1219
- if (m.sign?.stage === "pull")
1232
+ if (m.sign)
1220
1233
  clearTimer(fx, "signPrompt");
1221
1234
  if (awaitingResend(m))
1222
1235
  clearTimer(fx, "execute");
@@ -1232,12 +1245,16 @@ function signFlowFailed(m, fx, report) {
1232
1245
  const flow = m.sign;
1233
1246
  if (!flow)
1234
1247
  return m;
1235
- if (flow.stage === "pull")
1236
- clearTimer(fx, "signPrompt");
1248
+ clearTimer(fx, "signPrompt");
1237
1249
  const next = endSignFlow(m, flow);
1238
1250
  if (flow.origin === "sign") {
1239
1251
  if (report.rejected)
1240
1252
  return transition(next, fx, "quoted", { error: "Signature rejected" });
1253
+ if (report.value === "wallet_prompt_timeout") {
1254
+ return transition(next, fx, "quoted", {
1255
+ error: "Couldn't reach the wallet. Press Confirm to try again.",
1256
+ });
1257
+ }
1241
1258
  log(fx, "error", `${LOG} Sign error:`, report.detail);
1242
1259
  return transition(next, fx, "failed", { error: report.value ?? "Signing failed" });
1243
1260
  }
@@ -1306,15 +1323,20 @@ function onStatus(m, fx, gen, attempt, result, now) {
1306
1323
  if (!poll || poll.gen !== gen || poll.stopped)
1307
1324
  return m;
1308
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;
1309
1331
  let next = m;
1310
- // A resumed `executing` intent (a reload: no quote in this tab, so no sign
1311
- // flow and no execute from it can be in flight or answer late) that reads
1312
- // `quoted`: the Worker's row is unclaimed and the signed request, held
1313
- // elsewhere, executes until its deadline. Not sent yet, as after a give-up,
1314
- // never "Still confirming" (whose Close invites a second signature). An
1315
- // intent in doubt stays so, and so does one a newer reply already answered:
1316
- // a late `quoted` is no answer after a newer `executing`.
1317
- 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 &&
1318
1340
  result.ok &&
1319
1341
  !!result.status &&
1320
1342
  !("notFound" in result.status) &&
@@ -1322,14 +1344,14 @@ function onStatus(m, fx, gen, attempt, result, now) {
1322
1344
  result.status.unmapped !== true &&
1323
1345
  next.intent.intentId === id &&
1324
1346
  next.intent.status === "executing" &&
1325
- next.intent.quote === null &&
1347
+ next.sign === null &&
1326
1348
  !next.intent.reconciling;
1327
1349
  // A throw answers nothing, so only a reply (a 404 included) moves it.
1328
1350
  const answered = result.ok
1329
1351
  ? { ...poll, answered: Math.max(poll.answered, attempt) }
1330
1352
  : poll;
1331
- if (resumedUnsent && !next.intent.notSent) {
1332
- 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`);
1333
1355
  next = patch(next, { notSent: true });
1334
1356
  }
1335
1357
  // A slow execute escalates to "Still confirming"; one not sent (`notSent`)
@@ -1360,7 +1382,7 @@ function onStatus(m, fx, gen, attempt, result, now) {
1360
1382
  // nothing while the signed request can still be claimed: hold, below.
1361
1383
  log(fx, "warn", `${LOG} A not-sent intent is gone (swept unexecuted); offering Retry`);
1362
1384
  next = transition(next, fx, "failed", {
1363
- error: NOT_SENT_SWEPT_ERROR,
1385
+ error: exports.NOT_SENT_SWEPT_ERROR,
1364
1386
  reconciling: false,
1365
1387
  mayHaveMoved: false,
1366
1388
  });
@@ -1411,15 +1433,15 @@ function onStatus(m, fx, gen, attempt, result, now) {
1411
1433
  next = patch(next, { legsWithheld });
1412
1434
  }
1413
1435
  // The server owns the active-polling decision; a phase change refills the
1414
- // give-up budget, so the ceiling bounds a swap that is not moving. A read
1415
- // the intent cannot move to (`quoted` after an execute) is no change: the
1416
- // ceiling, sized past the Worker's finish (`pollCeilingAttempts`), holds.
1417
- // A resumed not-sent intent has no quote to size it by, so each `quoted`
1418
- // read refills it: the Worker's read finish ends the row past its
1419
- // 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;
1420
1442
  verdict = s.terminal
1421
1443
  ? "stop"
1422
- : next.intent.status !== previous || (resumedUnsent && next.intent.notSent)
1444
+ : next.intent.status !== previous || resumedNotSent
1423
1445
  ? "progress"
1424
1446
  : undefined;
1425
1447
  }
@@ -1587,7 +1609,11 @@ function armExpiry(m, fx, now) {
1587
1609
  function armPoll(m, fx, now) {
1588
1610
  const { intentId, status } = m.intent;
1589
1611
  const waiting = awaitingResend(m);
1590
- 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
1591
1617
  ? `${intentId}:${status}${waiting ? ":resend" : ""}`
1592
1618
  : null;
1593
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,
@@ -13,3 +13,4 @@ export * from "./validators.js";
13
13
  export * from "./schemas.js";
14
14
  export * from "./api.js";
15
15
  export * from "./intentStream.js";
16
+ export * from "./shieldedPreview.js";
@@ -29,3 +29,4 @@ __exportStar(require("./validators.js"), exports);
29
29
  __exportStar(require("./schemas.js"), exports);
30
30
  __exportStar(require("./api.js"), exports);
31
31
  __exportStar(require("./intentStream.js"), exports);
32
+ __exportStar(require("./shieldedPreview.js"), exports);
@@ -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