@xochi/client 0.0.0-stage → 0.1.1

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.
Files changed (125) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/LICENSE +21 -0
  3. package/README.md +119 -2
  4. package/dist/cjs/claim.d.ts +33 -0
  5. package/dist/cjs/claim.js +44 -0
  6. package/dist/cjs/fee.d.ts +247 -0
  7. package/dist/cjs/fee.js +295 -0
  8. package/dist/cjs/index.d.ts +18 -0
  9. package/dist/cjs/index.js +33 -0
  10. package/dist/cjs/machine/claimInfo.d.ts +29 -0
  11. package/dist/cjs/machine/claimInfo.js +26 -0
  12. package/dist/cjs/machine/decisions.d.ts +452 -0
  13. package/dist/cjs/machine/decisions.js +665 -0
  14. package/dist/cjs/machine/deposit.d.ts +113 -0
  15. package/dist/cjs/machine/deposit.js +265 -0
  16. package/dist/cjs/machine/index.d.ts +23 -0
  17. package/dist/cjs/machine/index.js +53 -0
  18. package/dist/cjs/machine/machine.d.ts +409 -0
  19. package/dist/cjs/machine/machine.js +1615 -0
  20. package/dist/cjs/machine/phase.d.ts +24 -0
  21. package/dist/cjs/machine/phase.js +45 -0
  22. package/dist/cjs/machine/quoteChecks.d.ts +41 -0
  23. package/dist/cjs/machine/quoteChecks.js +82 -0
  24. package/dist/cjs/machine/receipt.d.ts +151 -0
  25. package/dist/cjs/machine/receipt.js +69 -0
  26. package/dist/cjs/machine/recipientEcho.d.ts +16 -0
  27. package/dist/cjs/machine/recipientEcho.js +27 -0
  28. package/dist/cjs/machine/solverPin.d.ts +110 -0
  29. package/dist/cjs/machine/solverPin.js +214 -0
  30. package/dist/cjs/machine/state.d.ts +167 -0
  31. package/dist/cjs/machine/state.js +31 -0
  32. package/dist/cjs/machine/transition.d.ts +15 -0
  33. package/dist/cjs/machine/transition.js +20 -0
  34. package/dist/cjs/machine/validateRequest.d.ts +15 -0
  35. package/dist/cjs/machine/validateRequest.js +127 -0
  36. package/dist/cjs/machine/view.d.ts +3 -0
  37. package/dist/cjs/machine/view.js +74 -0
  38. package/dist/cjs/machine/wire.d.ts +527 -0
  39. package/dist/cjs/machine/wire.js +280 -0
  40. package/dist/cjs/package.json +1 -0
  41. package/dist/cjs/shared/api.d.ts +9 -0
  42. package/dist/cjs/shared/api.js +5 -0
  43. package/dist/cjs/shared/chains.d.ts +243 -0
  44. package/dist/cjs/shared/chains.js +250 -0
  45. package/dist/cjs/shared/eip712.d.ts +44 -0
  46. package/dist/cjs/shared/eip712.js +64 -0
  47. package/dist/cjs/shared/index.d.ts +15 -0
  48. package/dist/cjs/shared/index.js +31 -0
  49. package/dist/cjs/shared/intentStream.d.ts +74 -0
  50. package/dist/cjs/shared/intentStream.js +59 -0
  51. package/dist/cjs/shared/intents.d.ts +88 -0
  52. package/dist/cjs/shared/intents.js +227 -0
  53. package/dist/cjs/shared/schemas.d.ts +928 -0
  54. package/dist/cjs/shared/schemas.js +524 -0
  55. package/dist/cjs/shared/stealth.d.ts +53 -0
  56. package/dist/cjs/shared/stealth.js +68 -0
  57. package/dist/cjs/shared/trading.d.ts +153 -0
  58. package/dist/cjs/shared/trading.js +185 -0
  59. package/dist/cjs/shared/types.d.ts +37 -0
  60. package/dist/cjs/shared/types.js +7 -0
  61. package/dist/cjs/shared/validators.d.ts +30 -0
  62. package/dist/cjs/shared/validators.js +79 -0
  63. package/dist/cjs/transport/client.d.ts +234 -0
  64. package/dist/cjs/transport/client.js +1456 -0
  65. package/dist/esm/claim.d.ts +33 -0
  66. package/dist/esm/claim.js +41 -0
  67. package/dist/esm/fee.d.ts +247 -0
  68. package/dist/esm/fee.js +285 -0
  69. package/dist/esm/index.d.ts +18 -0
  70. package/dist/esm/index.js +17 -0
  71. package/dist/esm/machine/claimInfo.d.ts +29 -0
  72. package/dist/esm/machine/claimInfo.js +23 -0
  73. package/dist/esm/machine/decisions.d.ts +452 -0
  74. package/dist/esm/machine/decisions.js +640 -0
  75. package/dist/esm/machine/deposit.d.ts +113 -0
  76. package/dist/esm/machine/deposit.js +260 -0
  77. package/dist/esm/machine/index.d.ts +23 -0
  78. package/dist/esm/machine/index.js +23 -0
  79. package/dist/esm/machine/machine.d.ts +409 -0
  80. package/dist/esm/machine/machine.js +1609 -0
  81. package/dist/esm/machine/phase.d.ts +24 -0
  82. package/dist/esm/machine/phase.js +42 -0
  83. package/dist/esm/machine/quoteChecks.d.ts +41 -0
  84. package/dist/esm/machine/quoteChecks.js +78 -0
  85. package/dist/esm/machine/receipt.d.ts +151 -0
  86. package/dist/esm/machine/receipt.js +64 -0
  87. package/dist/esm/machine/recipientEcho.d.ts +16 -0
  88. package/dist/esm/machine/recipientEcho.js +24 -0
  89. package/dist/esm/machine/solverPin.d.ts +110 -0
  90. package/dist/esm/machine/solverPin.js +210 -0
  91. package/dist/esm/machine/state.d.ts +167 -0
  92. package/dist/esm/machine/state.js +28 -0
  93. package/dist/esm/machine/transition.d.ts +15 -0
  94. package/dist/esm/machine/transition.js +17 -0
  95. package/dist/esm/machine/validateRequest.d.ts +15 -0
  96. package/dist/esm/machine/validateRequest.js +123 -0
  97. package/dist/esm/machine/view.d.ts +3 -0
  98. package/dist/esm/machine/view.js +71 -0
  99. package/dist/esm/machine/wire.d.ts +527 -0
  100. package/dist/esm/machine/wire.js +266 -0
  101. package/dist/esm/shared/api.d.ts +9 -0
  102. package/dist/esm/shared/api.js +4 -0
  103. package/dist/esm/shared/chains.d.ts +243 -0
  104. package/dist/esm/shared/chains.js +236 -0
  105. package/dist/esm/shared/eip712.d.ts +44 -0
  106. package/dist/esm/shared/eip712.js +61 -0
  107. package/dist/esm/shared/index.d.ts +15 -0
  108. package/dist/esm/shared/index.js +15 -0
  109. package/dist/esm/shared/intentStream.d.ts +74 -0
  110. package/dist/esm/shared/intentStream.js +55 -0
  111. package/dist/esm/shared/intents.d.ts +88 -0
  112. package/dist/esm/shared/intents.js +213 -0
  113. package/dist/esm/shared/schemas.d.ts +928 -0
  114. package/dist/esm/shared/schemas.js +521 -0
  115. package/dist/esm/shared/stealth.d.ts +53 -0
  116. package/dist/esm/shared/stealth.js +64 -0
  117. package/dist/esm/shared/trading.d.ts +153 -0
  118. package/dist/esm/shared/trading.js +182 -0
  119. package/dist/esm/shared/types.d.ts +37 -0
  120. package/dist/esm/shared/types.js +6 -0
  121. package/dist/esm/shared/validators.d.ts +30 -0
  122. package/dist/esm/shared/validators.js +74 -0
  123. package/dist/esm/transport/client.d.ts +234 -0
  124. package/dist/esm/transport/client.js +1447 -0
  125. package/package.json +61 -4
