@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,665 @@
1
+ "use strict";
2
+ /**
3
+ * Intent lifecycle decision logic (pure).
4
+ *
5
+ * The fund-critical + behavior-defining decisions of the intent lifecycle, each
6
+ * a pure function of its inputs: no React, wagmi, network or clock coupling.
7
+ * `update` (machine.ts) maps each decision onto its state transitions and
8
+ * effects; these stay separately exported so each rule is assertable alone.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.LOADING_STATES = exports.POLL_CEILING_MAX_MS = exports.DEPOSIT_POLL_GRACE_MS = exports.STATUS_POLL_FLAT_CEILING_MS = exports.STATUS_POLL_FLAT_CEILING_ATTEMPTS = exports.STATUS_POLL_MS = exports.POLL_INFLIGHT_STATES = exports.WALLET_INTERRUPTIBLE_STATES = exports.ENDED_STATES = exports.ALLOCATABLE_STATES = exports.QUOTE_BUSY_STATES = void 0;
12
+ exports.failureMayHaveMoved = failureMayHaveMoved;
13
+ exports.unexecutedFinishAt = unexecutedFinishAt;
14
+ exports.pollCeilingAttempts = pollCeilingAttempts;
15
+ exports.canAdoptResumedIntent = canAdoptResumedIntent;
16
+ exports.buildResumedState = buildResumedState;
17
+ exports.normalizeTradeFields = normalizeTradeFields;
18
+ exports.buildQuoteRequest = buildQuoteRequest;
19
+ exports.quoteAccessDecision = quoteAccessDecision;
20
+ exports.shouldUpgradePreviewQuote = shouldUpgradePreviewQuote;
21
+ exports.canSignQuote = canSignQuote;
22
+ exports.canAllocateDepositQuote = canAllocateDepositQuote;
23
+ exports.computeRefreshDelayMs = computeRefreshDelayMs;
24
+ exports.shouldRefreshQuote = shouldRefreshQuote;
25
+ exports.shouldRefreshOnReturn = shouldRefreshOnReturn;
26
+ exports.decideSignPreflight = decideSignPreflight;
27
+ exports.decideExecuteResult = decideExecuteResult;
28
+ exports.decideExecuteResend = decideExecuteResend;
29
+ exports.executeDeadline = executeDeadline;
30
+ exports.mapStatusUpdate = mapStatusUpdate;
31
+ exports.mapStatusFrame = mapStatusFrame;
32
+ exports.notFoundDriftWarning = notFoundDriftWarning;
33
+ exports.mergeSameStatusReceipt = mergeSameStatusReceipt;
34
+ const index_js_1 = require("../shared/index.js");
35
+ const state_js_1 = require("./state.js");
36
+ // ============================================================
37
+ // Lifecycle state sets
38
+ // ============================================================
39
+ /** Quote requests are ignored while in one of these states (mid sign/execute). */
40
+ exports.QUOTE_BUSY_STATES = [
41
+ "signing",
42
+ "awaiting_deposit",
43
+ "executing",
44
+ "bridging",
45
+ "settling",
46
+ ];
47
+ /**
48
+ * States an explicit deposit-route allocation may start from: before any
49
+ * signature or deposit. Past them the trade has a ceremony, a swap or an
50
+ * outcome a fresh route would land on.
51
+ */
52
+ exports.ALLOCATABLE_STATES = ["idle", "pending", "quoting", "quoted"];
53
+ /** A delivered or returned trade: a late quote reply may not reopen it. */
54
+ exports.ENDED_STATES = ["completed", "refunded"];
55
+ /** A pre-execution quote is reset when the wallet context changes in these states. */
56
+ exports.WALLET_INTERRUPTIBLE_STATES = [
57
+ "quoted",
58
+ "signing",
59
+ "expired",
60
+ "failed",
61
+ ];
62
+ /**
63
+ * The status poll runs while in one of these in-flight states. Each follows an
64
+ * execute or a revealed deposit address, so a `failed` read from one is Retry
65
+ * only on the Worker's nothing-moved marker (`failureMayHaveMoved`): a deposit
66
+ * route's `failed` can follow a deposit the poll never saw.
67
+ */
68
+ exports.POLL_INFLIGHT_STATES = [
69
+ "awaiting_deposit",
70
+ "executing",
71
+ "bridging",
72
+ "settling",
73
+ ];
74
+ /**
75
+ * Whether a `failed` after an execute (or a revealed deposit address) may have
76
+ * moved funds: every one but the Worker's nothing-moved finish (`substatus`
77
+ * `NOT_EXECUTED_SUBSTATUS`, whose sources that constant lists). The machine
78
+ * shows it as the terminal "may have moved" view (no Retry, no poll, Reset
79
+ * only); rules: README "Fund safety".
80
+ */
81
+ function failureMayHaveMoved(substatus) {
82
+ return substatus !== index_js_1.NOT_EXECUTED_SUBSTATUS;
83
+ }
84
+ /**
85
+ * The latest time (unix seconds) the Worker's read finish ends this quote's
86
+ * intent `failed` with `NOT_EXECUTED_SUBSTATUS` while it is still `quoted`
87
+ * with no execute claim (never claimed, or every claim undone): the next read
88
+ * past its signed deadline plus `QUOTED_EXPIRY_GRACE_SECS` (market rows only;
89
+ * the quote sweep usually ends an undone claim earlier). The signed deadline
90
+ * is the served `eip712` message's `deadline`; the later of it and the
91
+ * quote's `expiry` is taken, so the poll never stops short of the Worker's
92
+ * clock. Either alone is enough; null only without a quote or with neither
93
+ * readable.
94
+ */
95
+ function unexecutedFinishAt(quote) {
96
+ if (!quote)
97
+ return null;
98
+ const expiryMs = new Date(quote.expiry).getTime();
99
+ const raw = quote.eip712Data?.message?.deadline;
100
+ const signed = typeof raw === "string" && /^\d+$/.test(raw) ? Number(raw) : raw;
101
+ const candidates = [
102
+ Number.isFinite(expiryMs) ? Math.floor(expiryMs / 1000) : NaN,
103
+ typeof signed === "number" ? signed : NaN,
104
+ ].filter((v) => Number.isFinite(v));
105
+ return candidates.length > 0 ? Math.max(...candidates) + index_js_1.QUOTED_EXPIRY_GRACE_SECS : null;
106
+ }
107
+ // ============================================================
108
+ // Status poll cadence and ceilings
109
+ // ============================================================
110
+ /**
111
+ * In-flight status poll cadence. THE cadence: every client poll of
112
+ * `/api/intent/:id/status` runs on this, the machine's own and the activity
113
+ * feeds' (`useIntentStatusPoll`).
114
+ */
115
+ exports.STATUS_POLL_MS = 4000;
116
+ /**
117
+ * Hard ceiling on in-flight polling, counted in elapsed POLL INTERVALS -- the
118
+ * only ticks that are wall clock. The arming poll happens at elapsed zero and a
119
+ * foreground snap-forward is off-interval, so neither spends this. Counting
120
+ * every poll would let a user backgrounding and foregrounding through a wallet
121
+ * deep-link burn the whole budget in seconds, while the give-up warning
122
+ * asserts an elapsed ten minutes that never happened.
123
+ *
124
+ * It is a budget for NOTHING HAPPENING, not for polling: a poll reporting
125
+ * progress refills it. Past this cap the client stops polling a stuck solver
126
+ * and leaves resolution to reconciliation/webhooks.
127
+ *
128
+ * This is the ceiling for a phase whose duration WE control. `awaiting_deposit`
129
+ * is not one: the solver sets that window (Riddler defaults to 1800s), so its
130
+ * ceiling is derived from the attested deadline instead (`pollCeilingAttempts`).
131
+ */
132
+ exports.STATUS_POLL_FLAT_CEILING_ATTEMPTS = 150;
133
+ /**
134
+ * The flat ceiling as wall clock (10 min); see above for why the attempt count
135
+ * is the primary form and this is derived from it. `pollCeilingAttempts` takes
136
+ * the duration, because the deposit window it competes with is a duration too.
137
+ */
138
+ exports.STATUS_POLL_FLAT_CEILING_MS = exports.STATUS_POLL_FLAT_CEILING_ATTEMPTS * exports.STATUS_POLL_MS;
139
+ /**
140
+ * Keep polling this long past the deposit deadline.
141
+ *
142
+ * The deadline is when the solver stops FILLING, not when it stops reporting. A
143
+ * transfer broadcast just inside the window can confirm outside it, and a late
144
+ * one is refunded rather than filled -- both are status changes the user is
145
+ * waiting on, and both land after the countdown reads zero.
146
+ */
147
+ exports.DEPOSIT_POLL_GRACE_MS = 10 * 60_000;
148
+ /**
149
+ * Absolute ceiling, whatever a deadline claims. A malformed or hostile
150
+ * `deposit_deadline` far in the future must not turn the poll into a permanent
151
+ * background request loop.
152
+ */
153
+ exports.POLL_CEILING_MAX_MS = 2 * 60 * 60_000;
154
+ /**
155
+ * How many polls to run before giving up, for the phase being entered.
156
+ *
157
+ * `awaiting_deposit` is the one phase whose duration the SOLVER sets: Riddler's
158
+ * deposit window defaults to 1800s. The flat ~10 minute ceiling was written for
159
+ * a wedged solver on the EVM path, and applying it here stopped the poll at a
160
+ * third of the window. A deposit that landed after minute ten never advanced the
161
+ * modal, so a swap that had actually settled sat on a spinner, and the user was
162
+ * then told their funds were coming back and to start again.
163
+ *
164
+ * `executing` lasts at least until the Worker's latest finish of an intent no
165
+ * execute holds (`unexecutedFinishAt`, passed as `unexecutedFinishSecs`) plus
166
+ * one poll: an execute that never reached the Worker (a timeout, a refused
167
+ * redirect) or that this client stopped resending reads `quoted`, which is no
168
+ * progress, until the quote sweep (usually first for an un-claimed intent) or
169
+ * the read past the signed deadline finishes it `failed` with
170
+ * `NOT_EXECUTED_SUBSTATUS` and offers Retry.
171
+ *
172
+ * Bounded below by the flat ceiling (never poll less than before), and above by
173
+ * POLL_CEILING_MAX_MS.
174
+ */
175
+ function pollCeilingAttempts(status, depositDeadlineSecs, nowMs, pollMs, flatCeilingMs, unexecutedFinishSecs) {
176
+ const flat = Math.ceil(flatCeilingMs / pollMs);
177
+ let windowMs = NaN;
178
+ if (status === "awaiting_deposit" && depositDeadlineSecs) {
179
+ windowMs = depositDeadlineSecs * 1000 - nowMs + exports.DEPOSIT_POLL_GRACE_MS;
180
+ }
181
+ else if (status === "executing" && unexecutedFinishSecs) {
182
+ windowMs = unexecutedFinishSecs * 1000 - nowMs + pollMs;
183
+ }
184
+ if (!Number.isFinite(windowMs) || windowMs <= 0)
185
+ return flat;
186
+ return Math.max(flat, Math.ceil(Math.min(windowMs, exports.POLL_CEILING_MAX_MS) / pollMs));
187
+ }
188
+ /**
189
+ * May a resumed intent (restored from the persisted activity log after a mobile
190
+ * tab eviction) be adopted into this state?
191
+ *
192
+ * Only from a clean slate. `resume` exists to restore a swap the machine has no
193
+ * memory of, so ANY live state means it is already tracking something --
194
+ * and overwriting that with a stale stored record would point the status poll,
195
+ * the modal, and the user's read of "what am I waiting on" at the wrong intent
196
+ * while a real one is mid-flight. Fails closed: unknown state means no adopt.
197
+ * See xochi#339.
198
+ */
199
+ function canAdoptResumedIntent(state) {
200
+ return state.status === "idle" && !state.intentId;
201
+ }
202
+ /**
203
+ * The state to hold after adopting a resumed intent, or `prev` UNCHANGED when the
204
+ * adopt must be refused. The machine's `resume` message applies exactly this,
205
+ * so the whole rule is assertable on its own.
206
+ *
207
+ * Three things it has to get right, all of them fund- or privacy-relevant:
208
+ *
209
+ * - Refuse unless the hook is on a clean slate (`canAdoptResumedIntent`), so a
210
+ * stale stored record can never clobber a real trade that is mid-flight.
211
+ * - Refuse an adopt with no `owner`. This is the only intent in the app whose
212
+ * owner does not come from a quote made this session (it comes from the storage
213
+ * key of the record), so without it the adopted state would be the only
214
+ * intentId-bearing state with `quotedAddress: null` -- and the panel's
215
+ * ownership fallback (`intent.quotedAddress ?? address`) silently becomes the
216
+ * LIVE wallet, copying this swap's amounts and tx hashes into whichever account
217
+ * the user switches to next.
218
+ * - Build from `INITIAL_INTENT_STATE`, not from `prev`. A spread of `prev` would
219
+ * carry a stale quote, error or receipt hash into a swap it does not describe.
220
+ *
221
+ * `depositDeadline` is the one served fact that must come ACROSS, and it is not
222
+ * an exception to the rule above. No quote is synthesized, because a stale price
223
+ * shown as current would be a lie; a deadline does not go stale, it expires, and
224
+ * carrying it is what lets the poll say so. Omitting it left
225
+ * `pollCeilingAttempts` on its flat 10-minute ceiling while the restored deposit
226
+ * screen counted down the solver's real 30-minute window, so the poll stopped and
227
+ * announced it beside a countdown that disagreed. Null on every pull route.
228
+ *
229
+ * See xochi#339.
230
+ */
231
+ function buildResumedState(prev, intentId, status, owner, depositDeadline = null) {
232
+ if (!intentId || !owner || !canAdoptResumedIntent(prev))
233
+ return prev;
234
+ return { ...state_js_1.INITIAL_INTENT_STATE, intentId, status, quotedAddress: owner, depositDeadline };
235
+ }
236
+ /** Statuses that render the panel as loading. */
237
+ exports.LOADING_STATES = [
238
+ "quoting",
239
+ "signing",
240
+ "executing",
241
+ "bridging",
242
+ "settling",
243
+ ];
244
+ /**
245
+ * The request fields the server reads through a normalisation: an empty
246
+ * `recipientAddress` or `refundAddress` is undefined (EVM routes settle to, and
247
+ * refund to, the connected wallet) and the stealth meta-address is sent ONLY
248
+ * on a stealth settlement. The machine's trade key reads the same values, so
249
+ * two forms that build one request are one trade.
250
+ */
251
+ function normalizeTradeFields(input) {
252
+ return {
253
+ // Trimmed HERE, at the single point the request is built, because the
254
+ // deposit attestation rebuilds both of these from the same panel state and
255
+ // compares bytes. Sending " 0xabc" while rebuilding "0xabc" is a message
256
+ // that cannot recover, reported to the user as a route being unavailable.
257
+ // One deal, one encoding.
258
+ recipientAddress: input.recipientAddress?.trim() || undefined,
259
+ refundAddress: input.refundAddress?.trim() || undefined,
260
+ stealthMetaAddress: input.settlementPreference === "stealth"
261
+ ? (input.stealthMetaAddress ?? undefined)
262
+ : undefined,
263
+ };
264
+ }
265
+ /** Build the quote request body, with the trade fields normalised. */
266
+ function buildQuoteRequest(input) {
267
+ return {
268
+ wallet: input.wallet,
269
+ preview: input.preview,
270
+ allocationKey: input.allocationKey,
271
+ fromChainId: input.fromChainId,
272
+ toChainId: input.toChainId,
273
+ fromToken: input.fromToken,
274
+ toToken: input.toToken,
275
+ fromAmount: input.fromAmount,
276
+ deadline: input.deadline,
277
+ slippageBps: input.slippageBps,
278
+ trustScore: input.trustScore,
279
+ settlementPreference: input.settlementPreference,
280
+ ...normalizeTradeFields(input),
281
+ };
282
+ }
283
+ /**
284
+ * Decide whether pricing is indicative or executable.
285
+ *
286
+ * Deposit-origin pricing is always a preview until the caller explicitly asks
287
+ * to allocate a route. That keeps amount editing side-effect free even for a
288
+ * connected wallet. Private settlement still needs wallet-derived keys.
289
+ */
290
+ function quoteAccessDecision(input) {
291
+ if (input.settlementPreference !== "public") {
292
+ return input.connected ? "executable" : "needs_wallet";
293
+ }
294
+ if (input.isDepositOrigin) {
295
+ if (!input.allocateDepositRoute)
296
+ return "preview";
297
+ return input.connected ? "executable" : "needs_wallet";
298
+ }
299
+ return input.connected ? "executable" : "preview";
300
+ }
301
+ /**
302
+ * Replace a held preview with a wallet-bound executable quote?
303
+ *
304
+ * True exactly when a wallet is present while a preview is the quote on
305
+ * screen. The caller refreshes silently (the visible price must not flash back
306
+ * to "quoting" on connect) and force-refreshes (a preview prices at Standard
307
+ * tier and carries no signing material, so it must never be served from cache
308
+ * as an executable quote).
309
+ */
310
+ function shouldUpgradePreviewQuote(input) {
311
+ return (!input.isDepositOrigin &&
312
+ input.connected &&
313
+ input.hasAmount &&
314
+ input.status === "quoted" &&
315
+ input.quoteIsPreview);
316
+ }
317
+ /**
318
+ * The signing gate. A preview is never signable whatever else it claims: the
319
+ * server strips its EIP-712 payload, so the wallet/session boundary stays at
320
+ * execution even though the price itself was served anonymously.
321
+ */
322
+ function canSignQuote(status, quote) {
323
+ return status === "quoted" && quote !== null && quote.canSolve && !quote.preview;
324
+ }
325
+ /** True when the current indicative deposit price can be explicitly allocated. */
326
+ function canAllocateDepositQuote(connected, isDepositOrigin, status, quote) {
327
+ return (connected &&
328
+ isDepositOrigin &&
329
+ status === "quoted" &&
330
+ quote !== null &&
331
+ quote.canSolve &&
332
+ quote.preview === true);
333
+ }
334
+ /**
335
+ * The pre-expiry auto-refresh delay in ms: how long until we should re-fetch to
336
+ * replace an expiring quote (`buffer` before expiry). A non-positive result means
337
+ * the quote is already inside the buffer window and no refresh is scheduled.
338
+ */
339
+ function computeRefreshDelayMs(expiryIso, now, bufferMs) {
340
+ return new Date(expiryIso).getTime() - now - bufferMs;
341
+ }
342
+ /**
343
+ * Whether the expiry timer should re-quote.
344
+ *
345
+ * Indicative pricing is allocation-free on every origin and may stay live.
346
+ * Once a deposit address exists, its `deposit_deadline` owns the lifecycle; a
347
+ * 30-second price refresh must never replace or invalidate that route.
348
+ */
349
+ function shouldRefreshQuote(input) {
350
+ if (input.status !== "quoted")
351
+ return false;
352
+ if (input.hidden)
353
+ return false;
354
+ return !input.hasAllocatedDeposit;
355
+ }
356
+ /**
357
+ * Whether returning to a foregrounded tab should re-quote.
358
+ *
359
+ * The confirm modal owns its own refresh. An allocated deposit route is also
360
+ * immutable until its deposit deadline; all other quoted/expired prices may be
361
+ * refreshed on return.
362
+ */
363
+ function shouldRefreshOnReturn(input) {
364
+ if (input.paused || input.hasAllocatedDeposit)
365
+ return false;
366
+ return input.status === "quoted" || input.status === "expired";
367
+ }
368
+ const WALLET_CHANGED = {
369
+ kind: "reject",
370
+ to: "quoted",
371
+ refusal: { kind: "wallet_changed" },
372
+ error: "The wallet or network changed. Get a fresh quote.",
373
+ };
374
+ /**
375
+ * Decide whether a quote may be signed, in the exact precedence the hook
376
+ * enforces (all fail-closed, no funds move on a reject):
377
+ * 1. Deposit route (Tron origin) -> no signature; reveal the address.
378
+ * 2. No EIP-712 payload -> nothing to sign.
379
+ * 3. Wallet account changed since the quote -> re-quote (`quoted`).
380
+ * 4. Wallet not on the origin chain -> refuse (`quoted`); the domain binds it.
381
+ * 5. Wallet chain changed since the quote -> re-quote (`quoted`).
382
+ * 6. Quote already expired -> `expired`.
383
+ * 7. Otherwise proceed.
384
+ */
385
+ function decideSignPreflight(input) {
386
+ if (input.hasDepositAddress)
387
+ return { kind: "awaitingDeposit" };
388
+ if (!input.hasEip712)
389
+ return { kind: "noQuote" };
390
+ const accountChanged = !input.address ||
391
+ (input.quotedAddress != null &&
392
+ input.address.toLowerCase() !== input.quotedAddress.toLowerCase());
393
+ if (accountChanged)
394
+ return WALLET_CHANGED;
395
+ // A signature from any other chain is one the solver rejects, and on a chain
396
+ // the wallet cannot serve it fails as an opaque wallet error. The panel
397
+ // prompts the switch before this is reachable; this is the backstop.
398
+ if (input.chainId !== input.originChainId) {
399
+ return {
400
+ kind: "reject",
401
+ to: "quoted",
402
+ refusal: { kind: "wrong_chain", chainId: input.originChainId },
403
+ error: `Switch the wallet to ${index_js_1.EXTENDED_CHAIN_NAMES[input.originChainId]} to sign.`,
404
+ };
405
+ }
406
+ const chainChanged = input.quotedChainId != null && input.chainId !== input.quotedChainId;
407
+ if (chainChanged)
408
+ return WALLET_CHANGED;
409
+ if (input.expiryMs && input.now >= input.expiryMs) {
410
+ return { kind: "reject", to: "expired", error: "Quote expired" };
411
+ }
412
+ return { kind: "proceed" };
413
+ }
414
+ /**
415
+ * Interpret an /execute response, preserving the in-doubt distinction (#226):
416
+ * the worker returns `{success:false}` for BOTH a genuine failure AND a
417
+ * fail-closed downgrade (the origin pull already moved funds but the
418
+ * settlement is unconfirmed inline, so IN-DOUBT). `reconciling` is the
419
+ * server's flag when it sends one, else on whenever the server withheld
420
+ * success (`reconciling ?? !success`). The verdicts, in order:
421
+ * - `failed` with `success: false` and `reconciling` off by that rule (the
422
+ * server sent `reconciling: false`) is final: Retry only with the Worker's
423
+ * nothing-moved marker, else "may have moved" (`failureMayHaveMoved`).
424
+ * - `completed` completes with reconciling off, whatever the reply says: a
425
+ * completion leaves nothing to reconcile.
426
+ * - Any other `failed` (`success: true`, or `reconciling` on, the absent flag
427
+ * included) is in doubt: it moves to the in-flight `executing` with
428
+ * reconciling on, and the poll reads the final status.
429
+ * - Anything else advances to its status with reconciling by that rule, so a
430
+ * withheld success shows the "Still confirming" path with no Retry (an
431
+ * in-session retry of a funds-moved swap would be a double execution).
432
+ * `onExecuted` also shows an unmapped status in doubt.
433
+ */
434
+ function decideExecuteResult(result) {
435
+ const reconciling = result.reconciling ?? !result.success;
436
+ if (result.status === "failed" && !result.success && !reconciling) {
437
+ return {
438
+ kind: "failed",
439
+ error: result.error || "Execution failed",
440
+ mayHaveMoved: failureMayHaveMoved(result.substatus),
441
+ };
442
+ }
443
+ const base = {
444
+ txHash: result.txHash || null,
445
+ receivingTxHash: result.receivingTxHash || null,
446
+ settlementTxHash: result.settlementTxHash || null,
447
+ settlementChainId: result.settlementChainId ?? null,
448
+ };
449
+ if (result.status === "completed") {
450
+ return { kind: "transition", status: "completed", updates: { ...base, reconciling: false } };
451
+ }
452
+ if (result.status === "failed") {
453
+ return { kind: "transition", status: "executing", updates: { ...base, reconciling: true } };
454
+ }
455
+ return { kind: "transition", status: result.status, updates: { ...base, reconciling } };
456
+ }
457
+ /**
458
+ * How far before `now` a not-accepted `retryAfter` may lie and still be read
459
+ * as the absolute target it must be. The transport rounds the target up from
460
+ * the moment it read the refusal, so a real one is never older than the
461
+ * report's dispatch delay; a minute is past any quote's life (30 s), so no
462
+ * resend is lost to it. An older value is a delta passed where the absolute
463
+ * target belongs (`retry_after: 7` read as 1970), which would resend at once
464
+ * and ignore the server's wait.
465
+ */
466
+ const RETRY_AFTER_MAX_AGE_SECS = 60;
467
+ /**
468
+ * After the server refused an execute before accepting anything, whether to
469
+ * send the SAME request again and when (rules: README "Fund safety").
470
+ * `retryAfter` and `deadline` are unix seconds, `now` the host's clock; a null
471
+ * `deadline` never resends, a `retryAfter` older than
472
+ * `RETRY_AFTER_MAX_AGE_SECS` gives up, and the wait is at least a second, so
473
+ * a slightly past `retryAfter` cannot spin. A give-up offers no Retry: the
474
+ * signed request stays executable until its deadline, so the machine shows
475
+ * "not sent yet" (`IntentState.notSent`) and polls until the Worker finishes
476
+ * the intent.
477
+ */
478
+ function decideExecuteResend(input) {
479
+ if (input.resends >= index_js_1.MAX_EXECUTE_RESENDS)
480
+ return { kind: "give_up", reason: "exhausted" };
481
+ const { retryAfter, deadline } = input;
482
+ if (retryAfter === undefined || !Number.isFinite(retryAfter)) {
483
+ return { kind: "give_up", reason: "no_retry_after" };
484
+ }
485
+ if (retryAfter * 1000 < input.now - RETRY_AFTER_MAX_AGE_SECS * 1000) {
486
+ return { kind: "give_up", reason: "stale_retry_after" };
487
+ }
488
+ const sendAt = Math.max(retryAfter * 1000, input.now + 1000);
489
+ if (deadline === null || sendAt > (deadline - index_js_1.EXECUTE_RESEND_MARGIN_SECS) * 1000) {
490
+ return { kind: "give_up", reason: "deadline" };
491
+ }
492
+ return { kind: "resend", delayMs: sendAt - input.now };
493
+ }
494
+ /**
495
+ * The earliest deadline (unix seconds) a resent execute carries: the quote's
496
+ * expiry, the signed intent's `deadline` and the origin pull's `validBefore` /
497
+ * `deadline`. Null without a readable quote expiry, which never resends.
498
+ */
499
+ function executeDeadline(quote) {
500
+ const expiryMs = quote ? new Date(quote.expiry).getTime() : NaN;
501
+ if (!quote || !Number.isFinite(expiryMs))
502
+ return null;
503
+ const signed = [
504
+ quote.eip712Data?.message?.deadline,
505
+ quote.pullAuthorization?.message?.validBefore,
506
+ quote.pullAuthorization?.message?.deadline,
507
+ ].flatMap((v) => {
508
+ const n = typeof v === "string" && /^\d+$/.test(v) ? Number(v) : v;
509
+ return typeof n === "number" && Number.isFinite(n) ? [n] : [];
510
+ });
511
+ return Math.min(Math.floor(expiryMs / 1000), ...signed);
512
+ }
513
+ // ============================================================
514
+ // Status update (poll / message -> transition)
515
+ // ============================================================
516
+ /**
517
+ * Map an intent status message (from the poll) to the transition to apply. A
518
+ * refund is fund-safe and terminal: its reason rides `refundReason` (plain
519
+ * language), never `error`. Mirrors the poll's fail-closed handling.
520
+ */
521
+ function mapStatusUpdate(msg) {
522
+ if (msg.status === "completed") {
523
+ return {
524
+ status: "completed",
525
+ updates: {
526
+ txHash: msg.txHash || null,
527
+ receivingTxHash: msg.receivingTxHash || null,
528
+ settlementTxHash: msg.settlementTxHash || null,
529
+ settlementChainId: msg.settlementChainId ?? null,
530
+ reconciling: false,
531
+ },
532
+ };
533
+ }
534
+ if (msg.status === "refunded") {
535
+ return {
536
+ status: "refunded",
537
+ updates: {
538
+ error: null,
539
+ reconciling: false,
540
+ refundReason: (0, index_js_1.getRefundReasonLabel)(msg.refundReason),
541
+ },
542
+ };
543
+ }
544
+ if (msg.status === "failed") {
545
+ return {
546
+ status: "failed",
547
+ updates: { error: msg.error || "Intent failed", reconciling: false },
548
+ };
549
+ }
550
+ return {
551
+ status: msg.status,
552
+ updates: {
553
+ receivingTxHash: msg.receivingTxHash || null,
554
+ settlementTxHash: msg.settlementTxHash || null,
555
+ settlementChainId: msg.settlementChainId ?? null,
556
+ substatus: msg.substatus ?? null,
557
+ reconciling: msg.reconciling ?? false,
558
+ },
559
+ };
560
+ }
561
+ /**
562
+ * Read a lifecycle frame from the intent stream (`IntentStatusFrame`, the
563
+ * worker's `{ type: "status", intent_id, status, revision, ... }`, D5 5.9).
564
+ *
565
+ * Returns null for anything that is not a CURRENT status frame: a value that
566
+ * does not parse, or a frame at or below `lastRevision` (the relay replays its
567
+ * buffer to every (re)connecting subscriber and deferred publishes can land out
568
+ * of order, so a stale frame must not be read as the current status).
569
+ *
570
+ * Otherwise returns the frame's `revision`, which the caller records, and the
571
+ * status `message` for `handleStatusUpdate`, or a null message when the status
572
+ * is outside POLL_INFLIGHT_STATES (terminals included). The frame carries none
573
+ * of what the poll returns with a terminal (receipt hashes, the failure reason,
574
+ * the refund reason; `tx_hash` only on a public settlement), and a terminal
575
+ * status stops the poll. Applying one would pin the final view without its
576
+ * receipt link or real reason, so the poll delivers terminals, whole, within
577
+ * one interval. The stream accelerates the in-flight steps.
578
+ */
579
+ function mapStatusFrame(value, lastRevision) {
580
+ const parsed = index_js_1.IntentStatusFrameSchema.safeParse(value);
581
+ if (!parsed.success)
582
+ return null;
583
+ const frame = parsed.data;
584
+ if (frame.revision !== null && lastRevision !== null && frame.revision <= lastRevision) {
585
+ return null;
586
+ }
587
+ if (!exports.POLL_INFLIGHT_STATES.includes(frame.status)) {
588
+ return { revision: frame.revision, message: null };
589
+ }
590
+ return {
591
+ revision: frame.revision,
592
+ message: {
593
+ type: "intent_status",
594
+ intentId: frame.intent_id,
595
+ status: frame.status,
596
+ txHash: frame.tx_hash,
597
+ timestamp: new Date(frame.ts).toISOString(),
598
+ },
599
+ };
600
+ }
601
+ /**
602
+ * The drift warning to log when the in-flight status poll gets a definitive 404
603
+ * for a DISPATCHED intent id, or null when this id has already been reported.
604
+ *
605
+ * The poll's decision on that 404 is to HOLD (never terminalize; see the status
606
+ * poll in machine.ts for why), and a hold that emits nothing is indistinguishable from a
607
+ * healthy poll: the modal spins, the poll runs to its ~10 min cap, and no console
608
+ * line says which swap it is. So the first 404 per id carries a signal, in the
609
+ * same shape CLAUDE.md already relies on for `mapRiddlerStatus` drift (a warn
610
+ * carrying the intent id, catchable in logs).
611
+ *
612
+ * `reportedId` is the last id the caller reported, so the warning is one-shot:
613
+ * the poll runs every 4s and would otherwise emit ~150 identical lines per stuck
614
+ * swap. A different id reports again -- a second swap hitting this is a second
615
+ * data point, not a repeat.
616
+ *
617
+ * Distinct from the transport-level warn in `intentAPI.getIntentStatus`, which
618
+ * fires once per id for ANY 404, including the benign one the orphan-quote sweep
619
+ * produces for a pre-dispatch row (and can be spent on that benign case by
620
+ * whichever caller polls the id first). This one names the dispatched case, which
621
+ * is the bug.
622
+ */
623
+ function notFoundDriftWarning(intentId, reportedId) {
624
+ if (!intentId || intentId === reportedId)
625
+ return null;
626
+ return `[useIntent] Status poll 404 for dispatched intent ${intentId}: holding, not failing. The worker has no row for a swap that was already dispatched; leaving the outcome to reconciliation.`;
627
+ }
628
+ /**
629
+ * Fields to merge from a poll whose status did NOT change, or null when the
630
+ * poll carries nothing new.
631
+ *
632
+ * The status-transition table has no self-edges, so a `settling -> settling`
633
+ * poll is an invalid move and `applyStatusTransition` discards the entire
634
+ * update, receipt hashes included. A stealth swap sits at `settling` for its
635
+ * whole life, so that edge is never valid and the fill hash the server returns
636
+ * on every 4s poll never reached the UI: the user was shown "Still confirming"
637
+ * with no transaction link while the settlement was on chain (xochi#353).
638
+ *
639
+ * Two deliberate omissions:
640
+ *
641
+ * - A field that did not ARRIVE is not applied. Blanking a hash we already hold
642
+ * because a later poll omitted it would flicker the receipt link back off.
643
+ * - `reconciling` is never merged. The poll owns that flag on its own timer, and
644
+ * re-applying `mapStatusUpdate`'s default `false` here would switch the
645
+ * "safe to close" view off again on every tick.
646
+ *
647
+ * Returns null when nothing changed, so the caller can skip the setState and
648
+ * avoid a re-render per poll.
649
+ */
650
+ function mergeSameStatusReceipt(prev, msg) {
651
+ const updates = {};
652
+ if (msg.substatus && msg.substatus !== prev.substatus) {
653
+ updates.substatus = msg.substatus;
654
+ }
655
+ if (msg.receivingTxHash && msg.receivingTxHash !== prev.receivingTxHash) {
656
+ updates.receivingTxHash = msg.receivingTxHash;
657
+ }
658
+ if (msg.settlementTxHash && msg.settlementTxHash !== prev.settlementTxHash) {
659
+ updates.settlementTxHash = msg.settlementTxHash;
660
+ }
661
+ if (msg.settlementChainId && msg.settlementChainId !== prev.settlementChainId) {
662
+ updates.settlementChainId = msg.settlementChainId;
663
+ }
664
+ return Object.keys(updates).length > 0 ? updates : null;
665
+ }