@xochi/client 0.0.0-stage → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -0
- package/LICENSE +21 -0
- package/README.md +120 -2
- package/dist/cjs/claim.d.ts +33 -0
- package/dist/cjs/claim.js +44 -0
- package/dist/cjs/fee.d.ts +247 -0
- package/dist/cjs/fee.js +295 -0
- package/dist/cjs/index.d.ts +18 -0
- package/dist/cjs/index.js +33 -0
- package/dist/cjs/machine/claimInfo.d.ts +29 -0
- package/dist/cjs/machine/claimInfo.js +26 -0
- package/dist/cjs/machine/decisions.d.ts +452 -0
- package/dist/cjs/machine/decisions.js +665 -0
- package/dist/cjs/machine/deposit.d.ts +113 -0
- package/dist/cjs/machine/deposit.js +265 -0
- package/dist/cjs/machine/index.d.ts +23 -0
- package/dist/cjs/machine/index.js +53 -0
- package/dist/cjs/machine/machine.d.ts +411 -0
- package/dist/cjs/machine/machine.js +1627 -0
- package/dist/cjs/machine/phase.d.ts +24 -0
- package/dist/cjs/machine/phase.js +45 -0
- package/dist/cjs/machine/quoteChecks.d.ts +41 -0
- package/dist/cjs/machine/quoteChecks.js +82 -0
- package/dist/cjs/machine/receipt.d.ts +151 -0
- package/dist/cjs/machine/receipt.js +69 -0
- package/dist/cjs/machine/recipientEcho.d.ts +16 -0
- package/dist/cjs/machine/recipientEcho.js +27 -0
- package/dist/cjs/machine/solverPin.d.ts +110 -0
- package/dist/cjs/machine/solverPin.js +214 -0
- package/dist/cjs/machine/state.d.ts +167 -0
- package/dist/cjs/machine/state.js +31 -0
- package/dist/cjs/machine/transition.d.ts +15 -0
- package/dist/cjs/machine/transition.js +20 -0
- package/dist/cjs/machine/validateRequest.d.ts +15 -0
- package/dist/cjs/machine/validateRequest.js +127 -0
- package/dist/cjs/machine/view.d.ts +3 -0
- package/dist/cjs/machine/view.js +74 -0
- package/dist/cjs/machine/wire.d.ts +527 -0
- package/dist/cjs/machine/wire.js +280 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/shared/api.d.ts +9 -0
- package/dist/cjs/shared/api.js +5 -0
- package/dist/cjs/shared/chains.d.ts +243 -0
- package/dist/cjs/shared/chains.js +250 -0
- package/dist/cjs/shared/eip712.d.ts +44 -0
- package/dist/cjs/shared/eip712.js +64 -0
- package/dist/cjs/shared/index.d.ts +16 -0
- package/dist/cjs/shared/index.js +32 -0
- package/dist/cjs/shared/intentStream.d.ts +74 -0
- package/dist/cjs/shared/intentStream.js +59 -0
- package/dist/cjs/shared/intents.d.ts +88 -0
- package/dist/cjs/shared/intents.js +227 -0
- package/dist/cjs/shared/schemas.d.ts +928 -0
- package/dist/cjs/shared/schemas.js +524 -0
- package/dist/cjs/shared/shieldedPreview.d.ts +841 -0
- package/dist/cjs/shared/shieldedPreview.js +128 -0
- package/dist/cjs/shared/stealth.d.ts +53 -0
- package/dist/cjs/shared/stealth.js +68 -0
- package/dist/cjs/shared/trading.d.ts +153 -0
- package/dist/cjs/shared/trading.js +185 -0
- package/dist/cjs/shared/types.d.ts +37 -0
- package/dist/cjs/shared/types.js +7 -0
- package/dist/cjs/shared/validators.d.ts +30 -0
- package/dist/cjs/shared/validators.js +79 -0
- package/dist/cjs/transport/client.d.ts +234 -0
- package/dist/cjs/transport/client.js +1456 -0
- package/dist/esm/claim.d.ts +33 -0
- package/dist/esm/claim.js +41 -0
- package/dist/esm/fee.d.ts +247 -0
- package/dist/esm/fee.js +285 -0
- package/dist/esm/index.d.ts +18 -0
- package/dist/esm/index.js +17 -0
- package/dist/esm/machine/claimInfo.d.ts +29 -0
- package/dist/esm/machine/claimInfo.js +23 -0
- package/dist/esm/machine/decisions.d.ts +452 -0
- package/dist/esm/machine/decisions.js +640 -0
- package/dist/esm/machine/deposit.d.ts +113 -0
- package/dist/esm/machine/deposit.js +260 -0
- package/dist/esm/machine/index.d.ts +23 -0
- package/dist/esm/machine/index.js +23 -0
- package/dist/esm/machine/machine.d.ts +411 -0
- package/dist/esm/machine/machine.js +1621 -0
- package/dist/esm/machine/phase.d.ts +24 -0
- package/dist/esm/machine/phase.js +42 -0
- package/dist/esm/machine/quoteChecks.d.ts +41 -0
- package/dist/esm/machine/quoteChecks.js +78 -0
- package/dist/esm/machine/receipt.d.ts +151 -0
- package/dist/esm/machine/receipt.js +64 -0
- package/dist/esm/machine/recipientEcho.d.ts +16 -0
- package/dist/esm/machine/recipientEcho.js +24 -0
- package/dist/esm/machine/solverPin.d.ts +110 -0
- package/dist/esm/machine/solverPin.js +210 -0
- package/dist/esm/machine/state.d.ts +167 -0
- package/dist/esm/machine/state.js +28 -0
- package/dist/esm/machine/transition.d.ts +15 -0
- package/dist/esm/machine/transition.js +17 -0
- package/dist/esm/machine/validateRequest.d.ts +15 -0
- package/dist/esm/machine/validateRequest.js +123 -0
- package/dist/esm/machine/view.d.ts +3 -0
- package/dist/esm/machine/view.js +71 -0
- package/dist/esm/machine/wire.d.ts +527 -0
- package/dist/esm/machine/wire.js +266 -0
- package/dist/esm/shared/api.d.ts +9 -0
- package/dist/esm/shared/api.js +4 -0
- package/dist/esm/shared/chains.d.ts +243 -0
- package/dist/esm/shared/chains.js +236 -0
- package/dist/esm/shared/eip712.d.ts +44 -0
- package/dist/esm/shared/eip712.js +61 -0
- package/dist/esm/shared/index.d.ts +16 -0
- package/dist/esm/shared/index.js +16 -0
- package/dist/esm/shared/intentStream.d.ts +74 -0
- package/dist/esm/shared/intentStream.js +55 -0
- package/dist/esm/shared/intents.d.ts +88 -0
- package/dist/esm/shared/intents.js +213 -0
- package/dist/esm/shared/schemas.d.ts +928 -0
- package/dist/esm/shared/schemas.js +521 -0
- package/dist/esm/shared/shieldedPreview.d.ts +841 -0
- package/dist/esm/shared/shieldedPreview.js +124 -0
- package/dist/esm/shared/stealth.d.ts +53 -0
- package/dist/esm/shared/stealth.js +64 -0
- package/dist/esm/shared/trading.d.ts +153 -0
- package/dist/esm/shared/trading.js +182 -0
- package/dist/esm/shared/types.d.ts +37 -0
- package/dist/esm/shared/types.js +6 -0
- package/dist/esm/shared/validators.d.ts +30 -0
- package/dist/esm/shared/validators.js +74 -0
- package/dist/esm/transport/client.d.ts +234 -0
- package/dist/esm/transport/client.js +1447 -0
- 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
|
+
}
|