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