@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
@@ -0,0 +1,1456 @@
1
+ "use strict";
2
+ /**
3
+ * Transport for the Xochi Worker API (`<baseUrl>/api/*`).
4
+ *
5
+ * - Quote caching (TTL) and request deduplication
6
+ * - Retry with exponential backoff, closed to transient gateway statuses
7
+ * - A per-attempt timeout
8
+ * - Status polling for in-flight intents (no WebSocket)
9
+ *
10
+ * Everything host-specific is injected: the `fetch` implementation, the bearer
11
+ * token, what to do when the server says a credential is no longer good, and
12
+ * where log lines go. Nothing here reads a global config, storage or the DOM.
13
+ *
14
+ * The Worker speaks the snake_case wire contract; responses are decoded to the
15
+ * camelCase app types by the same decoders the state machine reads.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.RedirectRefused = exports.MAX_RETRY_WAIT_SEC = exports.ApiError = exports.DEFAULT_RETRY = exports.DEFAULT_TIMEOUTS = void 0;
19
+ exports.takeApiErrorReadToken = takeApiErrorReadToken;
20
+ exports.parseRetryAfterMs = parseRetryAfterMs;
21
+ exports.resolveRetryAfterSeconds = resolveRetryAfterSeconds;
22
+ exports.createXochiClient = createXochiClient;
23
+ const index_js_1 = require("../shared/index.js");
24
+ const wire_js_1 = require("../machine/wire.js");
25
+ exports.DEFAULT_TIMEOUTS = {
26
+ quoteCacheTtlMs: 15_000,
27
+ requestTimeoutMs: 15_000,
28
+ executeTimeoutMs: 120_000,
29
+ };
30
+ exports.DEFAULT_RETRY = {
31
+ maxAttempts: 3,
32
+ baseDelayMs: 1_000,
33
+ maxDelayMs: 10_000,
34
+ };
35
+ // ============================================================
36
+ // Errors and Retry-After
37
+ // ============================================================
38
+ /**
39
+ * A structured API failure. Carries the numeric HTTP status and, for
40
+ * backpressure responses (429/503), an ABSOLUTE unix-seconds retry target so the
41
+ * UI can render a countdown and disable Retry until it elapses. The message is
42
+ * `HTTP {status}: {body excerpt}` so a code-less consumer can still fall back to
43
+ * matching on it; the excerpt is capped and read-token redacted (README
44
+ * "Transport" states the rule).
45
+ *
46
+ * A failed execute may still carry the intent read capability (xochi#563: the
47
+ * server serves it on a 502 when funds may have moved). It is never a property
48
+ * of the error: take it with `takeApiErrorReadToken`.
49
+ */
50
+ class ApiError extends Error {
51
+ code;
52
+ retryAfter;
53
+ /**
54
+ * True only on the Worker's not-accepted answer to `execute`
55
+ * (`isNotAcceptedExecuteBody`), whose `retryAfter` is the server's wait;
56
+ * false on every other failure, which is never resent. Never true together
57
+ * with `inDoubt`: an answer that also says funds may have moved is not a
58
+ * not-accepted answer. Hand it to the machine as `ErrorReport.notAccepted`
59
+ * (rules: README "Transport").
60
+ */
61
+ notAccepted;
62
+ /**
63
+ * Whether this failed `execute` answer cannot vouch that nothing moved for
64
+ * the executed intent (`isInDoubtExecuteAnswer`): false only on the three
65
+ * Worker answers that speak for the intent itself, true on every other
66
+ * execute failure, a refusal of this request alone included (another
67
+ * request may hold the intent). For the host's own copy and logs; the
68
+ * machine never reads it (README "Transport"). False on every other call.
69
+ */
70
+ inDoubt;
71
+ constructor(message, code, retryAfter, notAccepted = false, inDoubt = false) {
72
+ super(message);
73
+ this.name = "ApiError";
74
+ this.code = code;
75
+ this.retryAfter = retryAfter;
76
+ this.notAccepted = notAccepted;
77
+ this.inDoubt = inDoubt;
78
+ }
79
+ }
80
+ exports.ApiError = ApiError;
81
+ /**
82
+ * A failed answer to `POST /api/intent/execute` that cannot vouch that
83
+ * nothing moved for the executed intent (rules: README "Transport"). Fail
84
+ * closed: in doubt unless the answer is one of the three that speak for the
85
+ * intent, not only for this request:
86
+ * - the not-accepted 503 (`isNotAcceptedExecuteBody`): the Worker undid its
87
+ * own claim, and the intent is `quoted` again;
88
+ * - the 502 `failed` marked `not_executed` for the executed intent: the
89
+ * Worker ended the intent with nothing moved;
90
+ * - the 409 with `error_category: "promo_unavailable"`: its promo redemption
91
+ * is keyed by intent and idempotent, and every claim follows one, so a
92
+ * refused redemption means no request has claimed the intent.
93
+ * Every other answer refuses this request only, and another request for the
94
+ * same intent (another tab, a host's replay) may hold its claim: the Worker
95
+ * answers its parse 400 and its 403 `api_key_intent_mismatch` before it reads
96
+ * the intent's status, its other 400s and 401s (the signed-deadline 400
97
+ * included) from a row it read before, which another request can claim in
98
+ * between, and anything in front of it never reads the intent. So every
99
+ * other 4xx, every 429 and 5xx, JSON or not, is in doubt, and the transport
100
+ * reads no error text. Always in doubt when the answer carries a read token
101
+ * or `reconciling: true`.
102
+ */
103
+ function isInDoubtExecuteAnswer(status, body, intentId, servedToken) {
104
+ if (servedToken !== undefined || body?.reconciling === true)
105
+ return true;
106
+ if (status === 409)
107
+ return body?.error_category !== "promo_unavailable";
108
+ if (isNotAcceptedExecuteBody(status, body, intentId))
109
+ return false;
110
+ return !(status === 502 &&
111
+ body !== undefined &&
112
+ body.success === false &&
113
+ body.intent_id === intentId &&
114
+ body.status === "failed" &&
115
+ body.substatus === index_js_1.NOT_EXECUTED_SUBSTATUS);
116
+ }
117
+ /**
118
+ * The Worker's not-accepted answer to `POST /api/intent/execute` (xochi#686),
119
+ * the server's statement that it undid its claim and the intent is `quoted`
120
+ * again. Fail closed: anything else stays in doubt (rules: README "Transport").
121
+ */
122
+ function isNotAcceptedExecuteBody(status, body, intentId) {
123
+ return (status === 503 &&
124
+ body !== undefined &&
125
+ body.success === false &&
126
+ body.status === "quoted" &&
127
+ body.accepted === false &&
128
+ body.intent_id === intentId &&
129
+ typeof body.retry_after === "number" &&
130
+ Number.isFinite(body.retry_after) &&
131
+ body.retry_after > 0);
132
+ }
133
+ /**
134
+ * When a not-accepted execute may go again (ABSOLUTE unix seconds, rounded UP
135
+ * so it never lands before the server's wait): the body's `retry_after`
136
+ * (delta seconds, the wait the server chose), or a readable numeric
137
+ * `Retry-After` header only if it is later. Nothing else moves it. Capped at
138
+ * MAX_RETRY_WAIT_SEC.
139
+ */
140
+ function notAcceptedRetryAt(headers, retryAfter) {
141
+ const header = headers.get("Retry-After");
142
+ const headerDelta = header === null || header.trim() === "" ? NaN : Number(header);
143
+ const wait = Number.isFinite(headerDelta) && headerDelta > retryAfter ? headerDelta : retryAfter;
144
+ return Math.ceil((Date.now() + Math.min(wait, exports.MAX_RETRY_WAIT_SEC) * 1000) / 1000);
145
+ }
146
+ /**
147
+ * Read tokens held for the errors `execute` rejected with, keyed by the error
148
+ * so the token is never reachable from the error itself (a crash reporter or a
149
+ * debugger inspecting its own properties sees none).
150
+ */
151
+ const errorReadTokens = new WeakMap();
152
+ /**
153
+ * Take the `read_token` of the intent a failed `execute` named, once: a second
154
+ * call returns undefined. The error is an `ApiError` (a 502 when funds may have
155
+ * moved) or the `Invalid response format` error of a 2xx execute body whose
156
+ * other fields broke the contract. Keep it with the intent and poll with it,
157
+ * exactly as for a successful execute; take it before the error goes to any
158
+ * reporter. Undefined for any other error, and for a token naming another
159
+ * intent.
160
+ */
161
+ function takeApiErrorReadToken(error) {
162
+ if (!(error instanceof Error))
163
+ return undefined;
164
+ const token = errorReadTokens.get(error);
165
+ errorReadTokens.delete(error);
166
+ return token;
167
+ }
168
+ /** Preserve useful API failure context without handing the error object to a logger. */
169
+ function safeErrorForLog(error) {
170
+ if (!(error instanceof ApiError))
171
+ return error;
172
+ return {
173
+ name: error.name,
174
+ message: error.message,
175
+ code: error.code,
176
+ ...(error.retryAfter === undefined ? {} : { retryAfter: error.retryAfter }),
177
+ ...(error.notAccepted ? { notAccepted: true } : {}),
178
+ };
179
+ }
180
+ /**
181
+ * The `read_token` an execute body carries for `intentId`, or undefined. A token
182
+ * not of the issued shape, or naming another intent, is not kept: it would only
183
+ * ever be presented as a malformed header or for the wrong intent.
184
+ */
185
+ function readTokenForIntent(body, intentId) {
186
+ if (body?.intent_id !== intentId)
187
+ return undefined;
188
+ const token = body.read_token;
189
+ return typeof token === "string" && index_js_1.READ_TOKEN_PATTERN.test(token) ? token : undefined;
190
+ }
191
+ /** The most of an error body ever read (bytes); the rest is cancelled unread. */
192
+ const MAX_ERROR_BODY_BYTES = 8 * 1024;
193
+ /** The most of a normalized error body that reaches `ApiError.message`. */
194
+ const MAX_ERROR_EXCERPT_CHARS = 2048;
195
+ /** Normalization passes for nested escaping (`%2522`, `\\u0022`); each pass is linear. */
196
+ const NORMALIZE_PASSES = 3;
197
+ /**
198
+ * The window a possible read token is redacted by: every run of this many of
199
+ * its characters, in any of its forms and with separators ignored, is hidden
200
+ * wherever it appears. 16 base64url characters are 96 random bits, so a window
201
+ * never matches unrelated text by chance; what a split can leave readable of a
202
+ * token is a piece shorter than this, with other text on both sides.
203
+ */
204
+ const TOKEN_FRAGMENT_CHARS = 16;
205
+ const REDACTED = "[redacted]";
206
+ /** A read-token key (`read_token`, `readToken`, `X-Xochi-Read-Token`) and the value it opens. */
207
+ const READ_TOKEN_KEYED_VALUE = /(read[_-]?token\\*["']?\s*[:=]\s*\\*["']?)([A-Za-z0-9_-]+)/gi;
208
+ /** A base64url run at least a read token long (greedy from its first character: maximal). */
209
+ const LONG_BASE64URL_RUN = new RegExp(`[A-Za-z0-9_-]{${index_js_1.READ_TOKEN_LENGTH},}`, "g");
210
+ const BASE64URL_RUN = /[A-Za-z0-9_-]+/g;
211
+ const HEX_RUN = /^(?:0x)?[0-9a-fA-F]+$/;
212
+ const HEX_DIGIT = /[0-9a-fA-F]/;
213
+ /** What renders as nothing: format and default-ignorable characters, controls but tab and line breaks. */
214
+ const INVISIBLE = /(?![\t\n\r])[\p{Cc}\p{Cf}\p{Default_Ignorable_Code_Point}]/gu;
215
+ /**
216
+ * Separators that split no word of ASCII text, between two base64url
217
+ * characters: combining marks, and every space and line separator but U+0020.
218
+ */
219
+ const RUN_JOINER = /(?<=[A-Za-z0-9_-])[\p{M}\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+(?=[A-Za-z0-9_-])/gu;
220
+ /**
221
+ * What `normalizeOnce` folds with NFKD, one character at a time: every
222
+ * non-ASCII character but a space, so fullwidth `A1` reads `A1`. Spaces are
223
+ * left for `RUN_JOINER` (NFKD would turn a no-break space into U+0020, which
224
+ * splits a token), and NFKC is not used, since it would compose a combining
225
+ * mark that splits a token into one letter outside base64url.
226
+ */
227
+ const COMPATIBILITY_FOLDED = /[^\x00-\x7f\p{Zs}]/gu;
228
+ /**
229
+ * What may sit between two pieces of a split token: one space, tab, `.`, `,`,
230
+ * `;`, `|` or `/`, or a line break (LF, CRLF or a lone CR).
231
+ */
232
+ const PIECE_GAP = /^(?:[ \t.,;|/]|(?:\r?\n|\r)[ \t]*)$/;
233
+ /** A name: CamelCase (`PermitWitnessTransferFrom`) or snake_case (`pull_failed`); a token piece only in a chain holding one that is not (`splitTokens`). */
234
+ const IDENTIFIER = /^(?:(?:[A-Z][a-z]+)+|[a-z]+(?:_[a-z]+)+)$/;
235
+ /** Complete markup: an element tag (`<p>`, `</b>`, `<a href=…>`), `<!…>` or `<?…>`; never a bare `<`. */
236
+ const MARKUP_TAG = /<(?:\/?[A-Za-z][^<>]*|[!?][^<>]*)>/g;
237
+ const UNCLOSED_TRAILING_TAG = /<(?:\/?[A-Za-z]|[!?])[^<>]*$/;
238
+ /** What the end of a cut excerpt may hold of a token or an unfinished escape. */
239
+ const CUT_TAIL_CHAR = /[A-Za-z0-9_%\\&#{-]/;
240
+ const BASE64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
241
+ /**
242
+ * At most the first `MAX_ERROR_BODY_BYTES` of a body, and whether any of it
243
+ * went unread. Reading stops once the cap is full, without waiting for the
244
+ * body to end (a body that fills the cap counts as cut), and the rest is
245
+ * cancelled, so a huge, endless or stalled error body costs a bounded read.
246
+ * A body that fails mid-read yields what arrived before the failure, marked
247
+ * truncated.
248
+ */
249
+ async function readCappedBytes(response) {
250
+ if (!response.body)
251
+ return { bytes: new Uint8Array(0), truncated: false };
252
+ const reader = response.body.getReader();
253
+ const bytes = new Uint8Array(MAX_ERROR_BODY_BYTES);
254
+ let size = 0;
255
+ let truncated = false;
256
+ try {
257
+ while (size < MAX_ERROR_BODY_BYTES) {
258
+ const { done, value } = await reader.read();
259
+ if (done)
260
+ break;
261
+ const room = MAX_ERROR_BODY_BYTES - size;
262
+ bytes.set(value.subarray(0, room), size);
263
+ size += Math.min(value.byteLength, room);
264
+ }
265
+ if (size === MAX_ERROR_BODY_BYTES)
266
+ truncated = true;
267
+ }
268
+ catch {
269
+ // A failed body read is a cut: what arrived is kept, and treated as cut.
270
+ truncated = true;
271
+ }
272
+ reader.cancel().catch(() => { });
273
+ return { bytes: bytes.subarray(0, size), truncated };
274
+ }
275
+ /** `readCappedBytes` as UTF-8 text (a character split by the cap becomes U+FFFD). */
276
+ async function readCappedText(response) {
277
+ const { bytes, truncated } = await readCappedBytes(response);
278
+ return { text: new TextDecoder().decode(bytes), truncated };
279
+ }
280
+ /**
281
+ * A run of `%XX` escapes decoded as UTF-8 (an invalid sequence becomes
282
+ * U+FFFD). `%5C` is never part of a run (`PERCENT_RUN`): it stays as written.
283
+ */
284
+ function decodePercentRun(run) {
285
+ const bytes = new Uint8Array(run.length / 3);
286
+ for (let i = 0; i < bytes.length; i++)
287
+ bytes[i] = parseInt(run.slice(i * 3 + 1, i * 3 + 3), 16);
288
+ return new TextDecoder().decode(bytes);
289
+ }
290
+ /**
291
+ * The character `codePoint` names, or `escape` itself when it names none or
292
+ * names a backslash: a decoded backslash would escape the character after it
293
+ * on the next pass (`\u005cnAbc…` would lose its `n`), so an escape for one
294
+ * stays as written, as a written backslash pair does.
295
+ */
296
+ function codePointOr(escape, codePoint) {
297
+ return codePoint <= 0x10ffff && codePoint !== 0x5c ? String.fromCodePoint(codePoint) : escape;
298
+ }
299
+ /**
300
+ * What JSON's short escapes for a separator or a control character stand for.
301
+ * `\"` and `\\` stay as written: neither character joins or splits a token,
302
+ * and decoding `\\` would halve a run of backslashes on every pass.
303
+ */
304
+ const JSON_SHORT_ESCAPE = {
305
+ n: "\n",
306
+ r: "\r",
307
+ t: "\t",
308
+ b: "\b",
309
+ f: "\f",
310
+ "/": "/",
311
+ };
312
+ /**
313
+ * Every backslash escape `normalizeOnce` reads, in one alternation scanned
314
+ * left to right: an escaped backslash (`\\`) is consumed as a pair before any
315
+ * escape could start at its second backslash, so the backslash it ends never
316
+ * escapes what follows (`C:\\keys\\nAbc…`, `C:\\u0041` and `C:\\x41` keep
317
+ * their `n`, `u0041` and `x41`).
318
+ */
319
+ const BACKSLASH_ESCAPE = /\\(?:\\|u\{0*([0-9a-fA-F]{1,6})\}|u([0-9a-fA-F]{4})|x([0-9a-fA-F]{2})|([nrtbf/]))/g;
320
+ /** A run of `%XX` escapes, never `%5C` (a backslash, kept as written: `codePointOr`). */
321
+ const PERCENT_RUN = /(?:%(?![5][cC])[0-9a-fA-F]{2})+/g;
322
+ /**
323
+ * One linear normalization pass: decode `\u{…}`, `\uXXXX`, `\xXX`, the JSON
324
+ * escapes `\n`, `\r`, `\t`, `\b`, `\f` and `\/` (`BACKSLASH_ESCAPE`), `%XX`
325
+ * and numeric character references (any number of leading zeros, `;`
326
+ * optional), fold every non-ASCII character but a space to its compatibility
327
+ * form (`COMPATIBILITY_FOLDED`), then drop invisible characters and complete
328
+ * markup tags, so a token split or disguised by any of these reads whole
329
+ * again. Nothing decodes or folds to a backslash: an escape or character
330
+ * standing for one stays as written (`codePointOr`). A JSON error body writes
331
+ * every line break and tab in a string as an escape, so without them a token
332
+ * split across lines would read a letter (`n`) between its pieces, which no
333
+ * separator rule skips.
334
+ */
335
+ function normalizeOnce(text) {
336
+ return text
337
+ .replace(BACKSLASH_ESCAPE, (escape, braced, u4, x2, short) => {
338
+ if (short !== undefined)
339
+ return JSON_SHORT_ESCAPE[short];
340
+ const hex = braced ?? u4 ?? x2;
341
+ return hex === undefined ? escape : codePointOr(escape, parseInt(hex, 16));
342
+ })
343
+ .replace(PERCENT_RUN, decodePercentRun)
344
+ .replace(/&#(?:[xX]0*([0-9a-fA-F]{1,6})|0*([0-9]{1,7}));?/g, (ref, hex, dec) => codePointOr(ref, hex === undefined ? Number(dec) : parseInt(hex, 16)))
345
+ .replace(COMPATIBILITY_FOLDED, (char) => {
346
+ const folded = char.normalize("NFKD");
347
+ return folded.includes("\\") ? char : folded;
348
+ })
349
+ .replace(INVISIBLE, "")
350
+ .replace(MARKUP_TAG, "");
351
+ }
352
+ /**
353
+ * Whether a base64url run has the shape of a piece of a random token: at
354
+ * least 8 characters, not hex, holding a digit, `-` or `_`, or mixing cases
355
+ * past its first character. One that reads as a name (`IDENTIFIER`) is a
356
+ * token piece only in a chain that holds one that does not (`splitTokens`).
357
+ */
358
+ function pieceShaped(run) {
359
+ return (run.length >= 8 &&
360
+ !HEX_RUN.test(run) &&
361
+ (/[0-9_-]/.test(run) || (/[a-z]/.test(run) && /[A-Z]/.test(run.slice(1)))));
362
+ }
363
+ /**
364
+ * Tokens split by ASCII separators, joined: chains of piece-shaped runs
365
+ * (`pieceShaped`), one `PIECE_GAP` apart, at least a token long together. A
366
+ * run that reads as a name (`IDENTIFIER`) joins only when the unbroken
367
+ * sequence of linked piece-shaped runs it sits in holds one that does not: a
368
+ * list of real names stays readable, and random pieces that happen to read
369
+ * as CamelCase, one or several in a row, do not break the chain they sit in.
370
+ * After a line break that follows a piece of `TOKEN_FRAGMENT_CHARS` or more
371
+ * (a hard wrap), the run that ends the chain may be of any shape.
372
+ */
373
+ function splitTokens(text) {
374
+ const runs = [];
375
+ let end = 0;
376
+ for (const match of text.matchAll(BASE64URL_RUN)) {
377
+ const [run] = match;
378
+ const gap = text.slice(end, match.index);
379
+ end = match.index + run.length;
380
+ const shaped = pieceShaped(run);
381
+ const name = IDENTIFIER.test(run);
382
+ runs.push({
383
+ run,
384
+ piece: shaped && !name,
385
+ name: shaped && name,
386
+ linked: PIECE_GAP.test(gap),
387
+ wrap: /[\r\n]/.test(gap),
388
+ });
389
+ }
390
+ // Whether each run's unbroken sequence of linked piece-shaped runs holds a
391
+ // piece that is not a name.
392
+ const nearPiece = new Array(runs.length).fill(false);
393
+ for (let start = 0; start < runs.length;) {
394
+ let stop = start + 1;
395
+ if (runs[start].piece || runs[start].name) {
396
+ while (stop < runs.length && runs[stop].linked && (runs[stop].piece || runs[stop].name)) {
397
+ stop++;
398
+ }
399
+ }
400
+ const holdsPiece = runs.slice(start, stop).some((r) => r.piece);
401
+ nearPiece.fill(holdsPiece, start, stop);
402
+ start = stop;
403
+ }
404
+ const joinable = (i) => runs[i].piece || (runs[i].name && nearPiece[i]);
405
+ const found = [];
406
+ let chain = [];
407
+ // Whether the chain may take another run: a hard-wrap tail ends it.
408
+ let open = false;
409
+ const flush = () => {
410
+ const joined = chain.join("");
411
+ if (chain.length > 1 && joined.length >= index_js_1.READ_TOKEN_LENGTH)
412
+ found.push(joined);
413
+ chain = [];
414
+ open = false;
415
+ };
416
+ for (let i = 0; i < runs.length; i++) {
417
+ const { run, linked, wrap } = runs[i];
418
+ const prev = runs[i - 1];
419
+ if (open && linked && joinable(i)) {
420
+ chain.push(run);
421
+ }
422
+ else if (open && linked && wrap && prev.piece && prev.run.length >= TOKEN_FRAGMENT_CHARS) {
423
+ chain.push(run);
424
+ open = false;
425
+ }
426
+ else {
427
+ flush();
428
+ if (joinable(i)) {
429
+ chain = [run];
430
+ open = true;
431
+ }
432
+ }
433
+ }
434
+ flush();
435
+ return found;
436
+ }
437
+ /**
438
+ * Every value of a body that may be a read token: the body's own `read_token`,
439
+ * the value after any read-token key, and every base64url run at least a
440
+ * token long that is not hex and every token split into pieces
441
+ * (`RUN_JOINER` separators removed, `splitTokens` across ASCII ones), each in
442
+ * the normalized prefix and in the undecoded one. Read from the whole prefix,
443
+ * so an echo before the cut is caught even when the token itself lies past
444
+ * it.
445
+ */
446
+ function possibleTokens(body, text) {
447
+ const tokens = [];
448
+ const served = parseJsonObject(body)?.read_token;
449
+ if (typeof served === "string" && index_js_1.READ_TOKEN_PATTERN.test(served))
450
+ tokens.push(served);
451
+ const joined = text.replace(RUN_JOINER, "");
452
+ for (const [, , value] of joined.matchAll(READ_TOKEN_KEYED_VALUE))
453
+ tokens.push(value);
454
+ // The undecoded prefix too: decoding a raw `\n`, `\t`, … eats the first
455
+ // character of a token written right after a backslash (`C:\keys\nAbc…`),
456
+ // whole or split, leaving a run or chain one character short of a token.
457
+ for (const source of [joined, body.replace(RUN_JOINER, "")]) {
458
+ for (const [run] of source.matchAll(LONG_BASE64URL_RUN)) {
459
+ if (!HEX_RUN.test(run))
460
+ tokens.push(run);
461
+ }
462
+ tokens.push(...splitTokens(source));
463
+ }
464
+ return tokens;
465
+ }
466
+ /** The bytes a base64url string encodes, as lowercase hex. */
467
+ function base64urlHex(value) {
468
+ let hex = "";
469
+ let acc = 0;
470
+ let bits = 0;
471
+ for (const char of value) {
472
+ acc = ((acc << 6) | BASE64URL_ALPHABET.indexOf(char)) & 0xffff;
473
+ bits += 6;
474
+ if (bits >= 8) {
475
+ bits -= 8;
476
+ hex += ((acc >> bits) & 0xff).toString(16).padStart(2, "0");
477
+ }
478
+ }
479
+ return hex;
480
+ }
481
+ /** An ASCII string's character codes as lowercase hex. */
482
+ function asciiHex(value) {
483
+ let hex = "";
484
+ for (let i = 0; i < value.length; i++)
485
+ hex += value.charCodeAt(i).toString(16).padStart(2, "0");
486
+ return hex;
487
+ }
488
+ /**
489
+ * The characters of `text` in one alphabet, each with its index in `text`:
490
+ * what a token reads as once every separator around or inside it is ignored.
491
+ * The hex alphabet is folded to lower case and skips `0x` prefixes.
492
+ */
493
+ function skeleton(text, alphabet, hex) {
494
+ let chars = "";
495
+ const at = [];
496
+ for (let i = 0; i < text.length; i++) {
497
+ if (hex &&
498
+ text[i] === "0" &&
499
+ /[xX]/.test(text[i + 1] ?? "") &&
500
+ HEX_DIGIT.test(text[i + 2] ?? "")) {
501
+ i++;
502
+ continue;
503
+ }
504
+ if (!alphabet.test(text[i]))
505
+ continue;
506
+ chars += hex ? text[i].toLowerCase() : text[i];
507
+ at.push(i);
508
+ }
509
+ return { chars, at };
510
+ }
511
+ /**
512
+ * `text` with every `TOKEN_FRAGMENT_CHARS`-long fragment of each token hidden,
513
+ * in base64url, standard base64 or hex (of its bytes or of its characters, any
514
+ * case), matched on the text's `skeleton` in that alphabet: a fragment is
515
+ * found whatever separators split it, and the span it covers, separators
516
+ * included, is hidden. Overlapping spans merge into one `[redacted]`.
517
+ */
518
+ function redactTokenFragments(text, tokens) {
519
+ const width = TOKEN_FRAGMENT_CHARS;
520
+ const base64url = new Set();
521
+ const base64 = new Set();
522
+ const hex = new Set();
523
+ const addFragments = (into, form) => {
524
+ for (let i = 0; i + width <= form.length; i++)
525
+ into.add(form.slice(i, i + width));
526
+ };
527
+ for (const token of tokens) {
528
+ addFragments(base64url, token);
529
+ addFragments(base64, token.replace(/-/g, "+").replace(/_/g, "/"));
530
+ addFragments(hex, base64urlHex(token));
531
+ addFragments(hex, asciiHex(token));
532
+ }
533
+ if (base64url.size === 0)
534
+ return text;
535
+ const hidden = new Uint8Array(text.length);
536
+ const forms = [
537
+ [base64url, skeleton(text, /[A-Za-z0-9_-]/, false)],
538
+ [base64, skeleton(text, /[A-Za-z0-9+/]/, false)],
539
+ [hex, skeleton(text, HEX_DIGIT, true)],
540
+ ];
541
+ for (const [fragments, { chars, at }] of forms) {
542
+ for (let i = 0; i + width <= chars.length; i++) {
543
+ if (fragments.has(chars.slice(i, i + width)))
544
+ hidden.fill(1, at[i], at[i + width - 1] + 1);
545
+ }
546
+ }
547
+ let out = "";
548
+ for (let i = 0; i < text.length;) {
549
+ if (!hidden[i]) {
550
+ out += text[i++];
551
+ continue;
552
+ }
553
+ out += REDACTED;
554
+ while (i < text.length && hidden[i])
555
+ i++;
556
+ }
557
+ return out;
558
+ }
559
+ /**
560
+ * An error body's capped prefix (`readCappedText`) made fit for
561
+ * `ApiError.message` and the logs: the redaction rule stated in README
562
+ * "Transport", applied in its order. Every step is linear in the prefix.
563
+ */
564
+ function redactErrorBody(body, truncated) {
565
+ // Decode before cutting, so the cut never splits an escape mid-token.
566
+ let text = body;
567
+ for (let pass = 0;; pass++) {
568
+ const next = normalizeOnce(text);
569
+ if (next === text)
570
+ break;
571
+ // Still unwrapping after the last pass: nothing of it is shown.
572
+ if (pass === NORMALIZE_PASSES)
573
+ return REDACTED;
574
+ text = next;
575
+ }
576
+ const tokens = possibleTokens(body, text);
577
+ let cut = truncated;
578
+ if (text.length > MAX_ERROR_EXCERPT_CHARS) {
579
+ text = text.slice(0, MAX_ERROR_EXCERPT_CHARS);
580
+ cut = true;
581
+ }
582
+ // A cut may have split a tag, an escape or a token: drop what it ends in.
583
+ if (cut) {
584
+ text = text.replace(UNCLOSED_TRAILING_TAG, "");
585
+ let end = text.length;
586
+ while (end > 0 && CUT_TAIL_CHAR.test(text[end - 1]))
587
+ end--;
588
+ text = `${text.slice(0, end)}…`;
589
+ }
590
+ return redactTokenFragments(text, tokens)
591
+ .replace(READ_TOKEN_KEYED_VALUE, `$1${REDACTED}`)
592
+ .replace(LONG_BASE64URL_RUN, (run) => (HEX_RUN.test(run) ? run : REDACTED));
593
+ }
594
+ /** `"intent_id": "…"` and the opening of a `read_token` key in a JSON prefix. */
595
+ const PREFIX_INTENT_ID = /"intent_id"\s*:\s*"([^"\\]*)"/g;
596
+ const PREFIX_READ_TOKEN_KEY = /"read_token"\s*:/g;
597
+ const PREFIX_READ_TOKEN = new RegExp(`"read_token"\\s*:\\s*"([A-Za-z0-9_-]{${index_js_1.READ_TOKEN_LENGTH}})"`);
598
+ /**
599
+ * The object/array nesting depth at `index` of a JSON prefix, counted outside
600
+ * strings, or -1 when `index` lies inside a string or past the close of the
601
+ * value the prefix opens.
602
+ */
603
+ function jsonDepthAt(prefix, index) {
604
+ let depth = 0;
605
+ let inString = false;
606
+ for (let i = 0; i < index; i++) {
607
+ const c = prefix[i];
608
+ if (inString) {
609
+ if (c === "\\")
610
+ i++;
611
+ else if (c === '"')
612
+ inString = false;
613
+ }
614
+ else if (c === '"') {
615
+ inString = true;
616
+ }
617
+ else if (c === "{" || c === "[") {
618
+ depth++;
619
+ }
620
+ else if ((c === "}" || c === "]") && --depth === 0) {
621
+ return -1;
622
+ }
623
+ }
624
+ return inString ? -1 : depth;
625
+ }
626
+ /**
627
+ * The `read_token` a cut JSON error body carries for `intentId`, read by strict
628
+ * match from the prefix that `readCappedText` kept (a cut body does not parse).
629
+ * Only from a prefix that opens a JSON object and names exactly one
630
+ * `intent_id`, equal to `intentId`, and exactly one `read_token` key, whose
631
+ * value is a whole token followed by its closing quote, both keys of that
632
+ * object itself (depth 1), never of a value nested in it.
633
+ */
634
+ function readTokenFromPrefix(prefix, intentId) {
635
+ if (!/^\s*\{/.test(prefix))
636
+ return undefined;
637
+ const ids = [...prefix.matchAll(PREFIX_INTENT_ID)];
638
+ if (ids.length !== 1 || ids[0][1] !== intentId)
639
+ return undefined;
640
+ if (jsonDepthAt(prefix, ids[0].index) !== 1)
641
+ return undefined;
642
+ if ([...prefix.matchAll(PREFIX_READ_TOKEN_KEY)].length !== 1)
643
+ return undefined;
644
+ const token = PREFIX_READ_TOKEN.exec(prefix);
645
+ return token && jsonDepthAt(prefix, token.index) === 1 ? token[1] : undefined;
646
+ }
647
+ /**
648
+ * Parse a Retry-After header (our API sends integer seconds) into milliseconds,
649
+ * clamped to [0, maxDelayMs] so a malformed or hostile value can't stall the
650
+ * client. Returns null when absent/unparseable so the caller falls back to its
651
+ * own exponential backoff.
652
+ */
653
+ function parseRetryAfterMs(headerValue, maxDelayMs = exports.DEFAULT_RETRY.maxDelayMs) {
654
+ if (!headerValue)
655
+ return null;
656
+ const seconds = Number(headerValue);
657
+ if (!Number.isFinite(seconds) || seconds < 0)
658
+ return null;
659
+ return Math.min(seconds * 1000, maxDelayMs);
660
+ }
661
+ /**
662
+ * HTTP statuses worth another attempt. Deliberately a closed set of TRANSIENT
663
+ * GATEWAY failures: 502/504 are a hiccup between the worker and the solver, and
664
+ * 503 is documented backpressure that carries its own Retry-After. Everything
665
+ * else fails fast, 500 included: a 500 is an unhandled exception in the worker,
666
+ * so every further attempt returns the same 500 while multiplying the error rate
667
+ * and delaying the failure the user sees (xochi#420). A network failure is not
668
+ * an ApiError and stays retryable, which is the case retries exist for.
669
+ */
670
+ const RETRYABLE_STATUS = { 502: true, 503: true, 504: true };
671
+ /**
672
+ * Ceiling on the DERIVED display wait (seconds). Distinct from the internal
673
+ * backoff clamp: this bounds only the user-facing countdown so a
674
+ * buggy-but-trusted upstream value (e.g. an absolute epoch mistakenly sent in a
675
+ * delta-only Retry-After header) cannot produce a multi-decade countdown that
676
+ * disables Retry until a reload. 10 minutes is far past any legitimate
677
+ * backpressure window.
678
+ */
679
+ exports.MAX_RETRY_WAIT_SEC = 600;
680
+ /**
681
+ * Resolve the display countdown target (ABSOLUTE unix SECONDS) for a
682
+ * backpressure response. Distinct from parseRetryAfterMs, which returns clamped
683
+ * milliseconds for internal retry pacing: a display countdown reflects the full
684
+ * server-requested wait, bounded only by MAX_RETRY_WAIT_SEC.
685
+ *
686
+ * Sources, in priority order:
687
+ * - `Retry-After` header: DELTA seconds, converted to absolute (now + delta).
688
+ * - `X-RateLimit-Reset` header: already ABSOLUTE unix seconds.
689
+ * - `retryAfter` in a JSON body (429 response): already ABSOLUTE unix seconds.
690
+ *
691
+ * Returns undefined when no source is present or parseable (so no countdown is
692
+ * shown and Retry stays enabled).
693
+ */
694
+ function resolveRetryAfterSeconds(headers, body) {
695
+ const nowSec = Math.floor(Date.now() / 1000);
696
+ const target = deriveRetryTarget(headers, body, nowSec);
697
+ if (target === undefined)
698
+ return undefined;
699
+ // Clamp the derived wait to a sane ceiling so no upstream value can lock the
700
+ // UI. A past/absolute value stays under the cap and simply yields no live
701
+ // countdown; the lower bound stays at 0.
702
+ return Math.min(target, nowSec + exports.MAX_RETRY_WAIT_SEC);
703
+ }
704
+ function deriveRetryTarget(headers, body, nowSec) {
705
+ const retryAfterHeader = headers.get("Retry-After");
706
+ if (retryAfterHeader !== null) {
707
+ const delta = Number(retryAfterHeader);
708
+ if (Number.isFinite(delta) && delta >= 0)
709
+ return nowSec + delta;
710
+ }
711
+ const reset = headers.get("X-RateLimit-Reset");
712
+ if (reset !== null) {
713
+ const abs = Number(reset);
714
+ if (Number.isFinite(abs) && abs > 0)
715
+ return abs;
716
+ }
717
+ // A body that is not a JSON object carries no absolute retry hint.
718
+ const value = parseJsonObject(body)?.retryAfter;
719
+ if (typeof value === "number" && Number.isFinite(value) && value > 0)
720
+ return value;
721
+ return undefined;
722
+ }
723
+ /**
724
+ * The read-capability header for one intent read: none without a token, null
725
+ * for a token not of the `read_token` shape (the caller decides whether that
726
+ * throws; nothing echoes the token, a bearer secret).
727
+ */
728
+ function readTokenHeaders(opts) {
729
+ const readToken = opts?.readToken;
730
+ if (readToken === undefined)
731
+ return {};
732
+ return index_js_1.READ_TOKEN_PATTERN.test(readToken) ? { [index_js_1.READ_TOKEN_HEADER]: readToken } : null;
733
+ }
734
+ const MALFORMED_READ_TOKEN = `readToken is not a read_token (${index_js_1.READ_TOKEN_LENGTH} unpadded base64url characters)`;
735
+ /**
736
+ * An intent read or an `execute` answered by a redirect it may not follow, or
737
+ * by a response from another origin or naming none (rules: README
738
+ * "Transport"). `getIntent`, `getIntentHistory` and `execute` reject with it
739
+ * (an execute refused this way is in doubt); `getIntentStatus` reports it once
740
+ * per intent and reads again without the token, or returns null when that
741
+ * read is refused too.
742
+ */
743
+ class RedirectRefused extends Error {
744
+ constructor(path, reason = "a redirect") {
745
+ super(`Refused ${reason} (${path})`);
746
+ this.name = "RedirectRefused";
747
+ }
748
+ }
749
+ exports.RedirectRefused = RedirectRefused;
750
+ /**
751
+ * The refusal reason, and the one-time log line, for an answer naming no
752
+ * `url`: a spec fetch names one on every network answer, so the injected
753
+ * `fetch` built this response itself.
754
+ */
755
+ const URLLESS_RESPONSE = "an answer naming no url: the injected fetch rebuilt the response, so every execute and token read is refused and an execute's read token is lost; return the response fetch produced";
756
+ /** Ids a log-once set holds before it is cleared, so a long session cannot grow it without bound. */
757
+ const MAX_WARNED_IDS = 500;
758
+ /** The statuses whose `Location` a pinned GET may follow by hand (once, same origin). */
759
+ const FOLLOWABLE_REDIRECT = {
760
+ 301: true,
761
+ 302: true,
762
+ 303: true,
763
+ 307: true,
764
+ 308: true,
765
+ };
766
+ /**
767
+ * `url` resolved against `base`, else against the page (a browser resolves a
768
+ * relative `baseUrl` against it; elsewhere there is none), or undefined when
769
+ * it does not resolve.
770
+ */
771
+ function resolveUrl(url, base) {
772
+ try {
773
+ return new URL(url, base ?? (typeof location === "undefined" ? undefined : location.href));
774
+ }
775
+ catch {
776
+ return undefined;
777
+ }
778
+ }
779
+ /**
780
+ * Whether a response's `url` is on the origin the request was sent to. A
781
+ * constructed `Response` names no url (empty): nothing to compare. A request
782
+ * url that does not resolve (a relative `baseUrl` outside a browser) fails
783
+ * closed.
784
+ */
785
+ function staysOnOrigin(responseUrl, requestUrl) {
786
+ if (!responseUrl)
787
+ return true;
788
+ const request = resolveUrl(requestUrl);
789
+ return request !== undefined && resolveUrl(responseUrl)?.origin === request.origin;
790
+ }
791
+ /**
792
+ * Where a pinned request may follow `fetched` to: a plain redirect status from
793
+ * the request's origin whose `Location` stays on it. Undefined for anything
794
+ * else, an `opaqueredirect` included (a browser hides its `Location`).
795
+ */
796
+ function sameOriginRedirectTarget(fetched, requestUrl) {
797
+ if (!FOLLOWABLE_REDIRECT[fetched.status] || fetched.redirected)
798
+ return undefined;
799
+ if (!staysOnOrigin(fetched.url, requestUrl))
800
+ return undefined;
801
+ const request = resolveUrl(requestUrl);
802
+ const location = fetched.headers.get("Location");
803
+ if (request === undefined || location === null)
804
+ return undefined;
805
+ const target = resolveUrl(location, request.href);
806
+ return target?.origin === request.origin ? target.href : undefined;
807
+ }
808
+ const WALLET_SESSION_REAUTH_CATEGORIES = {
809
+ auth_invalid: true,
810
+ wallet_auth_required: true,
811
+ };
812
+ const consoleLogger = (level, message, ...data) => {
813
+ if (level === "warn")
814
+ console.warn(message, ...data);
815
+ else if (level === "error")
816
+ console.error(message, ...data);
817
+ };
818
+ function createXochiClient(options) {
819
+ const baseUrl = options.baseUrl.replace(/\/+$/, "");
820
+ const doFetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
821
+ const log = options.log ?? consoleLogger;
822
+ const timeouts = { ...exports.DEFAULT_TIMEOUTS, ...options.timeouts };
823
+ const retry = { ...exports.DEFAULT_RETRY, ...options.retry };
824
+ const quoteCache = new Map();
825
+ const pendingQuotes = new Map();
826
+ // Ids already warned about a non-ok status poll. getIntentStatus runs in the
827
+ // poll loop, so a persistently failing id would flood the log; warn once per
828
+ // id instead. Cleared when it grows past a bound so a long session can't leak.
829
+ const warnedStatusIds = new Set();
830
+ // Ids already warned about a read token their status poll could not use
831
+ // (malformed, or answered by a redirect), bounded the same way.
832
+ const warnedReadTokenIds = new Set();
833
+ // Whether the one log line for a response-rebuilding injected fetch is out.
834
+ let warnedUrllessResponse = false;
835
+ /**
836
+ * One HTTP attempt with the bearer token attached and a timeout. Reports a
837
+ * credential failure the server names to `onAuthExpired`, then hands the
838
+ * response to `read` for the caller to classify and consume.
839
+ *
840
+ * The timeout covers the whole attempt, body included: a server that sends
841
+ * its headers and then stalls the body would otherwise hang the caller with
842
+ * no deadline at all. On expiry the attempt rejects with an `AbortError`, the
843
+ * same as a stalled connect, whether or not the injected `fetch` honours the
844
+ * signal on its body.
845
+ *
846
+ * Every GET (the intent reads) and `execute` are pinned to the request's
847
+ * origin, so no other host's answer becomes an intent's status, record or
848
+ * execute result:
849
+ * - A request carrying the read capability is sent with `redirect:
850
+ * "manual"`. It may follow one plain redirect whose `Location` stays on
851
+ * that origin, by hand and with the same headers (a path migration keeps
852
+ * the token). Anything else is refused (`RedirectRefused`): a further 3xx,
853
+ * an `opaqueredirect` (a browser's answer to every manual redirect: it
854
+ * hides the `Location`), `redirected`, a `response.url` off the origin, or
855
+ * none at all (a response the injected fetch built, which may be a
856
+ * followed redirect re-wrapped). fetch drops `Authorization` on a
857
+ * cross-origin hop but forwards a custom header, so following would hand
858
+ * the bearer secret to whatever host the `Location` names. `"manual"`, not
859
+ * `"error"`: Cloudflare Workers throws on `"error"`. The guard holds only
860
+ * for an injected `fetch` that honours `redirect: "manual"` (README
861
+ * "Transport").
862
+ * - `execute` arrives with `redirect: "manual"` in `init` and follows
863
+ * nothing: every redirect shape above is refused unread, so a redirect
864
+ * never re-posts the signed request and no other host's answer or token
865
+ * is taken.
866
+ * - A GET without the token carries no secret, so fetch follows its
867
+ * redirects, and the answer is refused (`RedirectRefused`) unless its
868
+ * `response.url` is on the origin; a `redirected` answer must name it.
869
+ */
870
+ async function send(path, init, timeoutMs, read) {
871
+ const token = options.getAuthToken?.() ?? null;
872
+ const headers = new Headers(init.headers);
873
+ if (token && !headers.has("Authorization"))
874
+ headers.set("Authorization", `Bearer ${token}`);
875
+ const carriesToken = headers.has(index_js_1.READ_TOKEN_HEADER);
876
+ const manual = carriesToken || init.redirect === "manual";
877
+ const isGet = (init.method ?? "GET") === "GET";
878
+ const controller = new AbortController();
879
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
880
+ // The source body's reader, held here so an abort can cancel the stream
881
+ // even while a `read` holds the (locked) body it was re-wrapped into.
882
+ let source;
883
+ const attempt = async () => {
884
+ const requestUrl = `${baseUrl}${path}`;
885
+ const requestInit = {
886
+ ...init,
887
+ headers,
888
+ signal: controller.signal,
889
+ ...(carriesToken ? { redirect: "manual" } : {}),
890
+ };
891
+ let fetched = await doFetch(requestUrl, requestInit);
892
+ // Said once, on the first answer of any call (a quote comes first), so a
893
+ // host learns of a response-rebuilding fetch before its first execute.
894
+ if (!fetched.url && !warnedUrllessResponse) {
895
+ warnedUrllessResponse = true;
896
+ log("error", `[xochi-client] ${URLLESS_RESPONSE}`);
897
+ }
898
+ if (manual) {
899
+ const target = isGet && carriesToken ? sameOriginRedirectTarget(fetched, requestUrl) : undefined;
900
+ if (target !== undefined) {
901
+ cancelBody(fetched);
902
+ fetched = await doFetch(target, requestInit);
903
+ }
904
+ // A spec fetch names the url of every network answer; an empty one is
905
+ // a response the injected fetch built itself, which may stand for a
906
+ // redirect it followed and re-wrapped (`url` and `redirected` lost).
907
+ const redirected = fetched.type === "opaqueredirect" ||
908
+ (fetched.status >= 300 && fetched.status < 400) ||
909
+ fetched.redirected ||
910
+ !staysOnOrigin(fetched.url, requestUrl);
911
+ if (redirected || !fetched.url) {
912
+ cancelBody(fetched);
913
+ throw new RedirectRefused(path, redirected ? undefined : URLLESS_RESPONSE);
914
+ }
915
+ }
916
+ else if (isGet &&
917
+ (fetched.type === "opaqueredirect" ||
918
+ (fetched.redirected && !fetched.url) ||
919
+ !staysOnOrigin(fetched.url, requestUrl))) {
920
+ cancelBody(fetched);
921
+ throw new RedirectRefused(path);
922
+ }
923
+ let response = fetched;
924
+ if (fetched.body) {
925
+ const reader = fetched.body.getReader();
926
+ source = reader;
927
+ // Pulled only on demand (no read-ahead), so a capped read
928
+ // (`readCappedText`) takes no more of the source than it keeps.
929
+ const body = new ReadableStream({
930
+ async pull(sink) {
931
+ const chunk = await reader.read();
932
+ if (chunk.done)
933
+ sink.close();
934
+ else
935
+ sink.enqueue(chunk.value);
936
+ },
937
+ cancel: (reason) => reader.cancel(reason),
938
+ }, { highWaterMark: 0 });
939
+ response = rewrapResponse(fetched, body);
940
+ }
941
+ if (options.onAuthExpired && (response.status === 401 || response.status === 403)) {
942
+ // Read once, capped, and hand `read` exactly that prefix, ending as the
943
+ // read did (cut or not): every reader of a failure reads it capped, and
944
+ // a copy (`clone`) would read ahead of the cap for the other branch.
945
+ const { bytes, truncated } = await readCappedBytes(response);
946
+ const body = new TextDecoder().decode(bytes);
947
+ let delivered = bytes.byteLength === 0;
948
+ response = rewrapResponse(response, new ReadableStream({
949
+ // Erroring clears a stream's queue, so the cut is signalled on
950
+ // the pull after the prefix, never with it.
951
+ pull(sink) {
952
+ if (!delivered) {
953
+ delivered = true;
954
+ sink.enqueue(bytes);
955
+ }
956
+ else if (truncated) {
957
+ sink.error(new Error("cut at the error-body read cap"));
958
+ }
959
+ else {
960
+ sink.close();
961
+ }
962
+ },
963
+ }, { highWaterMark: 0 }));
964
+ if (response.status === 401 && /expired/i.test(body)) {
965
+ options.onAuthExpired({ reason: "expired", status: response.status });
966
+ }
967
+ else if (token) {
968
+ const category = parseJsonObject(body)?.error_category;
969
+ if (typeof category === "string" && WALLET_SESSION_REAUTH_CATEGORIES[category]) {
970
+ options.onAuthExpired({
971
+ reason: "invalid",
972
+ status: response.status,
973
+ errorCategory: category,
974
+ });
975
+ }
976
+ }
977
+ }
978
+ return read(response);
979
+ };
980
+ try {
981
+ return await untilAborted(attempt(), controller.signal);
982
+ }
983
+ catch (err) {
984
+ // An injected fetch may ignore the signal: release the stalled body
985
+ // stream rather than leaving it open behind the abandoned read.
986
+ if (controller.signal.aborted)
987
+ source?.cancel().catch(() => { });
988
+ throw err;
989
+ }
990
+ finally {
991
+ clearTimeout(timeoutId);
992
+ }
993
+ }
994
+ async function sendWithRetry(path, init, validator, timeoutMs,
995
+ // Total attempts. Non-idempotent writes (execute) MUST pass 1: a 5xx/502/
996
+ // network failure on execute is IN-DOUBT (the origin pull may already be
997
+ // consumed), so a blind retry re-sends the one-time pull authorization and
998
+ // fails with "pull authorization has already been used". Those callers let
999
+ // the failure surface and resolve it by polling status.
1000
+ maxAttempts,
1001
+ // The intent an execute names (execute only): a failed attempt's body may
1002
+ // carry its `read_token`, held for the thrown error
1003
+ // (`takeApiErrorReadToken`) and never on it, or the not-accepted marker
1004
+ // (`ApiError.notAccepted`).
1005
+ executedIntentId) {
1006
+ let lastError = null;
1007
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
1008
+ // Server-supplied backoff hint for this attempt (503 backpressure). Falls
1009
+ // back to exponential when absent.
1010
+ let retryAfterMs = null;
1011
+ try {
1012
+ const data = await send(path, init, timeoutMs, async (response) => {
1013
+ if (!response.ok) {
1014
+ const { text: errorBody, truncated } = await readCappedText(response);
1015
+ // A 503 is a transient solver blip (saturation / gas-oracle
1016
+ // unavailable); pace the retry off the server's Retry-After.
1017
+ if (response.status === 503) {
1018
+ retryAfterMs = parseRetryAfterMs(response.headers.get("Retry-After"), retry.maxDelayMs);
1019
+ }
1020
+ // A cut body does not parse, so it never carries the marker.
1021
+ const record = truncated ? undefined : parseJsonObject(errorBody);
1022
+ // A cut body does not parse: its token is read from the prefix kept.
1023
+ const servedToken = executedIntentId === undefined
1024
+ ? undefined
1025
+ : truncated
1026
+ ? readTokenFromPrefix(errorBody, executedIntentId)
1027
+ : readTokenForIntent(record, executedIntentId);
1028
+ const inDoubt = executedIntentId !== undefined &&
1029
+ isInDoubtExecuteAnswer(response.status, record, executedIntentId, servedToken);
1030
+ // An answer that also says funds may have moved is never resent.
1031
+ const notAccepted = executedIntentId !== undefined &&
1032
+ !inDoubt &&
1033
+ isNotAcceptedExecuteBody(response.status, record, executedIntentId);
1034
+ // Structured failure: the numeric status plus, for backpressure
1035
+ // (429/503), an absolute retry target for a countdown. A
1036
+ // not-accepted answer's target is the wait its body names
1037
+ // (`notAcceptedRetryAt`), never an earlier source.
1038
+ const retryAfter = notAccepted
1039
+ ? notAcceptedRetryAt(response.headers, record.retry_after)
1040
+ : response.status === 429 || response.status === 503
1041
+ ? resolveRetryAfterSeconds(response.headers, errorBody)
1042
+ : undefined;
1043
+ const error = new ApiError(`HTTP ${response.status}: ${redactErrorBody(errorBody, truncated) || response.statusText}`, response.status, retryAfter, notAccepted, inDoubt);
1044
+ if (servedToken !== undefined)
1045
+ errorReadTokens.set(error, servedToken);
1046
+ throw error;
1047
+ }
1048
+ return response.json();
1049
+ });
1050
+ if (!validator(data)) {
1051
+ // A 2xx execute reports an execute that may already have moved funds:
1052
+ // a token it served for this intent outlives the malformed body.
1053
+ const error = new Error("Invalid response format");
1054
+ const servedToken = executedIntentId === undefined || typeof data !== "object" || data === null
1055
+ ? undefined
1056
+ : readTokenForIntent(data, executedIntentId);
1057
+ if (servedToken !== undefined)
1058
+ errorReadTokens.set(error, servedToken);
1059
+ throw error;
1060
+ }
1061
+ return data;
1062
+ }
1063
+ catch (err) {
1064
+ lastError = err instanceof Error ? err : new Error(String(err));
1065
+ // Retry only a transient gateway status. A 4xx is the caller's to fix; a
1066
+ // 429 carries its own retryAfter, and retrying in-call would stale a
1067
+ // quote and stack against the limit. A 500 is deterministic (xochi#420).
1068
+ if (lastError instanceof ApiError && !RETRYABLE_STATUS[lastError.code])
1069
+ break;
1070
+ // Don't retry an abort (the timeout fired).
1071
+ if (lastError.name === "AbortError")
1072
+ break;
1073
+ if (attempt < maxAttempts - 1) {
1074
+ const delay = retryAfterMs ?? retry.baseDelayMs * Math.pow(2, attempt);
1075
+ // A retry means the request already failed once: that is the context
1076
+ // for the error below if the retries run out.
1077
+ log("warn", `[xochi-client] Retry ${attempt + 1}/${maxAttempts} in ${delay}ms`);
1078
+ const { promise, resolve } = Promise.withResolvers();
1079
+ setTimeout(resolve, delay);
1080
+ await promise;
1081
+ }
1082
+ }
1083
+ }
1084
+ log("error", "[xochi-client] Request failed after retries:", safeErrorForLog(lastError));
1085
+ throw lastError ?? new Error("Request failed");
1086
+ }
1087
+ async function sendOnce(path, body) {
1088
+ try {
1089
+ const { response, text } = await send(path, {
1090
+ method: "POST",
1091
+ headers: { "Content-Type": "application/json" },
1092
+ body: JSON.stringify(body),
1093
+ }, timeouts.requestTimeoutMs, async (response) => ({
1094
+ response,
1095
+ // A failure's body is read capped; a success's is the documented response.
1096
+ text: response.ok ? await response.text() : (await readCappedText(response)).text,
1097
+ }));
1098
+ if (!response.ok) {
1099
+ const record = parseJsonObject(text);
1100
+ return {
1101
+ ok: false,
1102
+ error: typeof record?.error === "string"
1103
+ ? record.error
1104
+ : `HTTP ${response.status}: ${response.statusText}`,
1105
+ status: response.status,
1106
+ errorCategory: typeof record?.error_category === "string" ? record.error_category : undefined,
1107
+ body: record,
1108
+ };
1109
+ }
1110
+ // A 200 whose body is not a JSON object is not the documented response.
1111
+ const data = parseJsonObject(text);
1112
+ if (!data)
1113
+ return { ok: false, error: "Invalid response format", status: response.status };
1114
+ return { ok: true, data: data };
1115
+ }
1116
+ catch (err) {
1117
+ if (err instanceof Error) {
1118
+ return { ok: false, error: err.name === "AbortError" ? "Request timeout" : err.message };
1119
+ }
1120
+ return { ok: false, error: "Unknown error" };
1121
+ }
1122
+ }
1123
+ async function fetchQuote(request) {
1124
+ const wire = await sendWithRetry("/api/intent/quote", {
1125
+ method: "POST",
1126
+ headers: { "Content-Type": "application/json" },
1127
+ body: JSON.stringify({
1128
+ wallet: request.wallet,
1129
+ preview: request.preview,
1130
+ allocation_key: request.allocationKey,
1131
+ from_chain_id: request.fromChainId,
1132
+ to_chain_id: request.toChainId,
1133
+ from_token: request.fromToken,
1134
+ to_token: request.toToken,
1135
+ from_amount: request.fromAmount,
1136
+ deadline: request.deadline,
1137
+ slippage_bps: request.slippageBps,
1138
+ trust_score: request.trustScore,
1139
+ settlement_preference: request.settlementPreference,
1140
+ // Recipient on a non-EVM destination (Tron); omitted for EVM routes.
1141
+ recipient_address: request.recipientAddress,
1142
+ // Refund destination on the origin chain for a deposit-route origin.
1143
+ // Omitted for EVM origins, which refund to the connected wallet.
1144
+ refund_address: request.refundAddress,
1145
+ stealth_meta_address: request.stealthMetaAddress
1146
+ ? {
1147
+ spending_pub_key: request.stealthMetaAddress.spendingPubKey,
1148
+ viewing_pub_key: request.stealthMetaAddress.viewingPubKey,
1149
+ chain_id: request.stealthMetaAddress.chainId,
1150
+ }
1151
+ : undefined,
1152
+ }),
1153
+ }, wire_js_1.isQuoteResponseWire, timeouts.requestTimeoutMs, retry.maxAttempts);
1154
+ return (0, wire_js_1.mapQuoteResponse)(wire);
1155
+ }
1156
+ return {
1157
+ async getQuote(request, opts) {
1158
+ const requestHash = hashQuoteRequest(request);
1159
+ // A scheduled refresh must always fetch fresh: drop any cached/pending
1160
+ // entry so the near-expiry quote is replaced, not re-served. The hash
1161
+ // omits `deadline`, so without this a refresh would collide with the
1162
+ // stale entry.
1163
+ if (opts?.forceRefresh) {
1164
+ quoteCache.delete(requestHash);
1165
+ pendingQuotes.delete(requestHash);
1166
+ }
1167
+ const cached = quoteCache.get(requestHash);
1168
+ if (!opts?.forceRefresh &&
1169
+ cached &&
1170
+ Date.now() - cached.timestamp < timeouts.quoteCacheTtlMs) {
1171
+ log("debug", "[xochi-client] Returning cached quote:", cached.quote.quoteId);
1172
+ return cached.quote;
1173
+ }
1174
+ const pending = pendingQuotes.get(requestHash);
1175
+ if (pending && Date.now() - pending.timestamp < timeouts.requestTimeoutMs) {
1176
+ log("debug", "[xochi-client] Deduplicating quote request");
1177
+ return pending.promise;
1178
+ }
1179
+ const promise = fetchQuote(request);
1180
+ pendingQuotes.set(requestHash, { promise, timestamp: Date.now() });
1181
+ try {
1182
+ const quote = await promise;
1183
+ // Cache successful quotes -- EXCEPT pull-bearing ones. A quote carrying a
1184
+ // pull_authorization embeds a single-use ERC-3009 nonce; re-serving it
1185
+ // from the cache would re-sign a spent nonce and fail with "pull
1186
+ // authorization has already been used". Pull routes always refetch.
1187
+ if (quote.success && !quote.pullAuthorization) {
1188
+ quoteCache.set(requestHash, { quote, timestamp: Date.now() });
1189
+ }
1190
+ return quote;
1191
+ }
1192
+ finally {
1193
+ pendingQuotes.delete(requestHash);
1194
+ }
1195
+ },
1196
+ invalidateQuote(request) {
1197
+ quoteCache.delete(hashQuoteRequest(request));
1198
+ },
1199
+ clearQuoteCache() {
1200
+ quoteCache.clear();
1201
+ },
1202
+ async execute(request) {
1203
+ const wire = await sendWithRetry("/api/intent/execute", {
1204
+ method: "POST",
1205
+ headers: { "Content-Type": "application/json" },
1206
+ // Pinned to the origin and never followed (`send`).
1207
+ redirect: "manual",
1208
+ body: JSON.stringify({
1209
+ intent_id: request.intentId,
1210
+ quote_id: request.quoteId,
1211
+ signature: request.signature,
1212
+ nonce: request.nonce,
1213
+ pull_signature: request.pullSignature,
1214
+ aztec_proof: request.aztecProof,
1215
+ }),
1216
+ }, wire_js_1.isExecuteResponseWire, timeouts.executeTimeoutMs,
1217
+ // Single attempt: execute is non-idempotent (one-time pull authorization).
1218
+ 1, request.intentId);
1219
+ const { readToken, ...response } = (0, wire_js_1.mapExecuteResponse)(wire);
1220
+ if (readToken === undefined)
1221
+ return response;
1222
+ // Bound to the executed intent, exactly as on the failure path
1223
+ // (`readTokenForIntent`): a token served under another intent id would
1224
+ // only ever be presented for the wrong intent.
1225
+ if (wire.intent_id === request.intentId)
1226
+ return { ...response, readToken };
1227
+ log("warn", `[xochi-client] execute of ${request.intentId} answered for another intent`);
1228
+ return response;
1229
+ },
1230
+ async getIntent(intentId, opts) {
1231
+ // Strict: a one-shot read the caller asked for, so a malformed token is a
1232
+ // bug to surface now, not a poll that can stall on it.
1233
+ const headers = readTokenHeaders(opts);
1234
+ if (headers === null)
1235
+ throw new Error(MALFORMED_READ_TOKEN);
1236
+ const { response, body } = await send(`/api/intent/${encodeURIComponent(intentId)}`, { method: "GET", headers }, timeouts.requestTimeoutMs, readOkJson);
1237
+ if (!response.ok) {
1238
+ if (response.status === 404)
1239
+ return null;
1240
+ throw new Error(`Failed to get intent ${intentId}: HTTP ${response.status}`);
1241
+ }
1242
+ return (0, wire_js_1.mapIntentRead)(body);
1243
+ },
1244
+ /**
1245
+ * Poll the latest status for an in-flight intent. Discriminated so callers
1246
+ * can tell a definitive absence from a blip:
1247
+ * - the status object on 200,
1248
+ * - `{ notFound: true }` on a 404 (no row for this id; a pre-dispatch row
1249
+ * can be terminalized on it, and a "not sent yet" one past its finish
1250
+ * ends on it as nothing moved; any other is held and reported),
1251
+ * - `null` only on a transient failure (non-404 non-ok, or a network
1252
+ * error); the caller retries on its interval. See #271.
1253
+ * A read token never stops the poll. A malformed one is not sent, and a
1254
+ * token-bearing read refused for a redirect (`RedirectRefused`; in a
1255
+ * browser, any redirect) is read again without it: either way the poll
1256
+ * gets the anonymous status-only view and the problem is reported once per
1257
+ * intent, without echoing the token. That re-read, like every token-less
1258
+ * read, follows redirects natively and is accepted only from the origin
1259
+ * (`send`), so a refused one is a transient failure (`null`).
1260
+ */
1261
+ async getIntentStatus(intentId, opts) {
1262
+ const warnOnce = (message) => {
1263
+ if (warnedReadTokenIds.has(intentId))
1264
+ return;
1265
+ if (warnedReadTokenIds.size > MAX_WARNED_IDS)
1266
+ warnedReadTokenIds.clear();
1267
+ warnedReadTokenIds.add(intentId);
1268
+ log("warn", message);
1269
+ };
1270
+ let headers = readTokenHeaders(opts);
1271
+ if (headers === null) {
1272
+ warnOnce(`[xochi-client] ${MALFORMED_READ_TOKEN}; polling ${intentId} without it`);
1273
+ headers = {};
1274
+ }
1275
+ const path = `/api/intent/${encodeURIComponent(intentId)}/status`;
1276
+ const read = (init) => send(path, { method: "GET", ...init }, timeouts.requestTimeoutMs, readOkJson);
1277
+ try {
1278
+ const { response, body } = await read({ headers }).catch((err) => {
1279
+ if (!(err instanceof RedirectRefused) || !(index_js_1.READ_TOKEN_HEADER in headers))
1280
+ throw err;
1281
+ warnOnce(`[xochi-client] status read for ${intentId} was redirected; polling it without the read token`);
1282
+ return read({ headers: {} });
1283
+ });
1284
+ if (!response.ok) {
1285
+ // Logged, distinctly for a 404, once per id: a 404 leaves an in-flight
1286
+ // order reading "settling forever" and is the signal that was missing
1287
+ // when diagnosing a stuck order (#269).
1288
+ if (!warnedStatusIds.has(intentId)) {
1289
+ if (warnedStatusIds.size > MAX_WARNED_IDS)
1290
+ warnedStatusIds.clear();
1291
+ warnedStatusIds.add(intentId);
1292
+ log("warn", response.status === 404
1293
+ ? `[xochi-client] status 404: no order found for ${intentId}`
1294
+ : `[xochi-client] status poll non-ok (${response.status}) for ${intentId}`);
1295
+ }
1296
+ return response.status === 404 ? { notFound: true } : null;
1297
+ }
1298
+ const d = body;
1299
+ // Decode at the boundary: a granular/unknown solver status is bucketed
1300
+ // to a known state so the poll runs on to a real terminal, and an
1301
+ // unmapped one is flagged so the trade says "Still confirming", never done.
1302
+ const decoded = (0, wire_js_1.decodeWireStatus)(d.status);
1303
+ const status = decoded.status;
1304
+ return {
1305
+ intentId: d.intent_id ?? null,
1306
+ status,
1307
+ ...(decoded.kind === "unmapped" ? { unmapped: true } : {}),
1308
+ txHash: d.tx_hash ?? null,
1309
+ receivingTxHash: d.receiving_tx_hash ?? null,
1310
+ settlementTxHash: d.settlement_tx_hash ?? null,
1311
+ settlementChainId: d.settlement_chain_id ?? null,
1312
+ error: d.error ?? null,
1313
+ refundReason: d.refund_reason ?? null,
1314
+ substatus: d.substatus ?? null,
1315
+ // Trust the server flag; else derive from the normalized status.
1316
+ terminal: d.terminal ?? (0, wire_js_1.isTerminalIntentStatus)(status),
1317
+ // The anonymous view of a private intent (xochi#563): legs withheld,
1318
+ // so the null hashes above mean "not shown", not "not there". Its
1319
+ // `settlement_confirmed` stands in for the withheld receipt, so it
1320
+ // means nothing on a read that withheld nothing, and a confirmed
1321
+ // fill never reads as delivered on a refunded/failed/expired intent.
1322
+ ...(d.legs_withheld === true
1323
+ ? {
1324
+ legsWithheld: true,
1325
+ ...(typeof d.settlement_confirmed === "boolean"
1326
+ ? {
1327
+ settlementConfirmed: (0, wire_js_1.withheldSettlementConfirmed)({
1328
+ status: d.status,
1329
+ legsWithheld: d.legs_withheld,
1330
+ settlementConfirmed: d.settlement_confirmed,
1331
+ }),
1332
+ }
1333
+ : {}),
1334
+ }
1335
+ : {}),
1336
+ };
1337
+ }
1338
+ catch (err) {
1339
+ log("warn", "[xochi-client] status poll failed (will retry):", safeErrorForLog(err));
1340
+ return null;
1341
+ }
1342
+ },
1343
+ async getIntentHistory(wallet, limit = 10) {
1344
+ const { response, body } = await send(`/api/intent/history?wallet=${encodeURIComponent(wallet)}&limit=${limit}`, { method: "GET" }, timeouts.requestTimeoutMs, readOkJson);
1345
+ if (!response.ok) {
1346
+ throw new Error(`Failed to get intent history for ${wallet}: HTTP ${response.status}`);
1347
+ }
1348
+ const data = body;
1349
+ return (data.intents ?? []).map(wire_js_1.mapIntentRecord);
1350
+ },
1351
+ prepareStealthClaim(body) {
1352
+ return sendOnce("/api/stealth/claim/prepare", body);
1353
+ },
1354
+ submitStealthClaim(body) {
1355
+ return sendOnce("/api/stealth/claim/submit", body);
1356
+ },
1357
+ };
1358
+ }
1359
+ /** The response, with its JSON body read when it is a success (the GET reads). */
1360
+ async function readOkJson(response) {
1361
+ if (!response.ok) {
1362
+ cancelBody(response);
1363
+ return { response, body: undefined };
1364
+ }
1365
+ return { response, body: (await response.json()) };
1366
+ }
1367
+ /**
1368
+ * `fetched` with its body replaced by `body`. The Response constructor rejects
1369
+ * reason phrases fetch itself accepts (non-ASCII, control bytes), so an
1370
+ * invalid one is dropped from the constructor and served off `fetched`
1371
+ * instead: re-wrapping must never turn a readable reply into a throw.
1372
+ */
1373
+ function rewrapResponse(fetched, body) {
1374
+ const { status, statusText, headers } = fetched;
1375
+ // RFC 9110 reason-phrase: HTAB / SP / VCHAR / obs-text.
1376
+ if (/^[\t\x20-\x7e\x80-\xff]*$/.test(statusText)) {
1377
+ return new Response(body, { status, statusText, headers });
1378
+ }
1379
+ const response = new Response(body, { status, headers });
1380
+ Object.defineProperty(response, "statusText", { value: statusText });
1381
+ return response;
1382
+ }
1383
+ /** Cancel a response body that will not be read, ignoring any failure. */
1384
+ function cancelBody(response) {
1385
+ try {
1386
+ response?.body?.cancel().catch(() => { });
1387
+ }
1388
+ catch {
1389
+ // A locked or already-consumed body: nothing left to release.
1390
+ }
1391
+ }
1392
+ /**
1393
+ * `promise`, or a rejection with the signal's `AbortError` once `signal`
1394
+ * aborts, whichever comes first. A body stream that an injected `fetch` never
1395
+ * ties to the signal is then still bounded by the attempt's timeout.
1396
+ */
1397
+ function untilAborted(promise, signal) {
1398
+ const { promise: aborted, reject } = Promise.withResolvers();
1399
+ const onAbort = () => reject(signal.reason);
1400
+ if (signal.aborted)
1401
+ onAbort();
1402
+ else
1403
+ signal.addEventListener("abort", onAbort, { once: true });
1404
+ // Neither loser may surface as an unhandled rejection.
1405
+ aborted.catch(() => { });
1406
+ promise.catch(() => { });
1407
+ return Promise.race([promise, aborted]).finally(() => signal.removeEventListener("abort", onAbort));
1408
+ }
1409
+ /**
1410
+ * A response body read as a JSON object, or undefined when it is not one. The
1411
+ * one place a body becomes a field bag: every read off it is then narrowed by
1412
+ * `typeof` at the use site.
1413
+ */
1414
+ function parseJsonObject(text) {
1415
+ if (!text)
1416
+ return undefined;
1417
+ let parsed;
1418
+ try {
1419
+ parsed = JSON.parse(text);
1420
+ }
1421
+ catch {
1422
+ return undefined;
1423
+ }
1424
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
1425
+ return undefined;
1426
+ // Validated just above as a non-array object; its fields stay `unknown`.
1427
+ return parsed;
1428
+ }
1429
+ /**
1430
+ * Deterministic quote cache key. The composed string is already unique per
1431
+ * distinct request. `deadline` is intentionally excluded (the solver overrides
1432
+ * it), but `trustScore` MUST be included -- it drives the fee tier -- and so
1433
+ * must the refund address: the intent is created at QUOTE time with whatever
1434
+ * refund address the request carried (xochi#358), and a cached quote bound to
1435
+ * none cannot be flagged by the deposit attestation, because it is genuinely
1436
+ * signed for the deal it describes. The refund address is not lowercased: on a
1437
+ * Tron origin it is base58, which is case-sensitive.
1438
+ */
1439
+ function hashQuoteRequest(request) {
1440
+ return [
1441
+ request.wallet?.toLowerCase() ?? "preview",
1442
+ request.preview ? "preview" : "execute",
1443
+ request.allocationKey ?? "",
1444
+ request.fromChainId,
1445
+ request.toChainId,
1446
+ request.fromToken.toLowerCase(),
1447
+ request.toToken.toLowerCase(),
1448
+ request.fromAmount,
1449
+ request.slippageBps,
1450
+ request.trustScore,
1451
+ request.settlementPreference,
1452
+ // Distinct recipients (non-EVM dest) are distinct quotes.
1453
+ (request.recipientAddress ?? "").toLowerCase(),
1454
+ request.refundAddress ?? "",
1455
+ ].join(":");
1456
+ }