@xochi/client 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,128 @@
1
+ "use strict";
2
+ /**
3
+ * Riddler's shielded terms preview wire (`POST /xochi/shielded/preview`,
4
+ * Riddler#1296): the typed request and response shapes and a decoder.
5
+ *
6
+ * There is NO transport method: the preview needs a `quote`-scoped Riddler
7
+ * credential, so only the Xochi Worker calls it, and the Worker proxy route is
8
+ * a follow-up owned by xochi#635. These shapes exist so that proxy and its
9
+ * consumers decode the solver's answer with the same schema the solver
10
+ * publishes (`packages/spec/wire/openapi.json`, `ShieldedPreviewResponse`).
11
+ *
12
+ * The preview is NOT a signing payload: `preview_digest` is the EIP-712 digest
13
+ * of `ShieldedPreviewV1` under the `Xochi Shielded Preview` domain, which no
14
+ * `ShieldedTermsV1` signature verifies against (`@xochi/spec`
15
+ * `conformance/shielded-terms.json`, PROPOSED). `executable` is always false.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.ShieldedPreviewResponseSchema = exports.ShieldedPreviewSchema = exports.ShieldedFeeBreakdownSchema = exports.ShieldedCorridorSchema = exports.ShieldedNetworkIdentitySchema = exports.aztecValue = void 0;
19
+ exports.decodeShieldedPreview = decodeShieldedPreview;
20
+ const zod_1 = require("zod");
21
+ const schemas_js_1 = require("./schemas.js");
22
+ /** 32-byte hex (`0x` + 64). */
23
+ const bytes32 = zod_1.z.string().regex(/^0x[0-9a-fA-F]{64}$/);
24
+ /**
25
+ * A 32-byte Aztec address or field element. The decoder checks the shape only;
26
+ * the solver has already refused zero and values at or above the BN254 modulus.
27
+ * A 20-byte EVM address never fits.
28
+ */
29
+ exports.aztecValue = bytes32;
30
+ /** Canonical unsigned decimal string (uint256 members). */
31
+ const uintString = zod_1.z.string().regex(/^(0|[1-9][0-9]*)$/);
32
+ /** Canonical positive base-unit amount. */
33
+ const baseUnits = zod_1.z.string().regex(/^[1-9][0-9]*$/);
34
+ exports.ShieldedNetworkIdentitySchema = zod_1.z.object({
35
+ aztec_chain_id: zod_1.z.string().regex(/^[1-9][0-9]*$/),
36
+ protocol_version: zod_1.z.string().regex(/^[1-9][0-9]*$/),
37
+ rollup_address: schemas_js_1.ethAddress,
38
+ l1_chain_id: zod_1.z.string().regex(/^[1-9][0-9]*$/),
39
+ genesis_or_network_digest: bytes32,
40
+ });
41
+ exports.ShieldedCorridorSchema = zod_1.z.object({
42
+ corridor_id: bytes32,
43
+ revision: zod_1.z.number().int().min(1).max(4294967295),
44
+ manifest_digest: bytes32,
45
+ });
46
+ exports.ShieldedFeeBreakdownSchema = zod_1.z.object({
47
+ gas_floor: uintString,
48
+ solver_fee_bps: zod_1.z.number().int().min(0).max(10000),
49
+ solver_fee: uintString,
50
+ price_impact_bps: zod_1.z.number().int().min(0).max(10000),
51
+ price_impact: uintString,
52
+ venue_fee_bps: zod_1.z.number().int().min(0).max(10000),
53
+ venue_fee: uintString,
54
+ routing_fee_bps: zod_1.z.number().int().min(0).max(10000),
55
+ routing_fee: uintString,
56
+ total_bps: zod_1.z.number().int().min(0).max(40000),
57
+ total_fee: uintString,
58
+ });
59
+ /** `ShieldedPreviewV1` in snake_case: the terms minus the per-execution members. */
60
+ exports.ShieldedPreviewSchema = zod_1.z.object({
61
+ protocol_version: zod_1.z.literal("xochi.shielded.preview.v1"),
62
+ environment: zod_1.z.string().min(1),
63
+ source: zod_1.z.object({
64
+ chain_id: zod_1.z.number().int().positive(),
65
+ token_address: schemas_js_1.ethAddress,
66
+ wallet: schemas_js_1.ethAddress,
67
+ collector: schemas_js_1.ethAddress,
68
+ amount: baseUnits,
69
+ }),
70
+ destination: zod_1.z.object({
71
+ settlement_type: zod_1.z.literal("shielded"),
72
+ privacy_level: zod_1.z.literal("private"),
73
+ disclosure_profile: zod_1.z.literal("recipient-private-public-amount-v1"),
74
+ rail: zod_1.z.literal("a_tracer"),
75
+ network: exports.ShieldedNetworkIdentitySchema,
76
+ corridor: exports.ShieldedCorridorSchema,
77
+ asset_id: zod_1.z.string().min(1),
78
+ token_address: exports.aztecValue,
79
+ recipient_binding_id: zod_1.z.string().min(1),
80
+ recipient_descriptor_digest: bytes32,
81
+ recipient_address: exports.aztecValue,
82
+ amount: baseUnits,
83
+ fee_payer: exports.aztecValue,
84
+ }),
85
+ fee_breakdown: exports.ShieldedFeeBreakdownSchema,
86
+ refund_address: schemas_js_1.ethAddress,
87
+ refund_amount: baseUnits,
88
+ collect_before: zod_1.z.number().int().nonnegative(),
89
+ settlement_service_deadline: zod_1.z.number().int().nonnegative(),
90
+ recipient_binding_digest: bytes32,
91
+ policy_revision: zod_1.z.number().int().nonnegative(),
92
+ });
93
+ exports.ShieldedPreviewResponseSchema = zod_1.z.object({
94
+ executable: zod_1.z.literal(false),
95
+ preview_version: zod_1.z.literal("xochi.shielded.preview.v1"),
96
+ preview_digest: bytes32,
97
+ recipient_binding_digest: bytes32,
98
+ domain: zod_1.z.object({
99
+ name: zod_1.z.literal("Xochi Shielded Preview"),
100
+ version: zod_1.z.literal("1"),
101
+ chain_id: zod_1.z.number().int().positive(),
102
+ salt: bytes32,
103
+ }),
104
+ preview: exports.ShieldedPreviewSchema,
105
+ manifest: zod_1.z.object({
106
+ manifest_digest: bytes32,
107
+ aztec_sdk_version: zod_1.z.string().min(1),
108
+ recipient_account_class_id: exports.aztecValue,
109
+ }),
110
+ });
111
+ /**
112
+ * Decode a preview response. Throws (zod) on any shape the solver does not
113
+ * publish, including an executable or terms-shaped answer, and on a fee
114
+ * breakdown whose totals disagree: `total_fee` must be `max(gas_floor, layers)` (the gas
115
+ * floor is a minimum fee, as in FeePolicy) and `total_bps` the four rates.
116
+ */
117
+ function decodeShieldedPreview(body) {
118
+ const decoded = exports.ShieldedPreviewResponseSchema.parse(body);
119
+ const f = decoded.preview.fee_breakdown;
120
+ const layers = BigInt(f.solver_fee) + BigInt(f.price_impact) + BigInt(f.venue_fee) + BigInt(f.routing_fee);
121
+ const gasFloor = BigInt(f.gas_floor);
122
+ const totalFee = layers > gasFloor ? layers : gasFloor;
123
+ const totalBps = f.solver_fee_bps + f.price_impact_bps + f.venue_fee_bps + f.routing_fee_bps;
124
+ if (BigInt(f.total_fee) !== totalFee || f.total_bps !== totalBps) {
125
+ throw new Error("shielded preview fee_breakdown totals disagree with max(gas_floor, layers)");
126
+ }
127
+ return decoded;
128
+ }
@@ -38,7 +38,7 @@ export interface RetryPolicy {
38
38
  maxAttempts: number;
39
39
  /** First exponential backoff step. */
40
40
  baseDelayMs: number;
41
- /** Clamp on a server-supplied `Retry-After` used for internal pacing. */
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. Returns null when absent/unparseable so the caller falls back to its
123
- * own exponential backoff.
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 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;
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. Returns null when absent/unparseable so the caller falls back to its
651
- * own exponential backoff.
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.DEFAULT_RETRY.maxDelayMs) {
654
- if (!headerValue)
655
- return null;
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 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
- }
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"), retry.maxDelayMs);
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 && Date.now() - pending.timestamp < timeouts.requestTimeoutMs) {
1179
+ if (pending) {
1176
1180
  log("debug", "[xochi-client] Deduplicating quote request");
1177
- return pending.promise;
1181
+ return pending;
1178
1182
  }
1179
1183
  const promise = fetchQuote(request);
1180
- pendingQuotes.set(requestHash, { promise, timestamp: Date.now() });
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.delete(requestHash);
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;
@@ -219,6 +221,8 @@ export type MsgBody = {
219
221
  type: "SignRequested";
220
222
  } | {
221
223
  type: "PullSignRequested";
224
+ } | {
225
+ type: "CancelSignRequested";
222
226
  } | {
223
227
  type: "ResetRequested";
224
228
  } | {
@@ -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,
@@ -156,6 +156,10 @@ function step(m, msg, fx) {
156
156
  return m.amount ? startQuote(m, fx, msg.now, quoteOpts(m.amount)) : m;
157
157
  case "SignRequested":
158
158
  return beginSign(m, fx, msg.now);
159
+ case "CancelSignRequested":
160
+ if (m.intent.status !== "signing" || !m.sign)
161
+ return m;
162
+ return transition(abandonSign(m, fx), fx, "quoted", { error: "Signature cancelled" });
159
163
  case "PullSignRequested":
160
164
  return beginPullSign(m, fx, msg.now);
161
165
  case "ResetRequested":
@@ -216,10 +220,15 @@ function step(m, msg, fx) {
216
220
  // Mobile throttles timers while backgrounded (a wallet deep-link
217
221
  // round-trip), so snap the in-flight phase forward rather than wait out
218
222
  // the interval. Off-interval: it spends no give-up budget. A pending
219
- // 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.
220
226
  if (awaitingResend(next))
221
227
  next = resendBackstop(next, fx, msg.now);
222
- else if (next.poll && !next.poll.stopped)
228
+ else if (next.poll?.stopped) {
229
+ next = { ...patch(next, { pollExhausted: false }), poll: null };
230
+ }
231
+ else if (next.poll)
223
232
  next = pollTick(next, fx, false);
224
233
  return next;
225
234
  }
@@ -717,8 +726,9 @@ function onTimer(m, fx, timer, now) {
717
726
  return resendBackstop(m, fx, now);
718
727
  return m.poll && !m.poll.stopped ? pollTick(m, fx, true) : m;
719
728
  case "signPrompt":
720
- // A deep-link prompt that never re-opened the wallet.
721
- return m.sign?.stage === "pull"
729
+ // A wallet prompt that never resolved. The flow still owns the prompt;
730
+ // `signFlowFailed` makes any reply arriving after this timer inert.
731
+ return m.sign
722
732
  ? signFlowFailed(m, fx, {
723
733
  rejected: false,
724
734
  value: "wallet_prompt_timeout",
@@ -826,6 +836,10 @@ function beginSign(m, fx, now) {
826
836
  chainId: next.ctx.wallet.chainId,
827
837
  },
828
838
  });
839
+ // A wallet request can be dismissed without rejecting (mobile deep links in
840
+ // particular). Bound every prompt, including signature 1, so the ceremony
841
+ // always returns to an actionable surface.
842
+ setTimer(fx, "signPrompt", SIGN_PROMPT_TIMEOUT_MS, false);
829
843
  return { ...next, sign: { id, origin: "sign", stage: "intent", intentSignature: null } };
830
844
  }
831
845
  function beginPullSign(m, fx, now) {
@@ -926,8 +940,7 @@ function onSigned(m, fx, flowId, purpose, result, now) {
926
940
  // open; its signature then belongs to no ceremony.
927
941
  if (m.intent.status !== "signing")
928
942
  return abandonSign(m, fx);
929
- if (purpose === "pull")
930
- clearTimer(fx, "signPrompt");
943
+ clearTimer(fx, "signPrompt");
931
944
  if (!result.ok)
932
945
  return signFlowFailed(m, fx, result.error);
933
946
  if (purpose === "pull") {
@@ -1210,7 +1223,7 @@ function endSignFlow(m, flow) {
1210
1223
  * affordance, no prompt timer. Nothing signed so far can execute after this.
1211
1224
  */
1212
1225
  function abandonSign(m, fx) {
1213
- if (m.sign?.stage === "pull")
1226
+ if (m.sign)
1214
1227
  clearTimer(fx, "signPrompt");
1215
1228
  if (awaitingResend(m))
1216
1229
  clearTimer(fx, "execute");
@@ -1226,12 +1239,16 @@ function signFlowFailed(m, fx, report) {
1226
1239
  const flow = m.sign;
1227
1240
  if (!flow)
1228
1241
  return m;
1229
- if (flow.stage === "pull")
1230
- clearTimer(fx, "signPrompt");
1242
+ clearTimer(fx, "signPrompt");
1231
1243
  const next = endSignFlow(m, flow);
1232
1244
  if (flow.origin === "sign") {
1233
1245
  if (report.rejected)
1234
1246
  return transition(next, fx, "quoted", { error: "Signature rejected" });
1247
+ if (report.value === "wallet_prompt_timeout") {
1248
+ return transition(next, fx, "quoted", {
1249
+ error: "Couldn't reach the wallet. Press Confirm to try again.",
1250
+ });
1251
+ }
1235
1252
  log(fx, "error", `${LOG} Sign error:`, report.detail);
1236
1253
  return transition(next, fx, "failed", { error: report.value ?? "Signing failed" });
1237
1254
  }
@@ -1300,15 +1317,20 @@ function onStatus(m, fx, gen, attempt, result, now) {
1300
1317
  if (!poll || poll.gen !== gen || poll.stopped)
1301
1318
  return m;
1302
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;
1303
1325
  let next = m;
1304
- // A resumed `executing` intent (a reload: no quote in this tab, so no sign
1305
- // flow and no execute from it can be in flight or answer late) that reads
1306
- // `quoted`: the Worker's row is unclaimed and the signed request, held
1307
- // elsewhere, executes until its deadline. Not sent yet, as after a give-up,
1308
- // never "Still confirming" (whose Close invites a second signature). An
1309
- // intent in doubt stays so, and so does one a newer reply already answered:
1310
- // a late `quoted` is no answer after a newer `executing`.
1311
- const resumedUnsent = attempt >= poll.answered &&
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 &&
1312
1334
  result.ok &&
1313
1335
  !!result.status &&
1314
1336
  !("notFound" in result.status) &&
@@ -1316,14 +1338,14 @@ function onStatus(m, fx, gen, attempt, result, now) {
1316
1338
  result.status.unmapped !== true &&
1317
1339
  next.intent.intentId === id &&
1318
1340
  next.intent.status === "executing" &&
1319
- next.intent.quote === null &&
1341
+ next.sign === null &&
1320
1342
  !next.intent.reconciling;
1321
1343
  // A throw answers nothing, so only a reply (a 404 included) moves it.
1322
1344
  const answered = result.ok
1323
1345
  ? { ...poll, answered: Math.max(poll.answered, attempt) }
1324
1346
  : poll;
1325
- if (resumedUnsent && !next.intent.notSent) {
1326
- log(fx, "warn", `${LOG} Resumed intent ${id} reads quoted: not sent yet`);
1347
+ if (unsentRead && !next.intent.notSent) {
1348
+ log(fx, "warn", `${LOG} Intent ${id} reads quoted after execute: not sent yet`);
1327
1349
  next = patch(next, { notSent: true });
1328
1350
  }
1329
1351
  // A slow execute escalates to "Still confirming"; one not sent (`notSent`)
@@ -1405,15 +1427,15 @@ function onStatus(m, fx, gen, attempt, result, now) {
1405
1427
  next = patch(next, { legsWithheld });
1406
1428
  }
1407
1429
  // The server owns the active-polling decision; a phase change refills the
1408
- // give-up budget, so the ceiling bounds a swap that is not moving. A read
1409
- // the intent cannot move to (`quoted` after an execute) is no change: the
1410
- // ceiling, sized past the Worker's finish (`pollCeilingAttempts`), holds.
1411
- // A resumed not-sent intent has no quote to size it by, so each `quoted`
1412
- // read refills it: the Worker's read finish ends the row past its
1413
- // deadline, which this poll's own reads trigger.
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;
1414
1436
  verdict = s.terminal
1415
1437
  ? "stop"
1416
- : next.intent.status !== previous || (resumedUnsent && next.intent.notSent)
1438
+ : next.intent.status !== previous || resumedNotSent
1417
1439
  ? "progress"
1418
1440
  : undefined;
1419
1441
  }
@@ -1581,7 +1603,11 @@ function armExpiry(m, fx, now) {
1581
1603
  function armPoll(m, fx, now) {
1582
1604
  const { intentId, status } = m.intent;
1583
1605
  const waiting = awaitingResend(m);
1584
- const key = POLL_INFLIGHT_STATES.includes(status) && intentId
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
1585
1611
  ? `${intentId}:${status}${waiting ? ":resend" : ""}`
1586
1612
  : null;
1587
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". The host offers wallet SIWE
70
- * recovery on it; a later owner read (no marker) clears it. Never a token.
69
+ * null hashes mean "not shown", not "not there". False after an owner read.
70
+ * Null until the first status reply, so the host does not render an owner
71
+ * verdict before it has read one. Never a token.
71
72
  */
72
- legsWithheld: boolean;
73
+ legsWithheld: boolean | null;
73
74
  reconciling: boolean;
74
75
  /** Plain-language refund explanation; set only on a `refunded` status. */
75
76
  refundReason: string | null;
@@ -12,7 +12,7 @@ export const INITIAL_INTENT_STATE = {
12
12
  settlementTxHash: null,
13
13
  settlementChainId: null,
14
14
  substatus: null,
15
- legsWithheld: false,
15
+ legsWithheld: null,
16
16
  reconciling: false,
17
17
  refundReason: null,
18
18
  quotedAddress: null,
@@ -13,3 +13,4 @@ export * from "./validators.js";
13
13
  export * from "./schemas.js";
14
14
  export * from "./api.js";
15
15
  export * from "./intentStream.js";
16
+ export * from "./shieldedPreview.js";
@@ -13,3 +13,4 @@ export * from "./validators.js";
13
13
  export * from "./schemas.js";
14
14
  export * from "./api.js";
15
15
  export * from "./intentStream.js";
16
+ export * from "./shieldedPreview.js";
@@ -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 the unsolvable-200 branch: `error_category` is a stable machine
392
- // key for the reject class, `detail` the solver's raw reason. Absent on
393
- // solvable quotes.
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