@xochi/client 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/LICENSE +21 -0
  3. package/README.md +119 -2
  4. package/dist/cjs/claim.d.ts +33 -0
  5. package/dist/cjs/claim.js +44 -0
  6. package/dist/cjs/fee.d.ts +247 -0
  7. package/dist/cjs/fee.js +295 -0
  8. package/dist/cjs/index.d.ts +18 -0
  9. package/dist/cjs/index.js +33 -0
  10. package/dist/cjs/machine/claimInfo.d.ts +29 -0
  11. package/dist/cjs/machine/claimInfo.js +26 -0
  12. package/dist/cjs/machine/decisions.d.ts +452 -0
  13. package/dist/cjs/machine/decisions.js +665 -0
  14. package/dist/cjs/machine/deposit.d.ts +113 -0
  15. package/dist/cjs/machine/deposit.js +265 -0
  16. package/dist/cjs/machine/index.d.ts +23 -0
  17. package/dist/cjs/machine/index.js +53 -0
  18. package/dist/cjs/machine/machine.d.ts +409 -0
  19. package/dist/cjs/machine/machine.js +1615 -0
  20. package/dist/cjs/machine/phase.d.ts +24 -0
  21. package/dist/cjs/machine/phase.js +45 -0
  22. package/dist/cjs/machine/quoteChecks.d.ts +41 -0
  23. package/dist/cjs/machine/quoteChecks.js +82 -0
  24. package/dist/cjs/machine/receipt.d.ts +151 -0
  25. package/dist/cjs/machine/receipt.js +69 -0
  26. package/dist/cjs/machine/recipientEcho.d.ts +16 -0
  27. package/dist/cjs/machine/recipientEcho.js +27 -0
  28. package/dist/cjs/machine/solverPin.d.ts +110 -0
  29. package/dist/cjs/machine/solverPin.js +214 -0
  30. package/dist/cjs/machine/state.d.ts +167 -0
  31. package/dist/cjs/machine/state.js +31 -0
  32. package/dist/cjs/machine/transition.d.ts +15 -0
  33. package/dist/cjs/machine/transition.js +20 -0
  34. package/dist/cjs/machine/validateRequest.d.ts +15 -0
  35. package/dist/cjs/machine/validateRequest.js +127 -0
  36. package/dist/cjs/machine/view.d.ts +3 -0
  37. package/dist/cjs/machine/view.js +74 -0
  38. package/dist/cjs/machine/wire.d.ts +527 -0
  39. package/dist/cjs/machine/wire.js +280 -0
  40. package/dist/cjs/package.json +1 -0
  41. package/dist/cjs/shared/api.d.ts +9 -0
  42. package/dist/cjs/shared/api.js +5 -0
  43. package/dist/cjs/shared/chains.d.ts +243 -0
  44. package/dist/cjs/shared/chains.js +250 -0
  45. package/dist/cjs/shared/eip712.d.ts +44 -0
  46. package/dist/cjs/shared/eip712.js +64 -0
  47. package/dist/cjs/shared/index.d.ts +15 -0
  48. package/dist/cjs/shared/index.js +31 -0
  49. package/dist/cjs/shared/intentStream.d.ts +74 -0
  50. package/dist/cjs/shared/intentStream.js +59 -0
  51. package/dist/cjs/shared/intents.d.ts +88 -0
  52. package/dist/cjs/shared/intents.js +227 -0
  53. package/dist/cjs/shared/schemas.d.ts +928 -0
  54. package/dist/cjs/shared/schemas.js +524 -0
  55. package/dist/cjs/shared/stealth.d.ts +53 -0
  56. package/dist/cjs/shared/stealth.js +68 -0
  57. package/dist/cjs/shared/trading.d.ts +153 -0
  58. package/dist/cjs/shared/trading.js +185 -0
  59. package/dist/cjs/shared/types.d.ts +37 -0
  60. package/dist/cjs/shared/types.js +7 -0
  61. package/dist/cjs/shared/validators.d.ts +30 -0
  62. package/dist/cjs/shared/validators.js +79 -0
  63. package/dist/cjs/transport/client.d.ts +234 -0
  64. package/dist/cjs/transport/client.js +1456 -0
  65. package/dist/esm/claim.d.ts +33 -0
  66. package/dist/esm/claim.js +41 -0
  67. package/dist/esm/fee.d.ts +247 -0
  68. package/dist/esm/fee.js +285 -0
  69. package/dist/esm/index.d.ts +18 -0
  70. package/dist/esm/index.js +17 -0
  71. package/dist/esm/machine/claimInfo.d.ts +29 -0
  72. package/dist/esm/machine/claimInfo.js +23 -0
  73. package/dist/esm/machine/decisions.d.ts +452 -0
  74. package/dist/esm/machine/decisions.js +640 -0
  75. package/dist/esm/machine/deposit.d.ts +113 -0
  76. package/dist/esm/machine/deposit.js +260 -0
  77. package/dist/esm/machine/index.d.ts +23 -0
  78. package/dist/esm/machine/index.js +23 -0
  79. package/dist/esm/machine/machine.d.ts +409 -0
  80. package/dist/esm/machine/machine.js +1609 -0
  81. package/dist/esm/machine/phase.d.ts +24 -0
  82. package/dist/esm/machine/phase.js +42 -0
  83. package/dist/esm/machine/quoteChecks.d.ts +41 -0
  84. package/dist/esm/machine/quoteChecks.js +78 -0
  85. package/dist/esm/machine/receipt.d.ts +151 -0
  86. package/dist/esm/machine/receipt.js +64 -0
  87. package/dist/esm/machine/recipientEcho.d.ts +16 -0
  88. package/dist/esm/machine/recipientEcho.js +24 -0
  89. package/dist/esm/machine/solverPin.d.ts +110 -0
  90. package/dist/esm/machine/solverPin.js +210 -0
  91. package/dist/esm/machine/state.d.ts +167 -0
  92. package/dist/esm/machine/state.js +28 -0
  93. package/dist/esm/machine/transition.d.ts +15 -0
  94. package/dist/esm/machine/transition.js +17 -0
  95. package/dist/esm/machine/validateRequest.d.ts +15 -0
  96. package/dist/esm/machine/validateRequest.js +123 -0
  97. package/dist/esm/machine/view.d.ts +3 -0
  98. package/dist/esm/machine/view.js +71 -0
  99. package/dist/esm/machine/wire.d.ts +527 -0
  100. package/dist/esm/machine/wire.js +266 -0
  101. package/dist/esm/shared/api.d.ts +9 -0
  102. package/dist/esm/shared/api.js +4 -0
  103. package/dist/esm/shared/chains.d.ts +243 -0
  104. package/dist/esm/shared/chains.js +236 -0
  105. package/dist/esm/shared/eip712.d.ts +44 -0
  106. package/dist/esm/shared/eip712.js +61 -0
  107. package/dist/esm/shared/index.d.ts +15 -0
  108. package/dist/esm/shared/index.js +15 -0
  109. package/dist/esm/shared/intentStream.d.ts +74 -0
  110. package/dist/esm/shared/intentStream.js +55 -0
  111. package/dist/esm/shared/intents.d.ts +88 -0
  112. package/dist/esm/shared/intents.js +213 -0
  113. package/dist/esm/shared/schemas.d.ts +928 -0
  114. package/dist/esm/shared/schemas.js +521 -0
  115. package/dist/esm/shared/stealth.d.ts +53 -0
  116. package/dist/esm/shared/stealth.js +64 -0
  117. package/dist/esm/shared/trading.d.ts +153 -0
  118. package/dist/esm/shared/trading.js +182 -0
  119. package/dist/esm/shared/types.d.ts +37 -0
  120. package/dist/esm/shared/types.js +6 -0
  121. package/dist/esm/shared/validators.d.ts +30 -0
  122. package/dist/esm/shared/validators.js +74 -0
  123. package/dist/esm/transport/client.d.ts +234 -0
  124. package/dist/esm/transport/client.js +1447 -0
  125. package/package.json +61 -4
