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