package/CHANGELOG.md ADDED
@@ -0,0 +1,54 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@xochi/client` are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versions follow [SemVer](https://semver.org/).
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.1.1] - 2026-10-26
8
+
9
+ ### Added
10
+
11
+ - `feeBreakdownReconcilable(feeBreakdown)` lets a `getQuote` result reach `assertFeeBreakdownInvariants` (the README example threw).
12
+ - `LayeredFeeBreakdownSchema` declares the served optional `discount { source: "tier" | "promo", bps, amount }` (xochi#584, served only when the quote request carried `promo_discount_bps > 0`). Shape-only: `assertFeeBreakdownInvariants` neither reads nor subtracts it, since `venue_fee`/`routing_fee` are already net of it. Previously it parsed through `.passthrough()` untyped.
13
+ - **Intent read capability** (xochi#563). `ExecuteResponseSchema` declares the optional `read_token` (32 random bytes, unpadded base64url; older servers omit it), surfaced as `ExecuteResponse.readToken` only when the response's `intent_id` is the executed intent. `getIntent` / `getIntentStatus` take `{ readToken }` and send it as the `X-Xochi-Read-Token` header, never in the URL, and this transport never sends it off the `baseUrl` origin (`redirect: "manual"`, which Cloudflare Workers accepts; an injected `fetch` that ignores it is the caller's leak). The server side is xochi#565 (merged as `0317e84`), served once it is deployed: a stealth intent read with the token gets the full record, and one without it (or an owner SIWE session) the status-only view; a token read of a shielded intent gets only the status-only view, as an anonymous read does (SPEC.md §5.1), and only a SIWE session for the intent's wallet reads its full record. A status-only view carries `substatus` only when it is `not_executed`. `getIntent` rejects a malformed `readToken`; `getIntentStatus` reads anonymously instead, so a caller bug cannot stall the poll. Every intent read, with or without the token, is pinned to the origin and refused as `RedirectRefused` otherwise; a refused token status read is retried without the token, following redirects natively, which also covers a browser's `opaqueredirect`. Rules: README "Transport". New exports `READ_TOKEN_LENGTH`, `READ_TOKEN_PATTERN`, `READ_TOKEN_HEADER`, `IntentReadOptions`, `takeApiErrorReadToken`, `RedirectRefused`, `WithheldIntentRecord`, `mapIntentRead`, `ExecuteReply`, `withheldSettlementConfirmed`.
14
+ - A malformed served `read_token` is dropped and the execute still parses (README "Transport").
15
+ - A failed execute whose body carries `read_token` for the intent it executed (the server serves it on a 502 when funds may have moved) hands it out once through `takeApiErrorReadToken(error)`; the token is never a property of the error, so error collectors, debuggers and property-inspecting loggers cannot reach it. The same channel carries the token of a 2xx execute whose other fields fail validation (`Invalid response format`), and of an error body cut by the read cap, read from its prefix by strict match. `ApiError.message` now carries a capped, redacted excerpt of the error body instead of the raw body (encodings and splits of the token included: a split across JSON's `\n`, `\r\n`, `\r`, `\t`, `\b`, `\f` or `\/` escapes, a lone CR, a token written after a backslash, whole or split once, whatever escape its first characters read as, and random pieces reading as names inside a chain of token pieces); backslashes as text stay as written (no escape starts at an escaped backslash, and nothing decodes to a backslash), and every error body is read only up to a cap (caps, redaction and what stays uncovered: README "Transport"); before, the token reached the raw message and was otherwise lost. When a token is lost for good: README "Transport".
16
+ - `getIntentStatus` surfaces the server's anonymous-view markers as `legsWithheld` and `settlementConfirmed`, and the machine shows a confirmed fill as delivered (`substatus: "filled"`) without changing `status` or `terminal`; when the marker counts (never on a failure, refund or unmapped status) is `withheldSettlementConfirmed`'s rule, applied by the transport, `getIntent` and the machine. `IntentState.legsWithheld` carries each status poll's verdict (an anonymous read sets it, an owner read clears it) so a host can offer wallet SIWE recovery.
17
+ - **Execute backpressure: the Worker's not-accepted refusal is resent, nothing else** (xochi#686). `ApiError.notAccepted` marks it (what counts, and the wait it carries in `retryAfter`: README "Transport"); `execute` stays single-attempt. A host passes the flag as `ErrorReport.notAccepted`; the machine then waits with no status read, shows the new signing slot `{ kind: "busy", retryAt, resend, of }`, and sends the identical `ExecuteRequest` again from the new `execute` timer, within `MAX_EXECUTE_RESENDS` (3) and `EXECUTE_RESEND_MARGIN_SECS` (5 s) before the earliest deadline (`decideExecuteResend`, `executeDeadline`), else "not sent yet" (below). Both constants are exported from the `@xochi/shared` mirror, as the Worker enforces them. A `retryAfter` more than a minute old (a delta passed where the absolute target belongs) gives up instead of resending at once, and the deadlines are judged on the host's clock (README "Fund safety"). `SignFlow.execute` (`ExecuteAttempt`) holds the request. Until the Riddler and Worker sides deploy the marker never arrives and nothing changes.
18
+ - **A not-accepted execute this client stops resending is "not sent yet", never Retry** (xochi#686). Every give-up (a 4th refusal, no or a stale `retryAfter`, a resend past the deadline), and the Worker's execute answer `200 { success: false, status: "quoted", reconciling: true }` (it ended its own claim but could not finish the intent), keeps the intent `executing` with the new `IntentState.notSent` (phase `not-sent`): the intent is `quoted` again, so its signed request stays executable until it expires. The poll runs on until the Worker ends the intent: usually its quote sweep first (an intent claimed and un-claimed, 90 s to about 6.5 min after the quote), otherwise the first read past the signed deadline plus `QUOTED_EXPIRY_GRACE_SECS` (60 s, new in the `@xochi/shared` mirror); `failed` with `not_executed` is Retry, and so is a 404 while "not sent yet" once `now` is past `unexecutedFinishAt(quote)` (the sweep deletes an intent nothing claimed after 30 minutes, and past the finish nothing can claim it); an earlier 404, one with no quote (a resumed intent) and a 404 anywhere else hold. A resumed `executing` intent that reads `quoted` is rebuilt as "not sent yet", its poll refilled by each `quoted` read, but only from a reply at least as new as every one answered (`PollState.answered`): a late `quoted` after a newer `executing` is no rebuild. Any other read ends the mark. Clearing the amount does not leave it; Reset does, and the README says when a host should not offer it (README "Fund safety").
19
+ - **The poll outlasts the Worker's finish of a never-claimed intent** (xochi#686). In `executing` the poll ceiling runs at least until `unexecutedFinishAt(quote)` (the later of the quote's `expiry` and the signed `deadline`, plus `QUOTED_EXPIRY_GRACE_SECS`) and one more poll, so an execute that never reached the Worker (a refused redirect, a timeout before any answer) reads `quoted` until the Worker finishes it `failed` with `not_executed`, then Retry. A read the intent cannot move to (`quoted` after an execute) no longer refills the poll budget (README "Fund safety").
20
+ - **After an execute, Retry only on the Worker's `not_executed`** (xochi#686). The `@xochi/shared` mirror gains `NOT_EXECUTED_SUBSTATUS` (its doc mirrors the one list of the marker's sources and serving points in xochi's contract doc) and an optional `substatus` on `ExecuteResponseSchema` and the stream frame (`IntentStatusFrameSchema`, `buildIntentStatusFrame`); `IntentRecord` / `WithheldIntentRecord` carry it as `substatus`. New: `IntentState.mayHaveMoved`, the phase `may-have-moved`, `failureMayHaveMoved`, and `ResumeRequested.substatus` for the record's marker as `getIntent` returns it at resume (README "Fund safety"). The rule covers every in-flight status (`POLL_INFLIGHT_STATES`), `awaiting_deposit` included: a deposit may have been sent unseen. `ApiError.inDoubt` says whether a failed execute answer cannot vouch that nothing moved for the executed intent: `false` only on the Worker's three answers that speak for the intent (the not-accepted 503, its 502 `failed` marked `not_executed`, its 409 `promo_unavailable`); `true` on every other answer, since another request may hold the intent: every other 4xx (a parse 400, a 403 `api_key_intent_mismatch`, a signature 401 and the signed-deadline 400 included), every 429 and 5xx, and any answer carrying a read token or `reconciling: true`. No error text is read (README "Transport"). It is for the host's copy and logs; the machine does not read it.
21
+
22
+ ### Changed
23
+
24
+ - **BREAKING: hosts must schedule the new `execute` timer and render the new `busy` signing slot** (`TimerName` gains `"execute"`, `SigningSlot` gains `{ kind: "busy"; retryAt; resend; of }`). An exhaustive switch over either union stops compiling until it handles the new member; a host that silently ignores the `execute` timer resends up to `STATUS_POLL_MS` (4 s) late, from the first poll tick or returning tab at or after the resend time, and stops resending ("not sent yet") when that lands inside the deadline margin (README "State machine"). Migration: schedule `execute` like the other timers (one-shot, `ms` from the effect) and render `busy` as a countdown to `retryAt` (phase `submitting`).
25
+ - **BREAKING: after an execute, a `failed` offers Retry only with `substatus: "not_executed"`.** Every other `failed` after an execute or a revealed deposit address, from the poll, a stream frame or a final execute reply, bare or granular, ends at once in the terminal "may have moved" view (`IntentState.mayHaveMoved`, phase `may-have-moved`): no Retry, the poll stopped, no quote and no cleared amount out until Reset. A resumed `failed` ends there too unless `ResumeRequested.substatus` is `not_executed`. Before, a `failed` poll after a thrown execute offered Retry. Until the Worker serves the marker (xochi#686), every `failed` after an execute ends in that view. Migration: render `may-have-moved` with Reset only and `not-sent` without Retry (suggested copy for both: README "Fund safety"), handle both in an exhaustive `SwapPhase` switch, and pass the `substatus` `getIntent` returns at resume.
26
+ - **`execute` is pinned to the `baseUrl` origin**: sent with `redirect: "manual"`, it follows no redirect and rejects any redirect shape, foreign answer, or answer naming no `url` (a response an injected `fetch` built, which may stand for a followed redirect) with `RedirectRefused`, unread (no result, no token); a token read refuses an answer naming no `url` too, and a token-less intent read refuses a `redirected` response that names no `url` (README "Transport"). An injected `fetch` must return the response it fetched; the first answer naming no `url` (normally the first quote) is logged once at `error`, and the refusal's message names the cause.
27
+ - **BREAKING: `getIntent` returns `IntentRecord | WithheldIntentRecord | null`** (was `IntentRecord | null`): a withheld read is the status-only `WithheldIntentRecord` (`legsWithheld: true`) instead of an `IntentRecord` with defaulted tokens, amounts and fees, its `status` decoded as a status poll decodes it (`fill_failed` reads `failed`; an unmodelled status reads `executing` with `unmapped: true`). Migration: narrow with `if (record && !record.legsWithheld)` before reading traded values.
28
+ - **BREAKING: `IntentRecord.fromAmount`, `toAmount`, `createdAt` and `updatedAt` are optional** (were `string`) and absent when the server does not serve them, never defaulted to `"0"` / `""`: a missing amount is not zero (SPEC.md §5.1). Code that assigns one to a `string` or calls a string method on it stops compiling. Migration: handle `undefined` (show the value as unknown, never as `0` or an empty date), e.g. `record.fromAmount ?? null`.
29
+ - **BREAKING: `ExecuteReplied` carries the execute response without its read token** (`result: Outcome<{ response: ExecuteReply }>`, where `ExecuteReply` is `ExecuteResponse` with `readToken?: never`), so no machine message, model, effect or replay log holds the bearer secret. The machine still derives the failed-vs-in-doubt verdict (#226) itself and never throws on a reply it cannot read; which replies are in doubt: README "Fund safety". Migration: `const { readToken, ...response } = await xochi.execute(request)`, keep `readToken` outside the machine, dispatch `{ type: "ExecuteReplied", flow, result: { ok: true, response } }`. A `failed` reply is final only with `reconciling: false` (Retry only with `not_executed`, else "may have moved"); without the flag it is in doubt and polled (README "Fund safety").
30
+ - **`QuoteReferralSchema` is a union** matching xochi#618: the named `{ code, name }` line (served only to a caller signed in as the quoting wallet) or the neutral `{ neutral: true, note: REFERRAL_NEUTRAL_NOTE }` line (a `ref` was applied, the caller is not the quoting wallet). New export `REFERRAL_NEUTRAL_NOTE`. Consumers that read `referral.code` must narrow on `"code" in referral` first.
31
+ - **Depends on the workspace `@xochi/signer`** (was the unpublished `@riddler/signer`).
32
+ - **Version 0.1.1**, past every pre-fix commit (which declare the never-published 0.1.0).
33
+ - Takes `Promise.withResolvers` from `ES2024.Promise`; built with TypeScript 6. `package.json` gains description, keywords and homepage.
34
+ - `package.json` declares `repository` with its `directory`, as `npm publish --provenance` requires; releases publish from `.github/workflows/release.yml` with provenance on a `client-v*` tag.
35
+ - The tarball ships this `CHANGELOG.md`.
36
+
37
+ ### Removed
38
+
39
+ - `QUOTE_CACHE_TTL_MS` / `TRADING.quoteCacheTtlMs` (30 s, the value the transport's 15 s default avoids).
40
+
41
+ ### Fixed
42
+
43
+ None of these was published with the old behavior.
44
+
45
+ - Injected transport logging receives a projection of an `ApiError`, never the error object itself.
46
+ - **Machine**:
47
+ - A new amount, trade/wallet context change, Reset or `ResumeRequested` drops in-flight quotes (a late reply overwrote the corrected quote; a Tron allocation waiting on sign-in re-quoted over the resumed deposit intent). Entering signing, awaiting-deposit or in-flight drops every quote in flight, and a reply arriving after quoting is logged and dropped.
48
+ - `RefreshRequested` is ignored in those states, and `AllocateRequested` (resolving its ref `ok: false`) outside idle/pending/quoting/quoted; a refresh over the open wallet prompt could fail an executing swap that then never reached `completed`, and an allocation after a completed deposit could reopen it as `quoted` and reveal the settled address again. A failed allocation never reopens a trade as `quoted`. Leaving `executing` ends the sign ceremony, and a status update that moves `signing -> executing` abandons it (a pull approval and signature prompt fired for a swap already in flight).
49
+ - A served `preview` that disagrees with the request is refused, any quote carrying `eip712` is signer-checked, and Confirm is gated on `canSignQuote`. A non-null `escrow` envelope (`QuoteResponse.escrow`) reaches the signer refusals.
50
+ - A held quote cannot be signed, or its deposit address revealed, once the form's amount or trade (chains, tokens, settlement, trust score, slippage, recipient, refund, stealth keys, wallet) no longer matches its quote (`holdsCurrentQuote`; the signing slot reads `unavailable`). The trade key uses the quote request's normalisation, so an edit that leaves the request unchanged (`""` recipient, stealth keys under public settlement) keeps Confirm enabled. The origin-pull solver pin and approval amount use `quotedFromAmount`, not the live amount.
51
+ - A visible stealth hold clears the held public quote; `view().settlement` shows the held quote's settlement (`IntentState.quotedSettlement`).
52
+ - Confirm on a deposit route with under `DEPOSIT_MIN_WINDOW_SECONDS` of attested window left expires instead of revealing the address.
53
+ - `StreamStatus` frames for another intent are ignored; "Sign 2 of 2" after expiry lands in `expired` instead of stranding in `signing`; clearing the amount ends a half-finished two-step ceremony.
54
+ - **Transport**: quote and execute bodies are validated against `QuoteResponseSchema` / `ExecuteResponseSchema` (undeclared top-level keys tolerated), so a malformed field is `Invalid response format`, not a raw `TypeError` or silent cast. The per-attempt timeout covers the body, so a stalled body no longer hangs `getIntentStatus` / `execute`; a timed-out or aborted request cancels its body even when an injected `fetch` ignores the signal, and unread non-ok bodies are cancelled. A reason phrase the `Response` constructor rejects (non-ASCII, control bytes) no longer throws out of the re-wrap.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 Xochi Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,120 @@
1
- # Temporary Holding Version
1
+ # @xochi/client
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Cross-chain swap client for the Xochi Worker API (`https://api.xochi.fi/api/*`): a transport, a pure trade state machine (`update(model, msg) -> [model, effects]`, `view(model)`), the fund-safety checks it runs on every quote, and the layered fee reference.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @xochi/client
9
+ ```
10
+
11
+ Node 22+ or a current browser. ESM + CommonJS with types.
12
+
13
+ ## Transport
14
+
15
+ ```ts
16
+ import { createXochiClient } from "@xochi/client";
17
+
18
+ const xochi = createXochiClient({
19
+ baseUrl: "https://api.xochi.fi",
20
+ getAuthToken: () => session.token, // optional
21
+ onAuthExpired: (e) => session.reauthenticate(e),
22
+ });
23
+
24
+ const quote = await xochi.getQuote(request);
25
+ ```
26
+
27
+ - `execute` makes one attempt. Never re-post a failed one: poll `getIntentStatus` until the intent ends, since even a refused execute leaves its signed request executable until it expires. The one exception is `ApiError.notAccepted`, below. Whether a failed answer can still have moved funds is `ApiError.inDoubt`, below; the machine polls every failed execute either way.
28
+ - `getIntentStatus` returns the status, `{ notFound: true }` on 404, or `null` on a transient failure.
29
+ - An `execute` response may carry `readToken`, served once (xochi#563) and kept only for the intent the execute named (one served under another `intent_id` is dropped). Keep it with the intent and pass `{ readToken }` to `getIntent` / `getIntentStatus`, which send it as `X-Xochi-Read-Token`. The server side is xochi#565 (merged as `0317e84`), served once it is deployed; a server without it serves no token and gives every read the full record, stealth included (SPEC.md §5.1). Under xochi#565 a stealth intent read with the token gets the full record, and one without it (or an owner SIWE session) omits its on-chain legs (tx hashes, chain ids, `sending` / `receiving`). A token read of a shielded intent gets only the status-only view (`legsWithheld`, below), as an anonymous read does (SPEC.md §5.1): every other field (Aztec identifiers, tx hashes, tokens, amounts, fees, timestamps) goes only to a SIWE session for the intent's wallet. A failed execute may carry it too (a 502 when funds may have moved, or a 2xx whose other fields fail validation): take it once with `takeApiErrorReadToken(error)`, before the error reaches any reporter. It is never a property of the error. It is a bearer secret: never log it or put it in a URL.
30
+ - To keep the full view across a reload, persist the token next to the intent id as you would a session credential (same-origin storage, never a URL, a log or a message the machine replays; `ResumeRequested` carries none), and delete it once the intent is no longer shown. Not persisting it is safe: once xochi#565 is deployed, a stealth intent reads status-only after a reload.
31
+ - The token is read-only: the transport sends it only on these two reads. It never authorizes a hold, an acknowledgement, a claim or any other write, and holding it is not an authenticated recipient channel: it is a bearer secret the host holds, not proof of the recipient.
32
+ - The token can be lost for good: an execute that times out or aborts, a reset connection, or a 5xx whose body is not JSON (or is cut by the 8 KiB read below before its `read_token` closes) leaves no token to take, and the server never re-issues one. Wallet SIWE for the intent's wallet is then the only way back to the full view.
33
+ - Every intent read (`getIntent`, `getIntentStatus`, `getIntentHistory`) and `execute` are pinned to the `baseUrl` origin: no other host's answer becomes an intent's status, record or execute result. A refusal is a `RedirectRefused`: `getIntent` / `getIntentHistory` / `execute` reject with it, and `getIntentStatus` reports it once per intent through `log` and returns `null` (a transient failure: the poll runs on).
34
+ - A read carrying the token is sent with `redirect: "manual"` (not `"error"`, which Cloudflare Workers rejects), since fetch forwards a custom header across origins and following would hand the token to whatever host the `Location` names. On Node (undici) and Workers, one plain redirect (301/302/303/307/308) whose `Location` is on the origin is followed by hand, once, with the token (a path migration). Every other redirect is refused: a further 3xx, a `redirected` response, a `response.url` off the origin or naming no url at all, or an `opaqueredirect`. A browser answers every manual redirect with an `opaqueredirect` (no status, no `Location`), so in a browser a redirected token read is always refused.
35
+ - A read without the token follows redirects natively and is accepted only if its final `response.url` is on the origin (a `redirected` response that names no `url` is refused). `getIntentStatus` reads again this way when its token read is refused, so behind a same-origin redirect a browser still gets the status-only view.
36
+ - `execute` is sent with `redirect: "manual"` and follows nothing: a 3xx, an `opaqueredirect`, a `redirected` response, or a `response.url` off the origin or naming no url at all is refused unread (no result and no token taken from it), so a redirect never re-posts the signed request. That refusal is in doubt, like any failed `execute` that is not an `ApiError`: the machine shows "Still confirming" and polls. So a redirect in front of `execute` (http to https, a www/apex rule, an access proxy) means the execute never reaches the Worker: the swap reads "Still confirming" until the Worker, read past the intent's signed deadline plus `QUOTED_EXPIRY_GRACE_SECS` (60 s), finishes the never-claimed intent `failed` with `not_executed`, which offers Retry; the machine polls at least that long ([Fund safety](#fund-safety)). Point `baseUrl` at the origin that answers `/api/intent/execute` itself.
37
+ - An injected `fetch` MUST honour `redirect: "manual"` and return the response it fetched. One that follows redirects has sent the token to the `Location` host before any check runs. A response that names no `url` (one the injected fetch built, such as `new Response(body, init)`, which may stand for a redirect it followed) is refused on `execute` and on a token read, so behind such a wrapper every execute is refused and its read token lost; a read without the token cannot tell and accepts it. The transport says so once, at `error` through `log`, on the first answer naming no url (normally the first quote, before any execute), and the refusal's message names it.
38
+ - `getIntent` rejects a `readToken` not of the issued shape before any request. `getIntentStatus` instead reads anonymously and reports it once per intent through `log`, so a caller bug never stalls the poll.
39
+ - An error body (any non-ok response) is read up to its first 8 KiB and no further: reading stops once the cap is full (a body that fills it counts as cut), and the rest is cancelled unread. Everything below works on that prefix. A cut JSON body does not parse, so `takeApiErrorReadToken` reads its token from the prefix by strict match: only from a prefix that opens a JSON object with exactly one `"intent_id"`, equal to the executed intent, and exactly one `"read_token"` whose value is a whole token, both keys of that object itself (not of a value nested in it). A body that does not arrive within the attempt's timeout fails the attempt as a timeout, its status and `Retry-After` lost with it.
40
+ - `ApiError.message` carries a capped, redacted excerpt of the error body, never the raw body; its log line (`Request failed after retries`) carries the same excerpt. In order:
41
+ 1. `\u{…}`, `\uXXXX`, `\xXX`, JSON's `\n`, `\r`, `\t`, `\b`, `\f` and `\/`, `%XX` (as UTF-8) and numeric `&#…;` escapes (any number of leading zeros, `;` optional) are decoded, every non-ASCII character but a space is folded to its compatibility form (NFKD: fullwidth `A1` reads `A1`), then invisible characters (format and default-ignorable characters, controls other than tab and line breaks) and complete markup tags (`<p>`, `</b>`, `<a …>`, `<!…>`, `<?…>`; a bare `<` is text) are removed; this repeats for nesting up to three deep, and a body still changing after that is shown as `[redacted]` alone. Backslashes as text stay as written: backslash escapes are read left to right with JSON's `\\` as a pair, so no escape starts at the backslash a pair ends (`C:\\keys\\nAbc`, `C:\\u0041` and `C:\\x41` keep their `n`, `u0041` and `x41`), and nothing decodes to a backslash (`\u005c`, `\x5c`, `%5C`, `&#92;` and a fullwidth `\` stay as written), so no decoded backslash escapes the character after it.
42
+ 2. The result is cut to its first 2048 characters. After a cut, here or by the 8 KiB read, an unclosed tag at the end and the base64url run or unfinished escape the cut ends in are dropped, and `…` appended.
43
+ 3. Possible tokens are collected from the whole prefix: the body's own `read_token`, the value after any read-token key (`read_token`, `readToken`, `X-Xochi-Read-Token`, any case, quoted or escaped), and, both decoded and as written (decoding a raw `\n` eats the first character of a token written after a backslash, whole or split), every base64url run of at least 43 characters that is not hex and every token split into pieces: combining marks and non-ASCII spaces or line separators between base64url characters are ignored, and pieces of 8 or more characters that are not hex and hold a digit, `-`, `_` or mixed case are joined across one space, tab, `.`, `,`, `;`, `|`, `/` or line break (LF, CRLF or a lone CR; after a line break that follows a piece of 16 or more that is not a name, the last piece may be any run) when they make at least 43 characters. A piece that reads as a CamelCase or `snake_case` name (`PermitWitnessTransferFrom`, `pull_failed`) joins only when the unbroken sequence of linked pieces it sits in holds one that does not: a list of names stays readable, and random pieces that happen to read as names, one or several in a row, do not break the chain they sit in.
44
+ 4. Every 16-character fragment of a possible token, in base64url, standard base64, or hex of its bytes or of its characters (any case), is replaced wherever it appears, matched with every character outside that alphabet ignored (`0x` prefixes too), so separators inside an echo hide nothing; the span it covers, separators included, is replaced.
45
+ 5. The value after any read-token key, and every base64url run of at least 43 characters that is not hex, are replaced.
46
+
47
+ Tx hashes and `0x` addresses stay readable; any other long base64url value (a base58 address, an opaque id, two ids one space apart) is redacted too, with its fragments.
48
+
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
+
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).
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
+ - `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
+ - `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)).
55
+ - Quote and execute bodies are validated against `QuoteResponseSchema` / `ExecuteResponseSchema` (undeclared top-level keys tolerated). The one exception is a malformed `read_token`, dropped (`readToken` undefined) rather than failing the execute (why: `ExecuteResponseSchema`). Each attempt's timeout stays armed until the body is read and cancels the body when it fires. The only quote-cache TTL is `DEFAULT_TIMEOUTS.quoteCacheTtlMs` (15 s).
56
+ - The exported `QuoteResponseSchema` / `ExecuteResponseSchema` are `.strict()` (the declared wire shape); the transport validates with a `.passthrough()` copy so undeclared served fields (e.g. `escrow`) reach the signer checks. A consumer running the exported schema directly is stricter than the transport.
57
+
58
+ ## State machine
59
+
60
+ ```ts
61
+ import { init, update, view, type MsgBody } from "@xochi/client";
62
+
63
+ let model = init(
64
+ {
65
+ solverPin: { erc3009Allowlist: [...], permit2Allowlist: [...], requirePin: true },
66
+ pullRecipients: { 8453: { erc3009: "0x...", permit2: "0x..." } }, // your pin
67
+ depositAttestationSigner: "0x...", // your pin; null refuses every deposit route
68
+ },
69
+ context,
70
+ );
71
+
72
+ function dispatch(body: MsgBody) {
73
+ const [next, effects] = update(model, { ...body, now: Date.now(), hidden: false });
74
+ model = next;
75
+ for (const effect of effects) perform(effect); // Api / Wallet / Timer / Emit
76
+ render(view(next));
77
+ }
78
+ ```
79
+
80
+ Lifecycle rules the machine enforces, beyond the signer checks under [Fund safety](#fund-safety):
81
+
82
+ - A quote whose `preview` disagrees with the request is refused; any quote carrying `eip712` is checked whether or not it claims to be a preview.
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
+ - 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
+ - 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
+ - `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
+ - 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
+ ## Fund safety
90
+
91
+ The machine checks every quote against the request (via `@xochi/signer`), the pull against your pins, the payout against what was rendered, and deposit attestations against your signer; claims are checked before signing. All fail closed: a failing quote is never signable, a failing deposit address never shown.
92
+
93
+ - **Pins are yours.** Never derive `pullRecipients` or `depositAttestationSigner` from `/api/capabilities` (same channel as the quote).
94
+ - **Quotes with an `escrow` envelope are refused.**
95
+ - **A deposit address is never revealed with under 60 s of its attested window left.**
96
+ - **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
+ - **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
+
101
+ ## Fee reference
102
+
103
+ ```ts
104
+ import { assertFeeBreakdownInvariants, feeBreakdownReconcilable } from "@xochi/client";
105
+
106
+ if (quote.feeBreakdown) {
107
+ assertFeeBreakdownInvariants(
108
+ feeBreakdownReconcilable(quote.feeBreakdown),
109
+ quote.feeDenomination === "origin" ? { input_amount: request.fromAmount } : {},
110
+ );
111
+ }
112
+ ```
113
+
114
+ `computeFeeBreakdown` predicts the layers; `FEE_SCHEDULE` mirrors the solver's policy. The invariant check reconciles layers, not the rate; capping it is your policy.
115
+
116
+ Changes: [CHANGELOG.md](https://github.com/xochi-fi/xochi-sdk/blob/main/packages/client/CHANGELOG.md).
117
+
118
+ ## License
119
+
120
+ MIT
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Pre-sign check for a stealth claim's UserOperation.
3
+ *
4
+ * `POST /api/stealth/claim/prepare` returns a sponsored ERC-4337 operation and
5
+ * the `userOpHash` the stealth key is asked to sign. The hash alone is valid over
6
+ * any preimage that hashes to it, so signing it unverified authorises whatever
7
+ * the server chose to build -- including a sweep to somebody else's address.
8
+ *
9
+ * `@xochi/signer`'s `assertPreparedClaimMatchesRequest` recomputes the hash
10
+ * from the served op under the pinned v0.8 EntryPoint, then checks `sender`,
11
+ * the settlement chain, `callData` (`execute(token, 0, transfer(recipient,
12
+ * amount))`), a zero native value and a paymaster. It runs here with
13
+ * `requireVerified` forced on: an absent op, EntryPoint or settlement chain is a
14
+ * refusal, never a warning, because the signature is the entire authorisation
15
+ * and nothing after it can catch a wrong one.
16
+ */
17
+ import { type ClaimExpectation, type PreparedClaim } from "@xochi/signer";
18
+ /** What the client independently knows the claim should do. */
19
+ export type PreparedClaimExpectation = Omit<ClaimExpectation, "requireVerified">;
20
+ /**
21
+ * `claim_op_unverified` is the category the widget already reports for a
22
+ * refused claim op, so a host branching on it keeps working.
23
+ */
24
+ export type PreparedClaimCheck = {
25
+ ok: true;
26
+ token: string;
27
+ amount: string;
28
+ } | {
29
+ ok: false;
30
+ errorCategory: "claim_op_unverified";
31
+ reason: string;
32
+ };
33
+ export declare function checkPreparedClaim(prepared: PreparedClaim, expected: PreparedClaimExpectation): PreparedClaimCheck;
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ /**
3
+ * Pre-sign check for a stealth claim's UserOperation.
4
+ *
5
+ * `POST /api/stealth/claim/prepare` returns a sponsored ERC-4337 operation and
6
+ * the `userOpHash` the stealth key is asked to sign. The hash alone is valid over
7
+ * any preimage that hashes to it, so signing it unverified authorises whatever
8
+ * the server chose to build -- including a sweep to somebody else's address.
9
+ *
10
+ * `@xochi/signer`'s `assertPreparedClaimMatchesRequest` recomputes the hash
11
+ * from the served op under the pinned v0.8 EntryPoint, then checks `sender`,
12
+ * the settlement chain, `callData` (`execute(token, 0, transfer(recipient,
13
+ * amount))`), a zero native value and a paymaster. It runs here with
14
+ * `requireVerified` forced on: an absent op, EntryPoint or settlement chain is a
15
+ * refusal, never a warning, because the signature is the entire authorisation
16
+ * and nothing after it can catch a wrong one.
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.checkPreparedClaim = checkPreparedClaim;
20
+ const signer_1 = require("@xochi/signer");
21
+ function checkPreparedClaim(prepared, expected) {
22
+ try {
23
+ const verification = (0, signer_1.assertPreparedClaimMatchesRequest)(prepared, {
24
+ ...expected,
25
+ requireVerified: true,
26
+ });
27
+ // With requireVerified every gap throws, so an unverified result here is a
28
+ // contract break in the signer: refuse it rather than sign on it.
29
+ if (!verification.verified || !verification.token || !verification.amount) {
30
+ return {
31
+ ok: false,
32
+ errorCategory: "claim_op_unverified",
33
+ reason: verification.reason ?? "the claim operation could not be verified",
34
+ };
35
+ }
36
+ return { ok: true, token: verification.token, amount: verification.amount };
37
+ }
38
+ catch (err) {
39
+ const reason = err instanceof signer_1.XochiClaimMismatchError
40
+ ? err.message
41
+ : `claim verification failed unexpectedly: ${err instanceof Error ? err.message : String(err)}`;
42
+ return { ok: false, errorCategory: "claim_op_unverified", reason };
43
+ }
44
+ }
@@ -0,0 +1,247 @@
1
+ import { z } from "zod";
2
+ import type { FeeBreakdown } from "./shared/types.js";
3
+ export type FeeTier = "standard" | "trusted" | "verified" | "premium" | "institutional";
4
+ export type AssetClass = "stable" | "volatile";
5
+ export type AttestationType = "compliance" | "non_membership";
6
+ export declare const FEE_SCHEDULE: {
7
+ readonly surplus_share_pct: 15;
8
+ readonly tier_boundaries: readonly [{
9
+ readonly min_trust_score: 100;
10
+ readonly tier: "institutional";
11
+ }, {
12
+ readonly min_trust_score: 75;
13
+ readonly tier: "premium";
14
+ }, {
15
+ readonly min_trust_score: 50;
16
+ readonly tier: "verified";
17
+ }, {
18
+ readonly min_trust_score: 25;
19
+ readonly tier: "trusted";
20
+ }, {
21
+ readonly min_trust_score: 0;
22
+ readonly tier: "standard";
23
+ }];
24
+ readonly solver_base_bps: {
25
+ readonly stable: 8;
26
+ readonly volatile: 18;
27
+ };
28
+ readonly venue_bps: {
29
+ readonly standard: {
30
+ readonly stable: 6;
31
+ readonly volatile: 10;
32
+ };
33
+ readonly trusted: {
34
+ readonly stable: 5;
35
+ readonly volatile: 8;
36
+ };
37
+ readonly verified: {
38
+ readonly stable: 3;
39
+ readonly volatile: 5;
40
+ };
41
+ readonly premium: {
42
+ readonly stable: 2;
43
+ readonly volatile: 3;
44
+ };
45
+ readonly institutional: {
46
+ readonly stable: 1;
47
+ readonly volatile: 2;
48
+ };
49
+ };
50
+ readonly routing_bps: {
51
+ readonly standard: {
52
+ readonly stable: 8;
53
+ readonly volatile: 12;
54
+ };
55
+ readonly trusted: {
56
+ readonly stable: 6;
57
+ readonly volatile: 9;
58
+ };
59
+ readonly verified: {
60
+ readonly stable: 4;
61
+ readonly volatile: 6;
62
+ };
63
+ readonly premium: {
64
+ readonly stable: 2;
65
+ readonly volatile: 4;
66
+ };
67
+ readonly institutional: {
68
+ readonly stable: 1;
69
+ readonly volatile: 2;
70
+ };
71
+ };
72
+ readonly price_impact: {
73
+ readonly k: 50;
74
+ readonly cap_bps: 150;
75
+ readonly max_fill_fraction: 0.25;
76
+ };
77
+ readonly tier_attestation_requirements: {
78
+ readonly standard: AttestationType[];
79
+ readonly trusted: AttestationType[];
80
+ readonly verified: AttestationType[];
81
+ readonly premium: AttestationType[];
82
+ readonly institutional: AttestationType[];
83
+ };
84
+ };
85
+ export declare function scoreToTier(score: number): FeeTier;
86
+ export declare function gateTier(rawTier: FeeTier, provided: AttestationType[]): FeeTier;
87
+ export declare function priceImpactBps(fillUsd: number, liquidityUsd: number | null | undefined): number;
88
+ export interface FeeComputeParams {
89
+ input_amount: string | bigint;
90
+ asset_class?: AssetClass;
91
+ trust_score?: number;
92
+ tier?: FeeTier;
93
+ attestations?: AttestationType[];
94
+ acp_origin?: boolean;
95
+ fill_usd?: number;
96
+ available_liquidity_usd?: number | null;
97
+ }
98
+ export interface FeeBreakdownCore {
99
+ tier: FeeTier;
100
+ asset_class: AssetClass;
101
+ solver_fee_bps: number;
102
+ solver_fee: string;
103
+ price_impact_bps: number;
104
+ price_impact: string;
105
+ venue_fee_bps: number;
106
+ venue_fee: string;
107
+ routing_fee_bps: number;
108
+ routing_fee: string;
109
+ total_bps: number;
110
+ total_fee: string;
111
+ surplus_share_pct: number;
112
+ }
113
+ export declare function computeFeeBreakdown(params: FeeComputeParams): FeeBreakdownCore;
114
+ export declare const LayeredFeeBreakdownSchema: z.ZodObject<{
115
+ tier: z.ZodString;
116
+ asset_class: z.ZodEnum<["stable", "volatile"]>;
117
+ gas_floor: z.ZodOptional<z.ZodObject<{
118
+ amount: z.ZodString;
119
+ usd: z.ZodString;
120
+ }, "strip", z.ZodTypeAny, {
121
+ amount: string;
122
+ usd: string;
123
+ }, {
124
+ amount: string;
125
+ usd: string;
126
+ }>>;
127
+ solver_fee_bps: z.ZodNumber;
128
+ solver_fee: z.ZodString;
129
+ price_impact_bps: z.ZodNumber;
130
+ price_impact: z.ZodString;
131
+ venue_fee_bps: z.ZodNumber;
132
+ venue_fee: z.ZodString;
133
+ routing_fee_bps: z.ZodNumber;
134
+ routing_fee: z.ZodString;
135
+ total_bps: z.ZodNumber;
136
+ total_fee: z.ZodString;
137
+ total_usd: z.ZodOptional<z.ZodString>;
138
+ surplus_share_pct: z.ZodNumber;
139
+ discount: z.ZodOptional<z.ZodObject<{
140
+ source: z.ZodEnum<["tier", "promo"]>;
141
+ bps: z.ZodNumber;
142
+ amount: z.ZodString;
143
+ }, "strip", z.ZodTypeAny, {
144
+ amount: string;
145
+ source: "tier" | "promo";
146
+ bps: number;
147
+ }, {
148
+ amount: string;
149
+ source: "tier" | "promo";
150
+ bps: number;
151
+ }>>;
152
+ }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
153
+ tier: z.ZodString;
154
+ asset_class: z.ZodEnum<["stable", "volatile"]>;
155
+ gas_floor: z.ZodOptional<z.ZodObject<{
156
+ amount: z.ZodString;
157
+ usd: z.ZodString;
158
+ }, "strip", z.ZodTypeAny, {
159
+ amount: string;
160
+ usd: string;
161
+ }, {
162
+ amount: string;
163
+ usd: string;
164
+ }>>;
165
+ solver_fee_bps: z.ZodNumber;
166
+ solver_fee: z.ZodString;
167
+ price_impact_bps: z.ZodNumber;
168
+ price_impact: z.ZodString;
169
+ venue_fee_bps: z.ZodNumber;
170
+ venue_fee: z.ZodString;
171
+ routing_fee_bps: z.ZodNumber;
172
+ routing_fee: z.ZodString;
173
+ total_bps: z.ZodNumber;
174
+ total_fee: z.ZodString;
175
+ total_usd: z.ZodOptional<z.ZodString>;
176
+ surplus_share_pct: z.ZodNumber;
177
+ discount: z.ZodOptional<z.ZodObject<{
178
+ source: z.ZodEnum<["tier", "promo"]>;
179
+ bps: z.ZodNumber;
180
+ amount: z.ZodString;
181
+ }, "strip", z.ZodTypeAny, {
182
+ amount: string;
183
+ source: "tier" | "promo";
184
+ bps: number;
185
+ }, {
186
+ amount: string;
187
+ source: "tier" | "promo";
188
+ bps: number;
189
+ }>>;
190
+ }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
191
+ tier: z.ZodString;
192
+ asset_class: z.ZodEnum<["stable", "volatile"]>;
193
+ gas_floor: z.ZodOptional<z.ZodObject<{
194
+ amount: z.ZodString;
195
+ usd: z.ZodString;
196
+ }, "strip", z.ZodTypeAny, {
197
+ amount: string;
198
+ usd: string;
199
+ }, {
200
+ amount: string;
201
+ usd: string;
202
+ }>>;
203
+ solver_fee_bps: z.ZodNumber;
204
+ solver_fee: z.ZodString;
205
+ price_impact_bps: z.ZodNumber;
206
+ price_impact: z.ZodString;
207
+ venue_fee_bps: z.ZodNumber;
208
+ venue_fee: z.ZodString;
209
+ routing_fee_bps: z.ZodNumber;
210
+ routing_fee: z.ZodString;
211
+ total_bps: z.ZodNumber;
212
+ total_fee: z.ZodString;
213
+ total_usd: z.ZodOptional<z.ZodString>;
214
+ surplus_share_pct: z.ZodNumber;
215
+ discount: z.ZodOptional<z.ZodObject<{
216
+ source: z.ZodEnum<["tier", "promo"]>;
217
+ bps: z.ZodNumber;
218
+ amount: z.ZodString;
219
+ }, "strip", z.ZodTypeAny, {
220
+ amount: string;
221
+ source: "tier" | "promo";
222
+ bps: number;
223
+ }, {
224
+ amount: string;
225
+ source: "tier" | "promo";
226
+ bps: number;
227
+ }>>;
228
+ }, z.ZodTypeAny, "passthrough">>;
229
+ export type LayeredFeeBreakdownWire = z.infer<typeof LayeredFeeBreakdownSchema>;
230
+ export declare function parseFeeBreakdown(raw: unknown): LayeredFeeBreakdownWire;
231
+ export interface FeeInvariantOptions {
232
+ input_amount?: string | bigint;
233
+ }
234
+ export interface FeeReconcilable {
235
+ solver_fee_bps: number;
236
+ price_impact_bps: number;
237
+ venue_fee_bps: number;
238
+ routing_fee_bps: number;
239
+ total_bps: number;
240
+ solver_fee: string;
241
+ price_impact: string;
242
+ venue_fee: string;
243
+ routing_fee: string;
244
+ total_fee: string;
245
+ }
246
+ export declare function feeBreakdownReconcilable(fb: FeeBreakdown): FeeReconcilable;
247
+ export declare function assertFeeBreakdownInvariants(fb: FeeReconcilable, opts?: FeeInvariantOptions): void;