@@ -0,0 +1,1609 @@
1
+ /**
2
+ * The quote -> sign -> execute -> status lifecycle as a pure state machine.
3
+ *
4
+ * update(model, msg) -> [model, effects]
5
+ *
6
+ * `update` is the only place lifecycle state changes. Everything it cannot do
7
+ * itself -- an HTTP call, a wallet prompt, a timer, a log line -- it asks for as
8
+ * an `Effect`, which is plain JSON. A host (the React interpreter in the widget,
9
+ * or a headless integrator) performs the effect and feeds the outcome back as a
10
+ * `Msg`. Every `Msg` is stamped with the clock and tab visibility it was
11
+ * observed under, so a recorded msg log replays to the same models, effects and
12
+ * receipts with no mocks.
13
+ *
14
+ * The one exception to that clock is the fund-safety checks from
15
+ * `@xochi/signer` that run at quote ingest: they bound the intent deadline and
16
+ * the deposit window against the WALL clock, and take no `now` argument by
17
+ * design, since an injectable clock on a security assertion is a bypass.
18
+ *
19
+ * No DOM, no fetch, no React, no wagmi, no console: the machine compiles
20
+ * against `lib: ["ES2020"]` alone (tsconfig.machine.json).
21
+ */
22
+ import { canTransitionTo, EXECUTE_RESEND_MARGIN_SECS, isChainId, MAX_EXECUTE_RESENDS, } from "../shared/index.js";
23
+ import { buildQuoteRequest, buildResumedState, canSignQuote, computeRefreshDelayMs, executeDeadline, decideExecuteResend, decideExecuteResult, decideSignPreflight, failureMayHaveMoved, mapStatusUpdate, mergeSameStatusReceipt, notFoundDriftWarning, pollCeilingAttempts, normalizeTradeFields, quoteAccessDecision, shouldRefreshOnReturn, shouldRefreshQuote, shouldUpgradePreviewQuote, POLL_INFLIGHT_STATES, ALLOCATABLE_STATES, ENDED_STATES, unexecutedFinishAt, QUOTE_BUSY_STATES, STATUS_POLL_FLAT_CEILING_MS, STATUS_POLL_MS, WALLET_INTERRUPTIBLE_STATES, } from "./decisions.js";
24
+ import { claimInfoError } from "./claimInfo.js";
25
+ import { DEPOSIT_MIN_WINDOW_SECONDS } from "@xochi/signer";
26
+ import { checkDepositQuote, recipientIsDestinationToken, } from "./deposit.js";
27
+ import { checkSignableQuote } from "./quoteChecks.js";
28
+ import { recipientEchoError } from "./recipientEcho.js";
29
+ import { validatePayoutPin, validateSolverPin } from "./solverPin.js";
30
+ import { INITIAL_INTENT_STATE } from "./state.js";
31
+ import { applyStatusTransition } from "./transition.js";
32
+ import { validateSwapParams } from "./validateRequest.js";
33
+ import { decodeWireStatus, withheldSettlementConfirmed } from "./wire.js";
34
+ // ============================================================
35
+ // Configuration
36
+ // ============================================================
37
+ /** Amount edits settle for this long before a quote is requested. */
38
+ export const QUOTE_DEBOUNCE_MS = 500;
39
+ /**
40
+ * Refresh this long before expiry. Exported so the panel countdown pill can
41
+ * align "New price in Ns" with the actual refresh instant.
42
+ */
43
+ export const QUOTE_REFRESH_BUFFER_MS = 5000;
44
+ /**
45
+ * After this many polls in one phase without reaching a terminal state, flip the
46
+ * modal from an indefinite "Submitting" spinner to the reassuring "Still
47
+ * confirming" (safe-to-close) view. Reconciliation/webhooks still resolve it;
48
+ * this only fixes the feedback gap. ~90s at the shared cadence.
49
+ */
50
+ export const RECONCILE_AFTER_ATTEMPTS = Math.ceil(90_000 / STATUS_POLL_MS);
51
+ /**
52
+ * If a wallet signature prompt never resolves (a mobile deep-link that failed to
53
+ * re-open the wallet), stop waiting after this and surface a retry. Long enough
54
+ * for a real human to review + sign, short enough to recover from a dead prompt.
55
+ */
56
+ export const SIGN_PROMPT_TIMEOUT_MS = 90_000;
57
+ /** Quote requests ask for a one-hour signing deadline. */
58
+ const QUOTE_DEADLINE_SECS = 3600;
59
+ /** Log prefix. The host hook is still `useIntent`, and its logs are grepped for it. */
60
+ const LOG = "[useIntent]";
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.";
63
+ export function init(config, ctx, hidden = false) {
64
+ return {
65
+ config,
66
+ ctx,
67
+ hidden,
68
+ intent: INITIAL_INTENT_STATE,
69
+ amount: "",
70
+ allocationKey: null,
71
+ awaitingPull: false,
72
+ isSigningPull: false,
73
+ pendingIntentSig: null,
74
+ quoteExpiresIn: null,
75
+ submittedAt: null,
76
+ clientFillMs: null,
77
+ notFoundReported: null,
78
+ seq: 0,
79
+ flights: [],
80
+ sign: null,
81
+ poll: null,
82
+ refresh: null,
83
+ quotedTrade: null,
84
+ seenDepositAddresses: {},
85
+ watched: { wallet: "", upgrade: "", expiry: null },
86
+ };
87
+ }
88
+ // ============================================================
89
+ // update
90
+ // ============================================================
91
+ export function update(model, msg) {
92
+ const fx = [];
93
+ const stamped = model.hidden === msg.hidden ? model : { ...model, hidden: msg.hidden };
94
+ const stepped = step(stamped, msg, fx);
95
+ return [settle(stepped, msg.now, fx), fx];
96
+ }
97
+ function step(m, msg, fx) {
98
+ switch (msg.type) {
99
+ case "ContextChanged": {
100
+ const next = { ...m, ctx: msg.ctx };
101
+ if (m.flights.length === 0 || tradeKey(next.ctx) === tradeKey(m.ctx))
102
+ return next;
103
+ // The quotes in the air were built from the old trade; their answers
104
+ // describe a deal no longer on the form, so none of them may land.
105
+ if (m.intent.status === "quoting" && m.amount) {
106
+ // A visible quote was loading: ask again for the trade now on the form.
107
+ setTimer(fx, "debounce", QUOTE_DEBOUNCE_MS, false);
108
+ }
109
+ return supersedeFlights(next, fx);
110
+ }
111
+ case "QuoteRequested": {
112
+ clearTimer(fx, "debounce");
113
+ clearTimer(fx, "refresh");
114
+ // A quote still in the air for another amount answers a question nobody
115
+ // is asking any more; left in place it could land over the newer one.
116
+ const kept = msg.amount === m.amount ? m : supersedeFlights(m, fx);
117
+ const next = { ...kept, allocationKey: null, amount: msg.amount, refresh: null };
118
+ if (!msg.amount || msg.amount === "0") {
119
+ // "May have moved" holds until Reset, and "not sent yet" until the
120
+ // Worker ends the intent: clearing the amount is no way out of either.
121
+ if ((m.intent.status === "failed" && m.intent.mayHaveMoved) || m.intent.notSent) {
122
+ return next;
123
+ }
124
+ // Including any half-finished two-step signing: the next quote must not
125
+ // inherit "Sign 2 of 2" or a held intent signature.
126
+ return abandonSign({ ...next, intent: INITIAL_INTENT_STATE }, fx);
127
+ }
128
+ setTimer(fx, "debounce", QUOTE_DEBOUNCE_MS, false);
129
+ return next;
130
+ }
131
+ case "AllocateRequested": {
132
+ // Only before execution: past quoting (a prompt open, a deposit awaited,
133
+ // a swap in flight) or once the trade has ended (completed, refunded,
134
+ // failed) a new quote has nothing to land on, and its failure would
135
+ // patch the finished trade back to `quoted` over its consumed route.
136
+ if (!m.amount ||
137
+ isChainId(m.ctx.fromChainId) ||
138
+ !m.ctx.wallet.address ||
139
+ !ALLOCATABLE_STATES.includes(m.intent.status)) {
140
+ emit(fx, { kind: "allocation", ref: msg.ref, ok: false });
141
+ return m;
142
+ }
143
+ const key = m.allocationKey ?? msg.key;
144
+ return startQuote({ ...m, allocationKey: key }, fx, msg.now, {
145
+ amount: m.amount,
146
+ silent: false,
147
+ forceRefresh: true,
148
+ allocate: { key, ref: msg.ref },
149
+ });
150
+ }
151
+ case "RefreshRequested":
152
+ if (QUOTE_BUSY_STATES.includes(m.intent.status)) {
153
+ log(fx, "debug", `${LOG} Ignoring quote request during`, m.intent.status);
154
+ return m;
155
+ }
156
+ return m.amount ? startQuote(m, fx, msg.now, quoteOpts(m.amount)) : m;
157
+ case "SignRequested":
158
+ return beginSign(m, fx, msg.now);
159
+ case "PullSignRequested":
160
+ return beginPullSign(m, fx, msg.now);
161
+ case "ResetRequested":
162
+ clearTimer(fx, "debounce");
163
+ clearTimer(fx, "refresh");
164
+ clearTimer(fx, "signPrompt");
165
+ if (awaitingResend(m))
166
+ clearTimer(fx, "execute");
167
+ fx.push({ type: "Wallet", call: { op: "resetApproval" } });
168
+ return {
169
+ ...supersedeFlights(m, fx),
170
+ intent: INITIAL_INTENT_STATE,
171
+ amount: "",
172
+ submittedAt: null,
173
+ clientFillMs: null,
174
+ awaitingPull: false,
175
+ isSigningPull: false,
176
+ pendingIntentSig: null,
177
+ allocationKey: null,
178
+ refresh: null,
179
+ quotedTrade: null,
180
+ sign: null,
181
+ };
182
+ case "ResumeRequested": {
183
+ const resumed = buildResumedState(m.intent, msg.intentId, msg.status, msg.owner, msg.depositDeadline);
184
+ if (resumed === m.intent)
185
+ return m;
186
+ // A stored failure is Retry only on the Worker's own nothing-moved marker:
187
+ // the reload cannot know whether this tab sent an execute for it.
188
+ const intent = resumed.status === "failed"
189
+ ? { ...resumed, mayHaveMoved: failureMayHaveMoved(msg.substatus) }
190
+ : resumed;
191
+ // The adopt supersedes any quote still queued for the form. The panel
192
+ // restores the record's amount before it adopts (the adopt waits on the
193
+ // stored deposit proof), and that amount change queues a debounced quote.
194
+ // Left armed, it fires once the arming poll has moved the intent to a
195
+ // terminal status, which QUOTE_BUSY_STATES does not cover, and the fresh
196
+ // quote replaces the receipt with a signable confirm for the same trade.
197
+ // A quote already in the air (a Tron allocation waiting on sign-in) would
198
+ // land over the resumed intent the same way, so it goes too.
199
+ clearTimer(fx, "debounce");
200
+ return { ...supersedeFlights(m, fx), intent };
201
+ }
202
+ case "VisibilityChanged": {
203
+ if (msg.hidden)
204
+ return m;
205
+ let next = m;
206
+ // A backgrounded tab stops refreshing quotes, so a returning user could be
207
+ // looking at a stale or expired price: pull one fresh quote now.
208
+ if (next.amount &&
209
+ shouldRefreshOnReturn({
210
+ status: next.intent.status,
211
+ paused: next.ctx.paused,
212
+ hasAllocatedDeposit: !!next.intent.quote?.depositAddress,
213
+ })) {
214
+ next = startQuote(next, fx, msg.now, { ...quoteOpts(next.amount), forceRefresh: true });
215
+ }
216
+ // Mobile throttles timers while backgrounded (a wallet deep-link
217
+ // round-trip), so snap the in-flight phase forward rather than wait out
218
+ // 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.
220
+ if (awaitingResend(next))
221
+ next = resendBackstop(next, fx, msg.now);
222
+ else if (next.poll && !next.poll.stopped)
223
+ next = pollTick(next, fx, false);
224
+ return next;
225
+ }
226
+ case "StreamStatus":
227
+ // The stream may still be relaying an intent this model has moved past
228
+ // (a reset, a fresh quote): its frames must not drive the current one.
229
+ if (msg.message.intentId !== m.intent.intentId) {
230
+ log(fx, "warn", `${LOG} Ignoring a status frame for another intent:`, {
231
+ frame: msg.message.intentId,
232
+ current: m.intent.intentId,
233
+ });
234
+ return m;
235
+ }
236
+ // Applied by the poll's rule (`applyStatusUpdate`): a `failed` frame
237
+ // after an execute ends "may have moved" unless it carries the marker.
238
+ return applyStatusUpdate(m, fx, msg.message, false);
239
+ case "TimerFired":
240
+ return onTimer(m, fx, msg.timer, msg.now);
241
+ case "SessionReplied":
242
+ return onSession(m, fx, msg.flight, msg.result, msg.now);
243
+ case "QuoteReplied":
244
+ return onQuote(m, fx, msg.flight, msg.result, msg.now);
245
+ case "SignReplied":
246
+ return onSigned(m, fx, msg.flow, msg.purpose, msg.result, msg.now);
247
+ case "ApprovalReplied":
248
+ return onApproval(m, fx, msg.flow, msg.result);
249
+ case "ExecuteReplied":
250
+ return onExecuted(m, fx, msg.flow, msg.result, msg.now);
251
+ case "StatusReplied":
252
+ return onStatus(m, fx, msg.gen, msg.attempt, msg.result, msg.now);
253
+ }
254
+ }
255
+ // ============================================================
256
+ // Effect helpers
257
+ // ============================================================
258
+ function setTimer(fx, timer, ms, repeat) {
259
+ fx.push({ type: "Timer", call: { op: "set", timer, ms, repeat } });
260
+ }
261
+ function clearTimer(fx, timer) {
262
+ fx.push({ type: "Timer", call: { op: "clear", timer } });
263
+ }
264
+ function emit(fx, event) {
265
+ fx.push({ type: "Emit", event });
266
+ }
267
+ function log(fx, level, message, ...data) {
268
+ emit(fx, { kind: "log", level, message, data });
269
+ }
270
+ function nextId(m, prefix) {
271
+ const seq = m.seq + 1;
272
+ return [`${prefix}${seq}`, { ...m, seq }];
273
+ }
274
+ function patch(m, updates) {
275
+ return { ...m, intent: { ...m.intent, ...updates } };
276
+ }
277
+ /**
278
+ * A validated status move; an invalid one is logged and leaves the intent as
279
+ * it was. Leaving the ceremony (`signing`, or `executing` with its /execute
280
+ * reply outstanding) for anything but `signing -> executing` (a rejection, a
281
+ * refusal, a status update that got there first) ends it, so a late wallet or
282
+ * execute reply has nothing to act on. Entering a state past quoting drops
283
+ * every quote still in the air: its answer describes a trade that has moved on.
284
+ */
285
+ function transition(m, fx, to, updates) {
286
+ const intent = applyStatusTransition(m.intent, to, canTransitionTo, updates);
287
+ if (intent === m.intent) {
288
+ log(fx, "warn", `${LOG} Invalid transition: ${m.intent.status} -> ${to}`);
289
+ return m;
290
+ }
291
+ const from = m.intent.status;
292
+ // "Not sent yet" describes an `executing` this client stopped resending.
293
+ let next = { ...m, intent: intent.notSent ? { ...intent, notSent: false } : intent };
294
+ if (QUOTE_BUSY_STATES.includes(to) && !QUOTE_BUSY_STATES.includes(from)) {
295
+ next = supersedeFlights(next, fx);
296
+ }
297
+ // `signing -> executing` keeps the ceremony only as the machine's own
298
+ // execute step (runExecute stages the flow first); a status update making
299
+ // the same move must not leave a pull prompt or "Sign 2 of 2" live.
300
+ const ownExecute = to === "executing" && next.sign?.stage === "execute";
301
+ const leftCeremony = from === "executing" || (from === "signing" && !ownExecute);
302
+ return leftCeremony ? abandonSign(next, fx) : next;
303
+ }
304
+ function quoteOpts(amount) {
305
+ return { amount, silent: false, forceRefresh: false, allocate: null };
306
+ }
307
+ function endAllocation(fx, flight, ok) {
308
+ if (flight.allocate)
309
+ emit(fx, { kind: "allocation", ref: flight.allocate.ref, ok });
310
+ }
311
+ function withoutFlight(m, id) {
312
+ return { ...m, flights: m.flights.filter((f) => f.id !== id) };
313
+ }
314
+ function putFlight(m, flight) {
315
+ return { ...m, flights: [...m.flights.filter((f) => f.id !== flight.id), flight] };
316
+ }
317
+ /** Drop every quote still in flight: a newer ask has made their answers moot. */
318
+ function supersedeFlights(m, fx) {
319
+ if (m.flights.length === 0)
320
+ return m;
321
+ for (const flight of m.flights)
322
+ endAllocation(fx, flight, false);
323
+ return { ...m, flights: [] };
324
+ }
325
+ function startQuote(m, fx, now, opts) {
326
+ // "May have moved" holds until Reset: no quote lands over it and offers the
327
+ // trade again before the user has checked what the last order did.
328
+ if (m.intent.status === "failed" && m.intent.mayHaveMoved) {
329
+ log(fx, "debug", `${LOG} Ignoring quote request: the last order may have moved funds`);
330
+ endAllocation(fx, opts, false);
331
+ return m;
332
+ }
333
+ const ctx = m.ctx;
334
+ const address = ctx.wallet.address;
335
+ const depositOrigin = !isChainId(ctx.fromChainId);
336
+ const access = quoteAccessDecision({
337
+ connected: !!address,
338
+ settlementPreference: ctx.settlementPreference,
339
+ isDepositOrigin: depositOrigin,
340
+ allocateDepositRoute: !!opts.allocate,
341
+ });
342
+ if (access === "needs_wallet") {
343
+ endAllocation(fx, opts, false);
344
+ return patch(m, { error: "Connect a wallet to quote this route" });
345
+ }
346
+ const preview = access === "preview";
347
+ const [id, next] = nextId(m, "q");
348
+ const flight = {
349
+ id,
350
+ stage: "session",
351
+ amount: opts.amount,
352
+ silent: opts.silent,
353
+ forceRefresh: opts.forceRefresh,
354
+ allocate: opts.allocate,
355
+ ctx,
356
+ depositOrigin,
357
+ preview,
358
+ request: null,
359
+ };
360
+ // A Tron-origin executable quote allocates a durable solver route before any
361
+ // intent signature exists. Prove the EVM wallet first so the worker can bind
362
+ // and quota that allocation.
363
+ if (depositOrigin && !preview) {
364
+ if (opts.silent && !address)
365
+ return next;
366
+ fx.push({
367
+ type: "Wallet",
368
+ call: { op: "ensureSession", flight: id, interactive: !opts.silent },
369
+ });
370
+ return putFlight(next, flight);
371
+ }
372
+ return sendQuote(next, fx, flight, now);
373
+ }
374
+ function onSession(m, fx, id, result, now) {
375
+ const flight = m.flights.find((f) => f.id === id && f.stage === "session");
376
+ if (!flight)
377
+ return m;
378
+ // A session flight may start on a finished trade (a new Tron quote after a
379
+ // completed one), so only a ceremony or a swap in flight makes it stale.
380
+ if (QUOTE_BUSY_STATES.includes(m.intent.status))
381
+ return dropStaleFlight(m, fx, flight);
382
+ if (result === "ok")
383
+ return sendQuote(m, fx, flight, now);
384
+ const next = withoutFlight(m, id);
385
+ endAllocation(fx, flight, false);
386
+ if (flight.silent)
387
+ return next;
388
+ const error = "Wallet sign-in is required to allocate a Tron deposit route";
389
+ return flight.allocate
390
+ ? allocationFailed(next, error)
391
+ : patch(next, { status: "failed", error, quote: null });
392
+ }
393
+ /**
394
+ * A quote reply that finds the trade past quoting: the intent it would patch
395
+ * is no longer the one it was asked for. Logged and dropped, never applied.
396
+ */
397
+ function dropStaleFlight(m, fx, flight) {
398
+ log(fx, "warn", `${LOG} Dropping a quote reply: intent is ${m.intent.status}`, flight.id);
399
+ endAllocation(fx, flight, false);
400
+ return withoutFlight(m, flight.id);
401
+ }
402
+ function sendQuote(m, fx, flight, now) {
403
+ const ctx = flight.ctx;
404
+ // Fail-closed on the stealth key precondition: the keys derive from a wallet
405
+ // signature and land a moment after `settlement` flips to stealth. Hold the
406
+ // request; the host re-quotes once they arrive. A visible hold also lets go of
407
+ // the quote on screen: it was served for another settlement, and leaving it
408
+ // `quoted` would keep it signable after the user asked for stealth.
409
+ if (ctx.settlementPreference === "stealth" && !ctx.stealthMetaAddress) {
410
+ endAllocation(fx, flight, false);
411
+ const next = withoutFlight(m, flight.id);
412
+ return flight.silent ? next : transition(next, fx, "quoting", { quote: null, error: null });
413
+ }
414
+ const request = buildQuoteRequest({
415
+ wallet: flight.preview ? undefined : (ctx.wallet.address ?? undefined),
416
+ preview: flight.preview,
417
+ allocationKey: flight.allocate?.key,
418
+ fromChainId: ctx.fromChainId,
419
+ toChainId: ctx.toChainId,
420
+ fromToken: ctx.fromToken,
421
+ toToken: ctx.toToken,
422
+ fromAmount: flight.amount,
423
+ deadline: Math.floor(now / 1000) + QUOTE_DEADLINE_SECS,
424
+ slippageBps: ctx.slippageBps,
425
+ trustScore: ctx.trustScore,
426
+ settlementPreference: ctx.settlementPreference,
427
+ recipientAddress: ctx.recipientAddress ?? undefined,
428
+ refundAddress: ctx.refundAddress ?? undefined,
429
+ stealthMetaAddress: ctx.stealthMetaAddress ?? undefined,
430
+ });
431
+ const validation = validateSwapParams(request, now);
432
+ if (!validation.isValid) {
433
+ // Logged before the silent bail: a refresh that stops validating is the
434
+ // first sign the panel and the request have drifted apart.
435
+ log(fx, "warn", `${LOG} Quote request failed validation:`, validation.errors);
436
+ endAllocation(fx, flight, false);
437
+ const next = withoutFlight(m, flight.id);
438
+ if (flight.silent)
439
+ return next;
440
+ return patch(next, {
441
+ status: "failed",
442
+ error: validation.errors[0] || "Invalid swap parameters",
443
+ });
444
+ }
445
+ const next = flight.silent ? m : transition(m, fx, "quoting");
446
+ fx.push({
447
+ type: "Api",
448
+ call: { op: "quote", flight: flight.id, request, forceRefresh: flight.forceRefresh },
449
+ });
450
+ return putFlight(next, { ...flight, stage: "api", request });
451
+ }
452
+ /** A quote the gates refused: logged always, applied only when not silent. */
453
+ function refuseQuote(m, fx, flight, updates) {
454
+ endAllocation(fx, flight, false);
455
+ if (flight.silent)
456
+ return m;
457
+ return transition(m, fx, "failed", updates);
458
+ }
459
+ /** The request or the proof threw: keep the confirm surface intact when silent. */
460
+ function quoteThrew(m, fx, flight, report) {
461
+ log(fx, "error", `${LOG} Quote error:`, report.detail);
462
+ endAllocation(fx, flight, false);
463
+ if (flight.silent)
464
+ return m;
465
+ const error = report.value ?? "Quote request failed";
466
+ return flight.allocate
467
+ ? allocationFailed(m, error)
468
+ : patch(m, { status: "failed", error, quote: null });
469
+ }
470
+ /**
471
+ * An allocation that did not land leaves the indicative price on screen. Only
472
+ * a trade still before execution returns to `quoted`; any other status is
473
+ * kept, so a failure never revives an ended trade over its consumed quote.
474
+ */
475
+ function allocationFailed(m, error) {
476
+ return ALLOCATABLE_STATES.includes(m.intent.status)
477
+ ? patch(m, { status: "quoted", error })
478
+ : patch(m, { error });
479
+ }
480
+ function onQuote(model, fx, id, result, now) {
481
+ const flight = model.flights.find((f) => f.id === id && f.stage === "api");
482
+ if (!flight)
483
+ return model;
484
+ // The request moved the intent to `quoting` (or was silent over `quoted`):
485
+ // finding it at a prompt, a swap in flight, delivered or refunded means the
486
+ // trade went on without this answer, which may then neither fail it nor
487
+ // replace its quote.
488
+ const status = model.intent.status;
489
+ if (QUOTE_BUSY_STATES.includes(status) || ENDED_STATES.includes(status)) {
490
+ return dropStaleFlight(model, fx, flight);
491
+ }
492
+ const m = withoutFlight(model, id);
493
+ if (!result.ok)
494
+ return quoteThrew(m, fx, flight, result.error);
495
+ const quote = result.quote;
496
+ const ctx = flight.ctx;
497
+ const address = ctx.wallet.address;
498
+ if (!quote.success) {
499
+ log(fx, "warn", `${LOG} Quote request rejected:`, quote.error);
500
+ return refuseQuote(m, fx, flight, { error: quote.error || "Failed to get quote", quote: null });
501
+ }
502
+ if (!quote.canSolve) {
503
+ // Surface the solver's real reason; ErrorDisplay maps these codes to copy.
504
+ log(fx, "warn", `${LOG} Quote not solvable:`, quote.error);
505
+ return refuseQuote(m, fx, flight, {
506
+ error: quote.error || "No liquidity available for this route",
507
+ quote,
508
+ });
509
+ }
510
+ // Whether this is a preview is OUR question, not the server's answer: a
511
+ // wallet-bound request answered `preview: true` would otherwise skip the
512
+ // signer checks and still reach Confirm, and an executable answer to a
513
+ // preview request was never bound to a wallet.
514
+ if ((quote.preview === true) !== flight.preview) {
515
+ log(fx, "error", `${LOG} Refusing a quote that answers the wrong kind of request`, {
516
+ requestedPreview: flight.preview,
517
+ servedPreview: quote.preview === true,
518
+ });
519
+ return refuseQuote(m, fx, flight, {
520
+ error: "This quote did not match the requested trade, so it cannot be signed.",
521
+ quote: null,
522
+ });
523
+ }
524
+ // Non-EVM destination: the solvable quote must echo exactly the recipient we
525
+ // asked for, or the funds would route to an unconfirmed destination (#113).
526
+ const echoError = recipientEchoError(flight.request?.recipientAddress, quote.recipientAddress);
527
+ if (echoError) {
528
+ log(fx, "warn", `${LOG} Recipient echo rejected:`, echoError);
529
+ return refuseQuote(m, fx, flight, {
530
+ error: "Couldn't confirm the destination address for this route. Please try again.",
531
+ quote: null,
532
+ });
533
+ }
534
+ // Stealth: the served factory decides who owns the payout account (#302).
535
+ const claimError = claimInfoError(quote.claimInfo, ctx.toChainId);
536
+ if (claimError) {
537
+ log(fx, "error", `${LOG} Stealth claim info rejected:`, claimError.code, quote.claimInfo);
538
+ return refuseQuote(m, fx, flight, {
539
+ error: `${claimError.message}. Send this one public, or try again later.`,
540
+ quote: null,
541
+ });
542
+ }
543
+ // The RESOLVED payout address: the request's recipient, or the wallet.
544
+ const resolvedRecipient = ctx.recipientAddress?.trim() || address || "";
545
+ // Never pay the destination token contract (raxol#869). Runs on EVERY route.
546
+ if (recipientIsDestinationToken(resolvedRecipient, ctx.toToken)) {
547
+ log(fx, "error", `${LOG} Refusing a quote whose payout recipient is the destination token`, {
548
+ recipient: resolvedRecipient,
549
+ toToken: ctx.toToken,
550
+ });
551
+ return refuseQuote(m, fx, flight, {
552
+ error: "That destination address is the token's own contract. Funds sent there cannot be recovered.",
553
+ quote: null,
554
+ });
555
+ }
556
+ // What we know from the origin chain and what the server sent must agree on
557
+ // "is this a deposit route", or a substituted quote picks its own code path.
558
+ if ((quote.preview && !!quote.depositAddress) ||
559
+ (!quote.preview && flight.depositOrigin !== !!quote.depositAddress)) {
560
+ log(fx, "error", `${LOG} Deposit-route disagreement between origin chain and quote`, {
561
+ isDepositOrigin: flight.depositOrigin,
562
+ preview: quote.preview === true,
563
+ hasDepositAddress: !!quote.depositAddress,
564
+ fromChainId: ctx.fromChainId,
565
+ });
566
+ return refuseQuote(m, fx, flight, {
567
+ error: "This route is not available right now.",
568
+ quote: null,
569
+ });
570
+ }
571
+ const request = flight.request;
572
+ if (!quote.depositAddress) {
573
+ // A signable quote: hold the served intent to the request and the origin
574
+ // pull to the intent (`@xochi/signer`) before it can reach the wallet.
575
+ // Whatever else the quote claims: any intent it carries is checked, and a
576
+ // quote with no intent to sign is refused at Confirm ("No quote to sign").
577
+ if (quote.eip712Data) {
578
+ const check = request
579
+ ? checkSignableQuote(request, quote, m.config.pullRecipients)
580
+ : { ok: false, kind: "intent_mismatch", reason: "no request on record" };
581
+ if (!check.ok) {
582
+ log(fx, "error", `${LOG} Refusing a quote that does not match the request:`, check.kind, check.reason);
583
+ return refuseQuote(m, fx, flight, {
584
+ error: "This quote did not match the requested trade, so it cannot be signed.",
585
+ quote: null,
586
+ });
587
+ }
588
+ }
589
+ return acceptQuote(m, fx, flight, quote, null, now);
590
+ }
591
+ // Deposit route: the v3 attestation binds the whole deal, and is verified
592
+ // against the pinned signer before the address can ever be shown. The
593
+ // RESOLVED settlement type and a real wallet are required; a default would
594
+ // produce a binding that proves nothing.
595
+ const resolvedSettlement = quote.settlementType;
596
+ if (!resolvedSettlement || !request?.wallet) {
597
+ return attestationRejected(m, fx, flight, {
598
+ ok: false,
599
+ kind: "unavailable",
600
+ reason: !resolvedSettlement
601
+ ? "quote did not state a resolved settlement type (route.settlement_type)"
602
+ : "no connected wallet to bind the deposit attestation to",
603
+ });
604
+ }
605
+ // The signer rebuilds the signed message from the REQUEST, settlement
606
+ // preference included, so a quote served under another settlement type cannot
607
+ // verify. Refused here first, under its own reason: on a stealth request it is
608
+ // a privacy downgrade, and as a bare recovery failure it reads as tampering.
609
+ if (resolvedSettlement !== request.settlementPreference) {
610
+ return attestationRejected(m, fx, flight, {
611
+ ok: false,
612
+ kind: "unavailable",
613
+ reason: `quote resolved settlement type ${resolvedSettlement} but ${request.settlementPreference} was requested`,
614
+ });
615
+ }
616
+ const verdict = checkDepositQuote(request, quote, m.config.depositAttestationSigner, m.seenDepositAddresses);
617
+ if (!verdict.ok)
618
+ return attestationRejected(m, fx, flight, verdict);
619
+ return acceptQuote({ ...m, seenDepositAddresses: verdict.seen }, fx, flight, quote, verdict.stored, now);
620
+ }
621
+ function attestationRejected(m, fx, flight, verdict) {
622
+ log(fx, "warn", `${LOG} Deposit attestation rejected:`, verdict.kind, verdict.reason);
623
+ return refuseQuote(m, fx, flight, {
624
+ // `addressReuse` is the per-session tripwire, and a reload clears it.
625
+ error: verdict.kind === "addressReuse"
626
+ ? "We were given a deposit address that was already in use. Reload the page and try again."
627
+ : "This deposit route is not available right now.",
628
+ quote: null,
629
+ });
630
+ }
631
+ function acceptQuote(m, fx, flight, quote, attestedDeposit, now) {
632
+ const address = flight.ctx.wallet.address;
633
+ const quoteState = {
634
+ intentId: quote.intentId,
635
+ quoteId: quote.quoteId,
636
+ quote,
637
+ error: null,
638
+ // A fresh quote is a fresh attempt: whatever stopped the last Confirm no
639
+ // longer describes this one.
640
+ refusal: null,
641
+ refundReason: null,
642
+ quotedAddress: quote.preview ? null : address,
643
+ quotedChainId: flight.ctx.wallet.chainId,
644
+ quotedFromAmount: flight.amount,
645
+ quotedSettlement: flight.request?.settlementPreference ?? flight.ctx.settlementPreference,
646
+ depositDeadline: quote.depositAddress ? (quote.depositDeadline ?? null) : null,
647
+ attestedDeposit,
648
+ };
649
+ let next;
650
+ if (flight.silent) {
651
+ // Never overwrite the quote the user is signing.
652
+ if (m.intent.status !== "quoted")
653
+ return m;
654
+ next = patch(m, quoteState);
655
+ }
656
+ else if (m.intent.status === "quoted") {
657
+ // A deduplicated in-flight request answering twice: refresh in place
658
+ // rather than ask for an invalid quoted -> quoted edge.
659
+ next = patch(m, { ...quoteState, pollExhausted: false });
660
+ }
661
+ else {
662
+ // A fresh quote is a fresh poll budget.
663
+ next = transition(m, fx, "quoted", { ...quoteState, pollExhausted: false });
664
+ }
665
+ // Only if it landed: a refused move leaves the old quote, and its trade, held.
666
+ if (next.intent.quote === quote)
667
+ next = { ...next, quotedTrade: tradeKey(flight.ctx) };
668
+ const refreshMs = computeRefreshDelayMs(quote.expiry, now, QUOTE_REFRESH_BUFFER_MS);
669
+ if (refreshMs > 0) {
670
+ // Clear-before-arm is the set itself (one timer per name), so a refresh
671
+ // reached without requestQuote never forks a second refresh loop.
672
+ setTimer(fx, "refresh", refreshMs, false);
673
+ next = {
674
+ ...next,
675
+ refresh: { amount: flight.amount, hasAllocatedDeposit: !!quote.depositAddress },
676
+ };
677
+ }
678
+ endAllocation(fx, flight, true);
679
+ return next;
680
+ }
681
+ // ============================================================
682
+ // Timers
683
+ // ============================================================
684
+ function onTimer(m, fx, timer, now) {
685
+ switch (timer) {
686
+ case "debounce":
687
+ if (QUOTE_BUSY_STATES.includes(m.intent.status)) {
688
+ log(fx, "debug", `${LOG} Ignoring quote request during`, m.intent.status);
689
+ return m;
690
+ }
691
+ return m.amount ? startQuote(m, fx, now, quoteOpts(m.amount)) : m;
692
+ case "refresh": {
693
+ const plan = m.refresh;
694
+ if (!plan)
695
+ return m;
696
+ const next = { ...m, refresh: null };
697
+ if (!shouldRefreshQuote({
698
+ status: next.intent.status,
699
+ hidden: next.hidden,
700
+ hasAllocatedDeposit: plan.hasAllocatedDeposit,
701
+ })) {
702
+ return next;
703
+ }
704
+ // Silent under an open confirm surface; forced so it replaces the
705
+ // expiring quote rather than re-serving it from cache.
706
+ return startQuote(next, fx, now, {
707
+ amount: plan.amount,
708
+ silent: next.ctx.paused,
709
+ forceRefresh: true,
710
+ allocate: null,
711
+ });
712
+ }
713
+ case "expiry":
714
+ return tickExpiry(m, fx, now);
715
+ case "poll":
716
+ if (awaitingResend(m))
717
+ return resendBackstop(m, fx, now);
718
+ return m.poll && !m.poll.stopped ? pollTick(m, fx, true) : m;
719
+ case "signPrompt":
720
+ // A deep-link prompt that never re-opened the wallet.
721
+ return m.sign?.stage === "pull"
722
+ ? signFlowFailed(m, fx, {
723
+ rejected: false,
724
+ value: "wallet_prompt_timeout",
725
+ detail: "wallet_prompt_timeout",
726
+ })
727
+ : m;
728
+ case "execute":
729
+ return resendExecute(m, fx, now);
730
+ }
731
+ }
732
+ function tickExpiry(m, fx, now) {
733
+ const expiry = m.intent.quote?.expiry;
734
+ if (!expiry)
735
+ return m;
736
+ const remaining = Math.max(0, Math.floor((new Date(expiry).getTime() - now) / 1000));
737
+ const next = { ...m, quoteExpiresIn: remaining };
738
+ return remaining === 0 ? transition(next, fx, "expired", { error: "Quote expired" }) : next;
739
+ }
740
+ // ============================================================
741
+ // Sign & execute
742
+ // ============================================================
743
+ function signPreflight(m, now) {
744
+ const expiry = m.intent.quote?.expiry;
745
+ return decideSignPreflight({
746
+ hasDepositAddress: false,
747
+ hasEip712: true,
748
+ address: m.ctx.wallet.address ?? undefined,
749
+ quotedAddress: m.intent.quotedAddress,
750
+ quotedChainId: m.intent.quotedChainId,
751
+ chainId: m.ctx.wallet.chainId ?? undefined,
752
+ originChainId: m.ctx.fromChainId,
753
+ expiryMs: expiry ? new Date(expiry).getTime() : 0,
754
+ now,
755
+ });
756
+ }
757
+ function beginSign(m, fx, now) {
758
+ // Confirm acts on a held quote only. A second Confirm while the wallet is
759
+ // open (or after signature 1, awaiting the pull) is not a new attempt: it
760
+ // must neither raise a prompt nor end the live ceremony, whose late
761
+ // signature would otherwise still execute behind a refusal.
762
+ if (m.intent.status !== "quoted")
763
+ return m;
764
+ const quote = m.intent.quote;
765
+ // The same gate the receipt renders from: a preview or a declined quote is
766
+ // never signable, whatever it carries.
767
+ if (!quote || !canSignQuote(m.intent.status, quote))
768
+ return patch(m, { error: "No quote to sign" });
769
+ // Nor a quote for another trade: after an edit the held quote stays on
770
+ // screen (priced at its own amount) until its replacement lands, but the
771
+ // wallet must never be asked to sign, or a deposit address revealed, for a
772
+ // trade the form no longer describes.
773
+ if (!holdsCurrentQuote(m)) {
774
+ log(fx, "warn", `${LOG} Not signing a quote for a trade no longer on the form`, {
775
+ quotedFromAmount: m.intent.quotedFromAmount,
776
+ amount: m.amount,
777
+ });
778
+ return m;
779
+ }
780
+ // Deposit route: no signature, no /execute. Revealing the address drives
781
+ // the poll to a terminal state once the solver detects the deposit.
782
+ if (quote.depositAddress) {
783
+ // The deposit window was checked at ingest, but an allocated route is
784
+ // never refreshed or expired by the price countdown, so it can sit on the
785
+ // screen past its attested deadline. Revealing it then hands the user an
786
+ // address whose deposit is refunded rather than filled.
787
+ const deadline = m.intent.depositDeadline;
788
+ if (deadline === null || deadline - Math.floor(now / 1000) < DEPOSIT_MIN_WINDOW_SECONDS) {
789
+ log(fx, "warn", `${LOG} Not revealing a deposit address past its window`, {
790
+ depositDeadline: deadline,
791
+ now: Math.floor(now / 1000),
792
+ });
793
+ return transition(m, fx, "expired", { error: "Quote expired" });
794
+ }
795
+ return transition(m, fx, "awaiting_deposit");
796
+ }
797
+ if (!quote.eip712Data)
798
+ return patch(m, { error: "No quote to sign" });
799
+ const preflight = signPreflight(m, now);
800
+ if (preflight.kind === "reject") {
801
+ return preflight.to === "quoted"
802
+ ? patch(m, { refusal: preflight.refusal })
803
+ : transition(m, fx, preflight.to, { error: preflight.error });
804
+ }
805
+ // Bind the DESTINATION leg before the wallet is prompted: the signed
806
+ // `recipient` and `toAmount` must be what this client rendered.
807
+ const payoutPin = validatePayoutPin(quote.eip712Data, {
808
+ toAmount: quote.toAmount,
809
+ recipient: m.ctx.recipientAddress?.trim() || m.ctx.wallet.address || undefined,
810
+ });
811
+ if (!payoutPin.ok) {
812
+ log(fx, "error", `${LOG} Refusing to sign intent:`, payoutPin);
813
+ return patch(m, { refusal: { kind: "payout_mismatch" } });
814
+ }
815
+ const signing = transition(m, fx, "signing", { refusal: null });
816
+ if (signing.intent.status !== "signing")
817
+ return signing;
818
+ const [id, next] = nextId(signing, "s");
819
+ fx.push({
820
+ type: "Wallet",
821
+ call: {
822
+ op: "signTypedData",
823
+ flow: id,
824
+ purpose: "intent",
825
+ typedData: quote.eip712Data,
826
+ chainId: next.ctx.wallet.chainId,
827
+ },
828
+ });
829
+ return { ...next, sign: { id, origin: "sign", stage: "intent", intentSignature: null } };
830
+ }
831
+ function beginPullSign(m, fx, now) {
832
+ const signature = m.pendingIntentSig;
833
+ if (!signature || !m.intent.quote?.pullAuthorization || m.isSigningPull || m.sign)
834
+ return m;
835
+ const preflight = signPreflight(m, now);
836
+ if (preflight.kind === "reject") {
837
+ // Back to the quote, whatever the reason: `signing` has no edge to
838
+ // `expired`, and an expired quote landing on `quoted` is expired by the
839
+ // countdown in this same update.
840
+ return transition({ ...m, awaitingPull: false }, fx, "quoted", { error: preflight.error });
841
+ }
842
+ const [id, next] = nextId({ ...m, isSigningPull: true }, "s");
843
+ return completePull(next, fx, {
844
+ id,
845
+ origin: "pull",
846
+ stage: "approval",
847
+ intentSignature: signature,
848
+ });
849
+ }
850
+ /** Read a served base-units amount, or report it the way a thrown BigInt was. */
851
+ function readAmount(raw) {
852
+ try {
853
+ return BigInt(raw);
854
+ }
855
+ catch (err) {
856
+ const detail = err instanceof Error ? err.message : String(err);
857
+ return { rejected: false, value: detail, detail };
858
+ }
859
+ }
860
+ /** Origin-pull leg, step 1: the one-time approval (Permit2; ERC-3009 no-ops). */
861
+ function completePull(m, fx, flow) {
862
+ const pull = m.intent.quote?.pullAuthorization;
863
+ const eip712 = m.intent.quote?.eip712Data;
864
+ if (!pull || !eip712)
865
+ return endSignFlow(m, flow);
866
+ const amount = readAmount(eip712.message?.fromAmount || m.intent.quotedFromAmount || "0");
867
+ if (typeof amount !== "bigint")
868
+ return signFlowFailed({ ...m, sign: flow }, fx, amount);
869
+ fx.push({
870
+ type: "Wallet",
871
+ call: { op: "ensureApproval", flow: flow.id, amount: amount.toString() },
872
+ });
873
+ return { ...m, sign: { ...flow, stage: "approval" } };
874
+ }
875
+ function onApproval(m, fx, flowId, result) {
876
+ const flow = m.sign;
877
+ if (!flow || flow.id !== flowId || flow.stage !== "approval")
878
+ return m;
879
+ if (!result.ok)
880
+ return signFlowFailed(m, fx, result.error);
881
+ if (!result.approved) {
882
+ return transition(m, fx, "quoted", {
883
+ error: "Token approval required to continue",
884
+ refusal: { kind: "approval_required" },
885
+ });
886
+ }
887
+ const pull = m.intent.quote?.pullAuthorization;
888
+ const eip712 = m.intent.quote?.eip712Data;
889
+ if (!pull || !eip712)
890
+ return endSignFlow(m, flow);
891
+ // Fail-closed solver pin (#333, #170): never sign a pull whose recipient is
892
+ // not the pinned solver, nor whose amount exceeds the intended input. The
893
+ // amount is anchored to the user's own quoted input (what the intent was
894
+ // requested and signed at), not a served field and not the live form.
895
+ const expected = readAmount(m.intent.quotedFromAmount || eip712.message?.fromAmount || "0");
896
+ if (typeof expected !== "bigint")
897
+ return signFlowFailed(m, fx, expected);
898
+ const pin = validateSolverPin(pull, m.config.solverPin, expected);
899
+ if (!pin.ok) {
900
+ log(fx, "error", `${LOG} Refusing to sign origin pull:`, pin);
901
+ return transition(m, fx, "quoted", {
902
+ error: `Refusing to sign origin pull: ${pin.reason}`,
903
+ refusal: { kind: "pull_unpinned" },
904
+ });
905
+ }
906
+ fx.push({
907
+ type: "Wallet",
908
+ call: {
909
+ op: "signTypedData",
910
+ flow: flow.id,
911
+ purpose: "pull",
912
+ typedData: pull,
913
+ chainId: m.ctx.wallet.chainId,
914
+ },
915
+ });
916
+ // Bound the prompt: a deep-link that never re-opens the wallet would
917
+ // otherwise wait forever.
918
+ setTimer(fx, "signPrompt", SIGN_PROMPT_TIMEOUT_MS, false);
919
+ return { ...m, sign: { ...flow, stage: "pull" } };
920
+ }
921
+ function onSigned(m, fx, flowId, purpose, result, now) {
922
+ const flow = m.sign;
923
+ if (!flow || flow.id !== flowId || flow.stage !== purpose)
924
+ return m;
925
+ // A status update can carry the intent past `signing` while a prompt is
926
+ // open; its signature then belongs to no ceremony.
927
+ if (m.intent.status !== "signing")
928
+ return abandonSign(m, fx);
929
+ if (purpose === "pull")
930
+ clearTimer(fx, "signPrompt");
931
+ if (!result.ok)
932
+ return signFlowFailed(m, fx, result.error);
933
+ if (purpose === "pull") {
934
+ return runExecute({ ...m, awaitingPull: false }, fx, flow, flow.intentSignature ?? "", result.signature, now);
935
+ }
936
+ const next = { ...m, pendingIntentSig: result.signature };
937
+ const signed = { ...flow, intentSignature: result.signature };
938
+ // No origin pull to authorize: execute on the single signature.
939
+ if (!next.intent.quote?.pullAuthorization) {
940
+ return runExecute(next, fx, signed, result.signature, undefined, now);
941
+ }
942
+ // A deep-link wallet will not re-open for a second back-to-back prompt, so
943
+ // the pull waits for an explicit tap ("Sign 2 of 2"). Injected wallets
944
+ // prompt fine in sequence and keep the single-gesture flow.
945
+ if (next.ctx.wallet.stepwise)
946
+ return { ...next, awaitingPull: true, sign: null };
947
+ return completePull(next, fx, signed);
948
+ }
949
+ /**
950
+ * Dispatch /execute with the collected signatures. Everything after this is
951
+ * POST-dispatch, so a failure is in doubt rather than terminal (#226), except
952
+ * the one the server proves accepted nothing (`onNotAccepted`).
953
+ */
954
+ function runExecute(m, fx, flow, signature, pullSignature, now) {
955
+ // Only a live ceremony dispatches: a signature that lands after the intent
956
+ // left `signing` (refused, reset, re-quoted) must not execute.
957
+ if (m.intent.status !== "signing") {
958
+ log(fx, "warn", `${LOG} Dropping signature: intent is ${m.intent.status}, not signing`);
959
+ return abandonSign(m, fx);
960
+ }
961
+ const request = {
962
+ intentId: m.intent.intentId ?? "",
963
+ quoteId: m.intent.quoteId ?? "",
964
+ signature,
965
+ nonce: now,
966
+ pullSignature,
967
+ };
968
+ // The flow is marked `execute` before the move, so `transition` can tell
969
+ // this step from a status update that skipped the ceremony.
970
+ const staged = {
971
+ ...m,
972
+ sign: {
973
+ ...flow,
974
+ stage: "execute",
975
+ execute: { request, resends: 0, resendAt: null, error: null },
976
+ },
977
+ };
978
+ const executing = transition(staged, fx, "executing");
979
+ if (executing.intent.status !== "executing")
980
+ return abandonSign(m, fx);
981
+ // Fill clock starts here: the signed intent is now with the solver.
982
+ const next = { ...executing, submittedAt: now, clientFillMs: null };
983
+ fx.push({ type: "Api", call: { op: "execute", flow: flow.id, request } });
984
+ return next;
985
+ }
986
+ function onExecuted(m, fx, flowId, result, now) {
987
+ const flow = m.sign;
988
+ // While a resend is pending no request is in flight, so nothing answers.
989
+ if (!flow || flow.id !== flowId || flow.stage !== "execute" || awaitingResend(m))
990
+ return m;
991
+ const report = notAcceptedReport(result);
992
+ const handled = report ? onNotAccepted(m, fx, flow, report, now) : null;
993
+ if (handled)
994
+ return handled;
995
+ const next = endSignFlow({ ...m, pendingIntentSig: null }, flow);
996
+ // Every error here is POST-dispatch and therefore IN-DOUBT (the origin pull
997
+ // may have moved funds): flag reconciling and let the poll resolve it (#226).
998
+ // So is a reply that cannot be read as this intent's execute response.
999
+ const reply = executeResponseUnlessInDoubt(fx, result, next.intent.intentId);
1000
+ if (!reply)
1001
+ return patch(next, { reconciling: true });
1002
+ // The Worker answers `quoted` for this intent when it ended its own claim
1003
+ // and could not finish the intent (a fault while finishing an unexecuted
1004
+ // claim): nothing is in flight, and the signed request stays executable
1005
+ // until its deadline, exactly as after a give-up. "Not sent yet", no Retry;
1006
+ // an intent that already reads in doubt stays so, as on a not-accepted
1007
+ // refusal (`onNotAccepted`).
1008
+ if (!reply.response.success &&
1009
+ reply.response.status === "quoted" &&
1010
+ next.intent.status === "executing" &&
1011
+ !next.intent.reconciling) {
1012
+ log(fx, "warn", `${LOG} Execute ended unclaimed (the intent reads quoted); not sent yet`);
1013
+ return patch(next, {
1014
+ notSent: true,
1015
+ error: reply.response.error || "The order was not sent.",
1016
+ });
1017
+ }
1018
+ const decision = decideExecuteResult(reply.response);
1019
+ if (decision.kind === "failed") {
1020
+ return transition(next, fx, "failed", {
1021
+ error: decision.error,
1022
+ mayHaveMoved: decision.mayHaveMoved,
1023
+ });
1024
+ }
1025
+ // An unmapped status is applied as an in-flight bucket and shown in doubt,
1026
+ // as an unmapped status poll is (`applyStatusUpdate`).
1027
+ const updates = reply.unmapped ? { ...decision.updates, reconciling: true } : decision.updates;
1028
+ // Inline fill: the execute round-trip bounds submit -> filled exactly.
1029
+ const timed = decision.status === "completed" && next.submittedAt !== null
1030
+ ? { ...next, clientFillMs: now - next.submittedAt }
1031
+ : next;
1032
+ const moved = transition(timed, fx, decision.status, updates);
1033
+ // A refused move (the reply names the status the intent is already in, or
1034
+ // one it cannot reach) must not drop an in-doubt verdict with it.
1035
+ return updates.reconciling === true && moved.intent === timed.intent
1036
+ ? patch(moved, { reconciling: true })
1037
+ : moved;
1038
+ }
1039
+ /** A thrown execute's report carrying the not-accepted flag (`true`, nothing else), read defensively. */
1040
+ function notAcceptedReport(result) {
1041
+ const reply = result;
1042
+ return reply?.ok === false && reply.error?.notAccepted === true
1043
+ ? reply.error
1044
+ : null;
1045
+ }
1046
+ /**
1047
+ * The server refused this execute before accepting anything: wait for the
1048
+ * refusal's `retryAfter` and send the SAME request again (`resendExecute`)
1049
+ * within `decideExecuteResend`'s bounds, else stop resending
1050
+ * (`stopResending`). Null, leaving the reply in doubt like any other thrown
1051
+ * execute, when the intent already reads in doubt (rules: README "Fund
1052
+ * safety").
1053
+ */
1054
+ function onNotAccepted(m, fx, flow, report, now) {
1055
+ const attempt = flow.execute;
1056
+ const intentId = m.intent.intentId;
1057
+ if (!attempt ||
1058
+ intentId === null ||
1059
+ attempt.request.intentId !== intentId ||
1060
+ m.intent.status !== "executing" ||
1061
+ m.intent.reconciling) {
1062
+ log(fx, "warn", `${LOG} Execute not accepted, but kept in doubt: the intent already is`);
1063
+ return null;
1064
+ }
1065
+ const error = report.value ?? "The solver is busy. Try again.";
1066
+ const decision = decideExecuteResend({
1067
+ resends: attempt.resends,
1068
+ retryAfter: typeof report.value === "object" ? report.value?.retryAfter : undefined,
1069
+ deadline: executeDeadline(m.intent.quote),
1070
+ now,
1071
+ });
1072
+ if (decision.kind === "give_up") {
1073
+ log(fx, "warn", `${LOG} Execute not accepted; not resending (${decision.reason})`);
1074
+ return stopResending({ ...m, pendingIntentSig: null }, flow, error);
1075
+ }
1076
+ log(fx, "warn", `${LOG} Execute not accepted (solver busy); resend ${attempt.resends + 1}/${MAX_EXECUTE_RESENDS} in ${decision.delayMs}ms`, report.detail);
1077
+ setTimer(fx, "execute", decision.delayMs, false);
1078
+ return {
1079
+ ...m,
1080
+ pendingIntentSig: null,
1081
+ sign: { ...flow, execute: { ...attempt, resendAt: now + decision.delayMs, error } },
1082
+ };
1083
+ }
1084
+ /**
1085
+ * The not-accepted wait is over: send the held request again, unchanged, under
1086
+ * a new flow id. A wait that ends past the deadline (a throttled background
1087
+ * tab) stops resending instead (`stopResending`).
1088
+ */
1089
+ function resendExecute(m, fx, now) {
1090
+ const flow = m.sign;
1091
+ const attempt = flow?.execute;
1092
+ if (!flow || !attempt || !awaitingResend(m) || m.intent.status !== "executing")
1093
+ return m;
1094
+ const deadline = executeDeadline(m.intent.quote);
1095
+ if (deadline === null || now > (deadline - EXECUTE_RESEND_MARGIN_SECS) * 1000) {
1096
+ log(fx, "warn", `${LOG} Execute not accepted; the deadline passed before the resend`);
1097
+ return stopResending(m, flow, attempt.error ?? "The solver is busy. Try again.");
1098
+ }
1099
+ const [id, next] = nextId(m, "s");
1100
+ fx.push({ type: "Api", call: { op: "execute", flow: id, request: attempt.request } });
1101
+ return {
1102
+ ...next,
1103
+ submittedAt: now,
1104
+ sign: {
1105
+ ...flow,
1106
+ id,
1107
+ execute: { ...attempt, resends: attempt.resends + 1, resendAt: null, error: null },
1108
+ },
1109
+ };
1110
+ }
1111
+ /**
1112
+ * This client gives up resending a not-accepted execute. No Retry: the
1113
+ * Worker un-claimed the intent, so it reads `quoted` and the signed request
1114
+ * stays executable until its deadline (by anyone holding it). The intent
1115
+ * stays `executing`, marked `notSent` ("not sent yet"), and the poll, re-armed
1116
+ * off the resend wait, runs until the Worker ends the intent (README "Fund
1117
+ * safety"): `failed` with `NOT_EXECUTED_SUBSTATUS`, or a 404 past its finish
1118
+ * (`onStatus`), is Retry; any other read ends the mark (`applyStatusUpdate`).
1119
+ */
1120
+ function stopResending(m, flow, error) {
1121
+ return patch(endSignFlow(m, flow), { notSent: true, error });
1122
+ }
1123
+ /**
1124
+ * A poll tick while a resend waits reads no status (the server holds nothing
1125
+ * to report). The first tick (or returning tab) at or after `resendAt` with
1126
+ * the resend still pending does the `execute` timer's work, in case the host
1127
+ * never scheduled it: `resendExecute` resends, or past the deadline fails with
1128
+ * the refusal. The poll interval bounds how late that is (`STATUS_POLL_MS`).
1129
+ */
1130
+ function resendBackstop(m, fx, now) {
1131
+ const resendAt = m.sign?.execute?.resendAt;
1132
+ if (typeof resendAt !== "number" || now < resendAt)
1133
+ return m;
1134
+ log(fx, "warn", `${LOG} The resend is due and still pending; resending from the poll timer`);
1135
+ clearTimer(fx, "execute");
1136
+ return resendExecute(m, fx, now);
1137
+ }
1138
+ /** A not-accepted execute is waiting for its resend: no request is in flight. */
1139
+ function awaitingResend(m) {
1140
+ return m.sign?.stage === "execute" && typeof m.sign.execute?.resendAt === "number";
1141
+ }
1142
+ /**
1143
+ * The execute response a reply carries for `intentId`, its status decoded
1144
+ * (`decodeWireStatus`), or null when the execute is in doubt: a reported
1145
+ * failure, or a reply the machine cannot read as an execute response for this
1146
+ * intent (malformed, or another intent's response; intent ids compare
1147
+ * exactly). Checked here rather than trusted from the host: the verdict
1148
+ * decides whether Retry is offered on a swap whose funds may have moved. A
1149
+ * granular failure (`fill_failed`) decodes to `failed` with the granular
1150
+ * status as its substatus, so it is never the Worker's nothing-moved marker.
1151
+ * Read defensively, since a replayed or foreign message need not match its
1152
+ * type.
1153
+ */
1154
+ function executeResponseUnlessInDoubt(fx, result, intentId) {
1155
+ const reply = result;
1156
+ if (reply?.ok === false) {
1157
+ log(fx, "warn", `${LOG} Execute in-doubt; polling to resolve:`, reply.error?.detail);
1158
+ return null;
1159
+ }
1160
+ const response = reply?.ok === true ? reply.response : undefined;
1161
+ if (!isRelayedExecuteReply(response)) {
1162
+ log(fx, "error", `${LOG} Execute reply is not an execute response; in doubt, polling`);
1163
+ return null;
1164
+ }
1165
+ if (response.intentId !== intentId) {
1166
+ log(fx, "error", `${LOG} Execute reply names another intent; in doubt, polling`, {
1167
+ reply: response.intentId,
1168
+ current: intentId,
1169
+ });
1170
+ return null;
1171
+ }
1172
+ const { readToken, status, ...rest } = response;
1173
+ if (readToken !== undefined) {
1174
+ // Never read, stored or echoed: the host already put it in the message.
1175
+ log(fx, "warn", `${LOG} Execute reply carried a readToken; keep it outside the machine`);
1176
+ }
1177
+ const decoded = decodeWireStatus(status);
1178
+ const granularFailure = decoded.status === "failed" && status !== "failed";
1179
+ return {
1180
+ response: {
1181
+ ...rest,
1182
+ status: decoded.status,
1183
+ ...(granularFailure ? { substatus: status } : {}),
1184
+ },
1185
+ unmapped: decoded.kind === "unmapped",
1186
+ };
1187
+ }
1188
+ /** The fields `decideExecuteResult` reads, each of its wire type (`status` any string). */
1189
+ function isRelayedExecuteReply(value) {
1190
+ if (typeof value !== "object" || value === null)
1191
+ return false;
1192
+ const r = value;
1193
+ const optional = (v, type) => v === undefined || typeof v === type;
1194
+ return (typeof r.success === "boolean" &&
1195
+ typeof r.intentId === "string" &&
1196
+ typeof r.status === "string" &&
1197
+ optional(r.reconciling, "boolean") &&
1198
+ optional(r.settlementChainId, "number") &&
1199
+ optional(r.error, "string") &&
1200
+ optional(r.txHash, "string") &&
1201
+ optional(r.receivingTxHash, "string") &&
1202
+ optional(r.settlementTxHash, "string") &&
1203
+ optional(r.substatus, "string"));
1204
+ }
1205
+ function endSignFlow(m, flow) {
1206
+ return { ...m, sign: null, isSigningPull: flow.origin === "pull" ? false : m.isSigningPull };
1207
+ }
1208
+ /**
1209
+ * End the whole sign ceremony: no flow, no held intent signature, no step-2
1210
+ * affordance, no prompt timer. Nothing signed so far can execute after this.
1211
+ */
1212
+ function abandonSign(m, fx) {
1213
+ if (m.sign?.stage === "pull")
1214
+ clearTimer(fx, "signPrompt");
1215
+ if (awaitingResend(m))
1216
+ clearTimer(fx, "execute");
1217
+ return { ...m, sign: null, pendingIntentSig: null, awaitingPull: false, isSigningPull: false };
1218
+ }
1219
+ /**
1220
+ * A wallet step failed. From Confirm, a rejection returns to the confirm
1221
+ * surface and anything else fails the intent. From the "Sign 2 of 2" tap, a
1222
+ * rejection cancels back to confirm, and a dead or hung prompt KEEPS the step-2
1223
+ * affordance so the user can retry with the held intent signature.
1224
+ */
1225
+ function signFlowFailed(m, fx, report) {
1226
+ const flow = m.sign;
1227
+ if (!flow)
1228
+ return m;
1229
+ if (flow.stage === "pull")
1230
+ clearTimer(fx, "signPrompt");
1231
+ const next = endSignFlow(m, flow);
1232
+ if (flow.origin === "sign") {
1233
+ if (report.rejected)
1234
+ return transition(next, fx, "quoted", { error: "Signature rejected" });
1235
+ log(fx, "error", `${LOG} Sign error:`, report.detail);
1236
+ return transition(next, fx, "failed", { error: report.value ?? "Signing failed" });
1237
+ }
1238
+ if (report.rejected) {
1239
+ return transition({ ...next, awaitingPull: false }, fx, "quoted", {
1240
+ error: "Signature rejected",
1241
+ });
1242
+ }
1243
+ log(fx, "error", `${LOG} Pull sign error:`, report.detail);
1244
+ return patch(next, {
1245
+ error: "Couldn't reach the wallet. Tap to try the second signature again.",
1246
+ });
1247
+ }
1248
+ // ============================================================
1249
+ // Status
1250
+ // ============================================================
1251
+ /**
1252
+ * Apply one status update (poll or stream). A same-status update is not a
1253
+ * transition -- the table has no self-edges, and routing it through one would
1254
+ * discard the receipt hashes it carries (xochi#353) -- so its receipt fields
1255
+ * merge directly. An UNMAPPED wire status (`decodeWireStatus`) never reads as
1256
+ * progress: the intent shows "Still confirming" (reconciling), never success.
1257
+ * A `failed` after an execute or a revealed deposit address (any
1258
+ * `POLL_INFLIGHT_STATES`) is Retry only with the Worker's nothing-moved
1259
+ * marker; any other ends "may have moved" (`failureMayHaveMoved`), and either
1260
+ * way the poll stops. "Not sent yet" (`notSent`) holds only while the intent
1261
+ * reads `quoted`: any other read means an execute reached it, shown in doubt.
1262
+ */
1263
+ function applyStatusUpdate(m, fx, msg, unmapped) {
1264
+ log(fx, "debug", `${LOG} Status update:`, msg);
1265
+ const plan = mapStatusUpdate(msg);
1266
+ let next;
1267
+ if (plan.status === m.intent.status) {
1268
+ const updates = mergeSameStatusReceipt(m.intent, msg);
1269
+ next = updates ? patch(m, updates) : m;
1270
+ }
1271
+ else {
1272
+ const updates = plan.status === "failed" && POLL_INFLIGHT_STATES.includes(m.intent.status)
1273
+ ? { ...plan.updates, mayHaveMoved: failureMayHaveMoved(msg.substatus) }
1274
+ : plan.updates;
1275
+ next = transition(m, fx, plan.status, updates);
1276
+ }
1277
+ if (unmapped && POLL_INFLIGHT_STATES.includes(next.intent.status) && !next.intent.reconciling) {
1278
+ next = patch(next, { reconciling: true });
1279
+ }
1280
+ if (next.intent.notSent && plan.status !== "quoted") {
1281
+ next = patch(next, { notSent: false, reconciling: true });
1282
+ }
1283
+ return next;
1284
+ }
1285
+ function pollTick(m, fx, elapsedInterval) {
1286
+ const poll = m.poll;
1287
+ if (!poll)
1288
+ return m;
1289
+ const polls = poll.polls + 1;
1290
+ const spent = elapsedInterval ? poll.spent + 1 : poll.spent;
1291
+ fx.push({
1292
+ type: "Api",
1293
+ call: { op: "status", intentId: poll.intentId, gen: poll.gen, attempt: polls },
1294
+ });
1295
+ return { ...m, poll: { ...poll, polls, spent } };
1296
+ }
1297
+ function onStatus(m, fx, gen, attempt, result, now) {
1298
+ const poll = m.poll;
1299
+ // A reply from a disarmed poll (a phase change, a reset, a stop) is dropped.
1300
+ if (!poll || poll.gen !== gen || poll.stopped)
1301
+ return m;
1302
+ const id = poll.intentId;
1303
+ 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 &&
1312
+ result.ok &&
1313
+ !!result.status &&
1314
+ !("notFound" in result.status) &&
1315
+ result.status.status === "quoted" &&
1316
+ result.status.unmapped !== true &&
1317
+ next.intent.intentId === id &&
1318
+ next.intent.status === "executing" &&
1319
+ next.intent.quote === null &&
1320
+ !next.intent.reconciling;
1321
+ // A throw answers nothing, so only a reply (a 404 included) moves it.
1322
+ const answered = result.ok
1323
+ ? { ...poll, answered: Math.max(poll.answered, attempt) }
1324
+ : poll;
1325
+ if (resumedUnsent && !next.intent.notSent) {
1326
+ log(fx, "warn", `${LOG} Resumed intent ${id} reads quoted: not sent yet`);
1327
+ next = patch(next, { notSent: true });
1328
+ }
1329
+ // A slow execute escalates to "Still confirming"; one not sent (`notSent`)
1330
+ // has nothing in flight to confirm.
1331
+ if (attempt >= RECONCILE_AFTER_ATTEMPTS &&
1332
+ next.intent.intentId === id &&
1333
+ !next.intent.reconciling &&
1334
+ !next.intent.notSent) {
1335
+ next = patch(next, { reconciling: true });
1336
+ }
1337
+ let verdict;
1338
+ if (!result.ok) {
1339
+ // A throw is not a change: count the attempt and keep the ceiling armed.
1340
+ log(fx, "error", `${LOG} status poll threw; counting the attempt`, result.error.detail);
1341
+ }
1342
+ else if (result.status && "notFound" in result.status) {
1343
+ const finish = unexecutedFinishAt(next.intent.quote);
1344
+ if (next.intent.notSent &&
1345
+ next.intent.intentId === id &&
1346
+ finish !== null &&
1347
+ now > finish * 1000) {
1348
+ // Not sent yet, gone, and past the Worker's finish: only the quote sweep
1349
+ // deletes a row, only one nothing touched, 30 min after the quote, and
1350
+ // past the finish the claim's compare-and-swap refuses on the database
1351
+ // clock, so even a 404 from something else (a router, a proxy, a
1352
+ // rollback) leaves nothing that can execute. Retry. Before the finish
1353
+ // (or with it unknown: a resumed intent has no quote) the 404 proves
1354
+ // nothing while the signed request can still be claimed: hold, below.
1355
+ log(fx, "warn", `${LOG} A not-sent intent is gone (swept unexecuted); offering Retry`);
1356
+ next = transition(next, fx, "failed", {
1357
+ error: NOT_SENT_SWEPT_ERROR,
1358
+ reconciling: false,
1359
+ mayHaveMoved: false,
1360
+ });
1361
+ if (next.intent.status === "failed")
1362
+ verdict = "stop";
1363
+ }
1364
+ else {
1365
+ // A definitive 404 for a DISPATCHED intent is not proof of failure (#271,
1366
+ // xochi#398), nor for a not-sent one before its finish: hold, and report
1367
+ // it once per id.
1368
+ const warning = notFoundDriftWarning(id, next.notFoundReported);
1369
+ if (warning) {
1370
+ log(fx, "warn", warning);
1371
+ next = { ...next, notFoundReported: id };
1372
+ }
1373
+ }
1374
+ }
1375
+ else if (result.status) {
1376
+ const s = result.status;
1377
+ const previous = next.intent.status;
1378
+ next = applyStatusUpdate(next, fx, {
1379
+ type: "intent_status",
1380
+ intentId: id,
1381
+ status: s.status,
1382
+ txHash: s.txHash ?? undefined,
1383
+ receivingTxHash: s.receivingTxHash ?? undefined,
1384
+ settlementTxHash: s.settlementTxHash ?? undefined,
1385
+ settlementChainId: (s.settlementChainId ?? undefined),
1386
+ // A confirmed fill whose receipt the server withheld (anonymous read,
1387
+ // xochi#563) is delivered, exactly as a `filled` substatus, under the
1388
+ // rule `withheldSettlementConfirmed` states. An unmapped status is only
1389
+ // its in-flight bucket, so it never qualifies.
1390
+ substatus: withheldSettlementConfirmed({
1391
+ status: s.unmapped === true ? undefined : s.status,
1392
+ legsWithheld: s.legsWithheld,
1393
+ settlementConfirmed: s.settlementConfirmed,
1394
+ })
1395
+ ? "filled"
1396
+ : (s.substatus ?? undefined),
1397
+ error: s.error ?? undefined,
1398
+ refundReason: s.refundReason ?? undefined,
1399
+ timestamp: new Date(now).toISOString(),
1400
+ }, s.unmapped === true);
1401
+ // Each poll's own verdict: an anonymous read sets it, an owner read (no
1402
+ // marker) clears it, so the host knows when to offer SIWE recovery.
1403
+ const legsWithheld = s.legsWithheld === true;
1404
+ if (next.intent.intentId === id && next.intent.legsWithheld !== legsWithheld) {
1405
+ next = patch(next, { legsWithheld });
1406
+ }
1407
+ // 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.
1414
+ verdict = s.terminal
1415
+ ? "stop"
1416
+ : next.intent.status !== previous || (resumedUnsent && next.intent.notSent)
1417
+ ? "progress"
1418
+ : undefined;
1419
+ }
1420
+ // Terminal beats the ceiling: a swap that settles on the ceiling tick is
1421
+ // applied and stops quietly.
1422
+ if (verdict === "stop") {
1423
+ clearTimer(fx, "poll");
1424
+ return { ...next, poll: { ...answered, stopped: true } };
1425
+ }
1426
+ if (verdict === "progress")
1427
+ return { ...next, poll: { ...answered, spent: 0 } };
1428
+ if (poll.spent < poll.max)
1429
+ return { ...next, poll: answered };
1430
+ // Never a silent stop: a stopped poll and a waiting one look identical.
1431
+ clearTimer(fx, "poll");
1432
+ log(fx, "warn", `${LOG} status poll stopped after ~${Math.round((poll.spent * STATUS_POLL_MS) / 1000)}s ` +
1433
+ `with no observable change (${poll.spent} intervals, ${poll.polls} polls); leaving resolution to reconciliation/webhooks`);
1434
+ next = { ...next, poll: { ...answered, stopped: true } };
1435
+ return next.intent.intentId === id && !next.intent.pollExhausted
1436
+ ? patch(next, { pollExhausted: true })
1437
+ : next;
1438
+ }
1439
+ // ============================================================
1440
+ // Reactive rules (run after every step, until stable)
1441
+ // ============================================================
1442
+ /**
1443
+ * Every context field the quote request is built from, in a fixed order (a
1444
+ * host may rebuild the context with its keys in any order), read through the
1445
+ * request's own normalisation so two forms that send one request are one
1446
+ * trade. `paused` and `stepwise` shape the ceremony, not the trade.
1447
+ */
1448
+ function tradeKey(c) {
1449
+ const { recipientAddress, refundAddress, stealthMetaAddress } = normalizeTradeFields(c);
1450
+ return JSON.stringify([
1451
+ c.fromChainId,
1452
+ c.toChainId,
1453
+ c.fromToken,
1454
+ c.toToken,
1455
+ c.settlementPreference,
1456
+ c.trustScore,
1457
+ c.slippageBps,
1458
+ recipientAddress ?? null,
1459
+ refundAddress ?? null,
1460
+ stealthMetaAddress?.spendingPubKey ?? null,
1461
+ stealthMetaAddress?.viewingPubKey ?? null,
1462
+ stealthMetaAddress?.chainId ?? null,
1463
+ c.wallet.address,
1464
+ c.wallet.chainId,
1465
+ ]);
1466
+ }
1467
+ /**
1468
+ * The held quote still describes the trade on the form: the amount and every
1469
+ * request field it was quoted for. Confirm and the receipt's signing slot both
1470
+ * read it, so neither offers to sign a quote for a trade the user has edited.
1471
+ */
1472
+ export function holdsCurrentQuote(m) {
1473
+ return m.intent.quotedFromAmount === m.amount && m.quotedTrade === tradeKey(m.ctx);
1474
+ }
1475
+ function settle(model, now, fx) {
1476
+ let m = model;
1477
+ // Each rule records the inputs it last ran on, so it fires once per change;
1478
+ // a rule's own output can trigger another, hence the (bounded) loop.
1479
+ for (let pass = 0; pass < 4; pass++) {
1480
+ const before = m;
1481
+ m = watchWallet(m, fx);
1482
+ m = upgradePreview(m, fx, now);
1483
+ m = armExpiry(m, fx, now);
1484
+ m = armPoll(m, fx, now);
1485
+ if (m === before)
1486
+ break;
1487
+ }
1488
+ return m;
1489
+ }
1490
+ /**
1491
+ * A quote is bound to the account and chain it was produced under. If either
1492
+ * changes while a pre-execution quote is held, reset: the panel re-quotes for
1493
+ * the new wallet. An in-flight settlement is never yanked out from under, and
1494
+ * neither is a "may have moved" ending (it holds until Reset).
1495
+ */
1496
+ function watchWallet(m, fx) {
1497
+ const { address, chainId } = m.ctx.wallet;
1498
+ const { quotedAddress, quotedChainId } = m.intent;
1499
+ const key = JSON.stringify([address, chainId, quotedAddress, quotedChainId]);
1500
+ if (key === m.watched.wallet)
1501
+ return m;
1502
+ const next = { ...m, watched: { ...m.watched, wallet: key } };
1503
+ if (!quotedAddress)
1504
+ return next;
1505
+ const accountChanged = !address || address.toLowerCase() !== quotedAddress.toLowerCase();
1506
+ const chainChanged = quotedChainId != null && chainId !== quotedChainId;
1507
+ if (!accountChanged && !chainChanged)
1508
+ return next;
1509
+ if (!WALLET_INTERRUPTIBLE_STATES.includes(next.intent.status) || next.intent.mayHaveMoved) {
1510
+ return next;
1511
+ }
1512
+ clearTimer(fx, "debounce");
1513
+ clearTimer(fx, "refresh");
1514
+ clearTimer(fx, "signPrompt");
1515
+ // Including any half-finished two-step signing, so a wallet switch mid-sign
1516
+ // cannot strand "Sign 2 of 2" or reuse a stale intent signature.
1517
+ return {
1518
+ ...next,
1519
+ intent: INITIAL_INTENT_STATE,
1520
+ refresh: null,
1521
+ awaitingPull: false,
1522
+ isSigningPull: false,
1523
+ pendingIntentSig: null,
1524
+ sign: null,
1525
+ };
1526
+ }
1527
+ /**
1528
+ * Replace a held preview with a wallet-bound executable quote once a wallet is
1529
+ * present: silently (the price must not flash back to "quoting") and forced (a
1530
+ * preview carries no signing material and must never be served from cache as
1531
+ * the executable quote).
1532
+ */
1533
+ function upgradePreview(m, fx, now) {
1534
+ const isPreview = m.intent.quote?.preview === true;
1535
+ const key = JSON.stringify([tradeKey(m.ctx), m.amount, isPreview, m.intent.status]);
1536
+ if (key === m.watched.upgrade)
1537
+ return m;
1538
+ const next = { ...m, watched: { ...m.watched, upgrade: key } };
1539
+ const upgrade = shouldUpgradePreviewQuote({
1540
+ connected: !!next.ctx.wallet.address,
1541
+ hasAmount: !!next.amount,
1542
+ status: next.intent.status,
1543
+ quoteIsPreview: isPreview,
1544
+ isDepositOrigin: !isChainId(next.ctx.fromChainId),
1545
+ });
1546
+ if (!upgrade)
1547
+ return next;
1548
+ return startQuote(next, fx, now, {
1549
+ amount: next.amount,
1550
+ silent: true,
1551
+ forceRefresh: true,
1552
+ allocate: null,
1553
+ });
1554
+ }
1555
+ /**
1556
+ * The quote countdown, armed while a quote is held. An allocated deposit route
1557
+ * is governed by its attested deposit deadline instead, never by the short
1558
+ * price expiry.
1559
+ */
1560
+ function armExpiry(m, fx, now) {
1561
+ const quote = m.intent.quote;
1562
+ const key = m.intent.status === "quoted" && quote?.expiry && !quote.depositAddress ? quote.expiry : null;
1563
+ if (key === m.watched.expiry)
1564
+ return m;
1565
+ const next = { ...m, watched: { ...m.watched, expiry: key } };
1566
+ if (m.watched.expiry !== null)
1567
+ clearTimer(fx, "expiry");
1568
+ if (key === null)
1569
+ return { ...next, quoteExpiresIn: null };
1570
+ setTimer(fx, "expiry", 1000, true);
1571
+ return tickExpiry(next, fx, now);
1572
+ }
1573
+ /**
1574
+ * The status poll: armed per (intent, phase) while in flight. Re-armed PER
1575
+ * PHASE deliberately: the ceiling is sized from the phase being entered, and
1576
+ * the reconcile escalation counts polls within one phase. While a not-accepted
1577
+ * execute waits for its resend it is armed under its own key and reads no
1578
+ * status (the server holds nothing to report): its ticks only back up the
1579
+ * `execute` timer (`resendBackstop`), and the resend re-arms the real poll.
1580
+ */
1581
+ function armPoll(m, fx, now) {
1582
+ const { intentId, status } = m.intent;
1583
+ const waiting = awaitingResend(m);
1584
+ const key = POLL_INFLIGHT_STATES.includes(status) && intentId
1585
+ ? `${intentId}:${status}${waiting ? ":resend" : ""}`
1586
+ : null;
1587
+ if (key === (m.poll?.key ?? null))
1588
+ return m;
1589
+ if (m.poll && !m.poll.stopped)
1590
+ clearTimer(fx, "poll");
1591
+ if (key === null || !intentId)
1592
+ return { ...m, poll: null };
1593
+ const [gen, next] = [m.seq + 1, { ...m, seq: m.seq + 1 }];
1594
+ const poll = {
1595
+ key,
1596
+ intentId,
1597
+ gen,
1598
+ polls: 0,
1599
+ answered: 0,
1600
+ spent: 0,
1601
+ // Read once at arm time: the deadlines are fixed for the life of a phase.
1602
+ // The deposit deadline comes from the INTENT (a resumed swap has no
1603
+ // quote); the Worker's finish of an unclaimed intent from the quote.
1604
+ max: pollCeilingAttempts(status, m.intent.depositDeadline ?? undefined, now, STATUS_POLL_MS, STATUS_POLL_FLAT_CEILING_MS, unexecutedFinishAt(m.intent.quote) ?? undefined),
1605
+ stopped: false,
1606
+ };
1607
+ setTimer(fx, "poll", STATUS_POLL_MS, true);
1608
+ return waiting ? { ...next, poll } : pollTick({ ...next, poll }, fx, false);
1609
+ }