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