@ninsei-labs/sdk 0.39.0 → 0.39.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.
package/src/swaps.mjs CHANGED
@@ -1,935 +1,935 @@
1
- // SWAPS: READING STATE AND STARTING.
2
- //
3
- // Here the SDK shows what already lies in storage (the engine's swap records) and starts a new swap. The SDK holds
4
- // the step order of a start, not the interface: guards -> record and recovery file -> the person's confirmation ->
5
- // order and halves -> server -> watching the Monero address. A lock without a recovery file is a lock with nothing
6
- // to open it with if the browser is lost, so the confirmation stands BEFORE anything that locks funds.
7
- import { SdkError } from "./errors.mjs";
8
- import * as engine from "./engine.mjs";
9
- // The name is NOT `record`: inside start there is a variable with that name, and referencing the module before it
10
- // ran into "Cannot access 'record' before initialization" - a refusal without a code, that is, a breakdown. A check
11
- // showed exactly that.
12
- import * as recordModule from "./record.mjs";
13
- // The order: our own side is built by the engine's builder, the counterparty's side is verified and accepted, and
14
- // the joint address is assembled from the halves. There are no formulas of our own in the core - see order.mjs.
15
- import { contextOf, orderStep } from "./order.mjs";
16
- // Locking the funds: sending goes through the already-finished step (`lock.send`), the wallet is an adapter from outside.
17
- import * as lock from "./lock.mjs";
18
- import { vmOfChain, legFor } from "./legs/index.mjs";
19
- // THE VIEW HALVES - from the core's primitives: the sum of two halves is the key the wallet sees incoming payments
20
- // with. There is no arithmetic of the core's own here either (see order.mjs) - we use the same halves builder.
21
- import { primitives } from "./primitives.mjs";
22
- // MARKING READY - A FINISHED STEP, NOT A SECOND IMPLEMENTATION OF IT: it requires the order expectations and checks
23
- // the slots against the chain BEFORE signing (actions.markReady). The watch must call exactly it, otherwise "we saw
24
- // the XMR" and "we allowed the settlement to be taken" would diverge.
25
- import { createActions } from "./actions.mjs";
26
- // THE XMR ARRIVAL CHECK ON TWO NODES (issue #85) - IN THE CORE, from the same mirrored rule and block parser.
27
- import * as xmrArrival from "./xmr-arrival.mjs";
28
-
29
- // The engine's terminal states -> the contract's codes. An unknown kind is not invented: it comes as "closed".
30
- const TERMINAL = { success: "success", refunded: "refunded_eth", xmr_returned: "xmr_returned", closed: "closed" };
31
-
32
- const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : null; };
33
-
34
- // THE SERVICE'S NAMED STATE FOR FUNDS THAT ARE ON THE ADDRESS BUT BARRED BY THE TRANSACTION'S unlock_time
35
- // (app/store.mjs, app/watcher.mjs; the rule itself is app/indexer.mjs, unlockStateOf). "Has the XMR arrived" is
36
- // decided by the SERVICE, which alone reads the chain's outputs; the core does NOT re-derive it from the amount -
37
- // it reads THIS state. That is the one rule: the indexer and the wallet path classify unlock_time, and the core
38
- // treats locked funds as not-an-arrival instead of inventing a second rule of its own.
39
- const LOCKED_STATUS = "xmr_locked";
40
-
41
- // --- THE "SERVER" STEP: RECORDING THE SWAP WITH US AND WATCHING THE MONERO ADDRESS -------------------
42
- //
43
- // WHAT THIS STEP IS AND WHY AFTER THE LOCK. The funds are already locked (the escrow was created by the factory
44
- // call), but only the browser knows about the swap: until the record reaches the service, the swap list is tied to
45
- // the tab, and nobody notices incoming XMR. So the step consists of two actions, and both are THE SAME REQUESTS the
46
- // current interface makes:
47
- //
48
- // 1) RECORDING: POST /api/evm-swaps, body { id, network, escrow, quoteId, providerId, hashlock, amount, t0, t1 }
49
- // (www/js/evm/auth.js:111-125, saveServerSwap). Our app accepts it by the template /^\/api\/evm-swaps$/
50
- // (app/server.mjs:1230) and requires a pass: without Authorization: Bearer *** 401 (app/server.mjs:1232).
51
- // It checks the escrow address against the CHAIN (locker() versus the address from the pass) - that is, it
52
- // confirms the record by the chain, not by the client's words.
53
- // 2) WATCHING: POST /api/swaps (address + VIEW KEY + expected amount; www/js/ui/views/confirm.js:581,
54
- // www/js/core/chainSource.js:178) and then polling GET /api/swaps/{id} (chainSource.js:184). The spend key does
55
- // not leave and cannot: no one has it until the half is revealed.
56
- //
57
- // WHAT IS DELIBERATELY ABSENT HERE: a waiting policy of its own inside the step. How long to wait for the XMR to
58
- // arrive is the caller's decision, so the watch is exposed as a separate method (`swaps.watch`) with a timeout from
59
- // the configuration.
60
- const MARK_FIELDS = ["id", "network", "escrow", "quoteId", "providerId", "hashlock", "amount", "t0", "t1"];
61
- // THE FIELDS OF THE FACTORY WATCH RECORD - the same whitelist as the page client's
62
- // (www/js/core/chainSource.js:33, ALLOWED_SESSION_FIELDS). The body is assembled FROM IT anew, so neither the spend
63
- // half nor the spend key can get into the request, even if someone adds them to the record "just in case".
64
- const ALLOWED_SESSION_FIELDS = ["swapId", "network", "address", "viewKey", "expectedAmountXmr", "restoreHeight", "chain"];
65
-
66
- /**
67
- * The mark body from the swap record. THE FIELDS ARE THE SAME AND WITH THE SAME MEANING as the interface's
68
- * (auth.js:114-125), not a private list: only public data goes out (the escrow address, amounts, deadlines, the
69
- * quote reference), and nothing secret - the spend key never leaves the browser under any configuration.
70
- */
71
- export function markBodyOf(swap, escrow, order = null) {
72
- const e = (swap && swap.escrow) || {};
73
- const o = order && typeof order === "object" ? order : {};
74
- // A BIGINT AS A STRING: the order amount in wei comes both as a string and as a BigInt number (the order terms),
75
- // and the request body is serialized to JSON, where a BigInt does not live at all (a TypeError on the very first
76
- // call). The app expects a string.
77
- const first = (...vals) => {
78
- for (const v of vals) {
79
- if (v === undefined || v === null || v === "") continue;
80
- return typeof v === "bigint" ? v.toString() : v;
81
- }
82
- return null;
83
- };
84
- return {
85
- id: swap.id || null,
86
- network: swap.network || swap.chain || null,
87
- escrow: escrow || e.address || null,
88
- quoteId: first(swap.orderQuoteId, e.quoteId),
89
- providerId: first(swap.providerId, e.providerId),
90
- hashlock: first(e.hashlock, swap.hashlock),
91
- amount: first(e.amountWei, o.amount, swap.amountWei),
92
- t0: first(e.t0, o.t0),
93
- t1: first(e.t1, o.t1),
94
- };
95
- }
96
-
97
- /**
98
- * THE VIEW KEY OF THE JOINT ADDRESS - THE SUM OF TWO HALVES. That is how the interface assembles it
99
- * (www/js/ui/views/confirm.js:559; math - www/js/monero/swapKeys.js, watchViewSeedHex), and only with it does the
100
- * wallet see incoming payments: the view and spend halves are independent in Monero.
101
- * Recorded as 64 hex WITHOUT the 0x prefix: the app checks the key by this form (app/store.mjs:74, HEX64).
102
- */
103
- export function jointViewKey(swap) {
104
- const e = (swap && swap.escrow) || {};
105
- if (typeof e.viewHalf !== "string" || !e.viewHalf) throw new SdkError("bad-input", { field: "viewHalf" });
106
- if (typeof e.counterViewHalf !== "string" || !e.counterViewHalf) throw new SdkError("bad-input", { field: "counterViewHalf" });
107
- try {
108
- const halves = engine.halves.createHalves(primitives);
109
- return halves.combineHalves(BigInt(e.viewHalf), BigInt(e.counterViewHalf)).toString(16).padStart(64, "0");
110
- } catch { throw new SdkError("bad-input", { field: "viewKey" }); }
111
- }
112
-
113
- /**
114
- * What is visible in the app's watch response. AN EMPTY RESPONSE IS NOT COUNTED AS AN ARRIVAL: it has no receivedXmr
115
- * field at all, and "the field is absent" and "zero" are different things (zero means "nothing arrived", absence -
116
- * "not read"). We return null if this is not a watch response at all - emptiness must not be taken for data.
117
- */
118
- export function incomingOf(session) {
119
- if (!session || typeof session !== "object") return null;
120
- const received = Number(session.receivedXmr);
121
- if (!Number.isFinite(received)) return null;
122
- const confirmations = Number(session.confirmations);
123
- // LOCKED FUNDS ARE ON THE ADDRESS BUT ARE NOT AN ARRIVAL THE SWAP CAN USE: the sender set unlock_time, and the
124
- // service names that state. `receivedXmr` counts only what can be spent; the barred sum is `lockedXmr`, and the
125
- // boundary (a height or a date) travels as `lockedUntil` so the interface can say until WHEN.
126
- const locked = session.status === LOCKED_STATUS;
127
- const lockedXmr = num(session.lockedXmr);
128
- return {
129
- id: session.id || null,
130
- address: session.address || null,
131
- status: session.status || null,
132
- expectedXmr: num(session.expectedAmountXmr),
133
- receivedXmr: received,
134
- confirmations: Number.isFinite(confirmations) ? confirmations : 0,
135
- txids: Array.isArray(session.fundingTxids) ? session.fundingTxids : [],
136
- locked,
137
- lockedXmr: lockedXmr === null ? 0 : lockedXmr,
138
- lockedUntil: session.lockedUntil || null,
139
- // AN ARRIVAL IS MONEY AT THE ADDRESS THAT IS NOT BARRED BY unlock_time. Zero, or locked, means none.
140
- seen: received > 0 && !locked,
141
- };
142
- }
143
-
144
- /** Translating the engine's record into the contract state: our names, the engine's meaning. */
145
- export function toState(swap) {
146
- if (!swap || typeof swap !== "object" || !swap.id) throw new SdkError("bad-input", { field: "swap" });
147
- const d = engine.swap.derive(swap);
148
- const steps = [];
149
- const engineSteps = Array.isArray(d.steps) ? d.steps : [];
150
- const funded = engineSteps.find((s) => s.key === "funded") || null;
151
- // THE FIRST STEP IS IN THE CONTRACT BUT NOT IN THE ENGINE: the engine considers the swap already created from the
152
- // "funded" step. Waiting for payment is a state the interface must show, so we add the step ourselves.
153
- steps.push({ code: "awaiting_funding", done: !!(funded && funded.done), active: !(funded && funded.done), at: null });
154
- for (const s of engineSteps) {
155
- steps.push({
156
- code: s.key, done: !!s.done, active: !!s.active, failed: !!s.failed,
157
- // The step time is taken from the record only in the sandbox: in a live swap it is the demonstration time.
158
- at: d.simulated && d.simulated.evm ? (s.atSim ?? null) : null,
159
- });
160
- }
161
- const active = steps.find((s) => s.active) || [...steps].reverse().find((s) => s.done) || steps[0];
162
- const chain = swap.network || swap.chain || null;
163
- // The virtual machine is taken from the network registry. An unknown network has none, and "unknown" is more honest
164
- // than the network's name: it is visible in the state and does not turn into "probably EVM".
165
- const vm = chain ? (vmOfChain(chain) || "unknown") : "unknown";
166
- return {
167
- id: swap.id,
168
- direction: swap.side === "sell" ? "sell" : "buy",
169
- // WHAT THE LIST SHOWS, NOT ONLY THE STEPPER (www/js/ui/views/info.js, swapsView). The list needs the date,
170
- // who gave the price, the human name of the phase and whether the swap is settled - and it must take them
171
- // from the CORE, not by reaching into the record itself. The names repeat the record's (core/swap.js,
172
- // createSwap: createdAt, maker, escrow).
173
- createdAt: swap.createdAt ?? null,
174
- maker: {
175
- id: (swap.makerId !== undefined && swap.makerId !== null) ? swap.makerId : ((swap.maker && swap.maker.id) ?? null),
176
- name: (swap.makerName !== undefined && swap.makerName !== null) ? swap.makerName : ((swap.maker && swap.maker.name) ?? null),
177
- },
178
- // THE HUMAN NAME OF THE PHASE IS THE ENGINE'S (derive -> phaseLabel): the words on the screen come from the
179
- // same place as the state they describe, so they cannot drift apart.
180
- phase: d.phase || null,
181
- phaseLabel: d.phaseLabel || null,
182
- // "LIVE OR SETTLED" IS THE RECORD'S SETTLEMENT, exactly as the screen split it before. It is NOT read from
183
- // the phase's text: the text is for the person, the flag is for the code.
184
- settled: !!swap.settlement,
185
- // WHAT HAS ALREADY MOVED ON THE SWAP - the two signs the removal prompt warns about. Computed HERE from the
186
- // record: the screen must not reach into it for them.
187
- funds: {
188
- receivedXmr: num(d.xmrAmount && d.xmrAmount.seen) ?? 0,
189
- escrowLocked: !!(swap.escrow && (swap.escrow.fundTx || swap.escrow.secret)),
190
- },
191
- pay: { token: swap.payToken || "eth", amount: num(swap.payAmount) ?? 0 },
192
- get: { xmr: num(swap.xmrAmount) ?? 0 },
193
- step: active.code,
194
- steps,
195
- deadlines: {
196
- readyBy: d.deadlines && d.deadlines.readyBy !== undefined ? num(d.deadlines.readyBy) : null,
197
- t1: d.deadlines && d.deadlines.t1 !== undefined ? num(d.deadlines.t1) : null,
198
- },
199
- xmr: {
200
- confirmations: num(d.xmr && (d.xmr.confirmations !== undefined ? d.xmr.confirmations : d.conf)) ?? 0,
201
- target: num(d.xmr && d.xmr.confTarget) ?? num(d.confTarget) ?? 0,
202
- txid: (d.xmr && Array.isArray(d.xmr.txids) && d.xmr.txids[0]) || null,
203
- amount: {
204
- expected: d.xmrAmount ? d.xmrAmount.expected ?? null : null,
205
- seen: d.xmrAmount ? d.xmrAmount.seen ?? null : null,
206
- },
207
- },
208
- counterparty: {
209
- chain,
210
- vm,
211
- // The address of whatever secures the leg. While there is none - null, not an empty string: the interface must
212
- // distinguish "not created yet" from "created, but the address was not read".
213
- leg: (swap.escrow && (swap.escrow.address || swap.escrow.leg)) || null,
214
- status: d.phase || null,
215
- txs: (swap.escrow && (swap.escrow.txids || swap.escrow.txs)) || [],
216
- },
217
- actions: { markReady: !!(d.can && d.can.ready), sweep: !!(d.can && d.can.sweep), refund: !!(d.can && d.can.refund) },
218
- simulated: { evm: !!(d.simulated && d.simulated.evm), xmr: !!(d.simulated && d.simulated.xmr) },
219
- terminal: d.terminal ? (TERMINAL[d.terminal.kind] ?? "closed") : null,
220
- };
221
- }
222
-
223
- // A PASSPHRASE SHORTER THAN THIS IS NOT ACCEPTED: a file with a weak passphrase is a file strangers will open.
224
- const MIN_PASSPHRASE = 12;
225
-
226
- // The record fields are taken from the request: what exactly the person changes is set by the interface, not the core.
227
- // A FIXED DEFECT: the record lost WHERE TO WITHDRAW XMR AND WHAT IT OWES. `receiveTo` and the quote data (maker, fee,
228
- // signature, rate) were not carried into the record at all, so the progress screen - which reads EXACTLY the record -
229
- // knew neither the withdrawal address nor the maker, and exporting the recovery file from that screen went out with an
230
- // empty address. The record fields stay the same as the engine's (core/swap.js, createSwap): the field name is
231
- // `receiveAddress`.
232
- const recordInputOf = (request) => {
233
- const quote = request && request.quote && typeof request.quote === "object" ? request.quote : {};
234
- const pass = (v) => (v === undefined || v === null || v === "" ? undefined : v);
235
- return {
236
- side: request.side || "buy",
237
- network: quote.chain ? quote.chain : undefined,
238
- payToken: quote.asset ? String(quote.asset).toLowerCase() : undefined,
239
- payAmount: quote.amount !== undefined ? quote.amount : undefined,
240
- xmrAmount: quote.size !== undefined ? quote.size : undefined,
241
- // THE ORDER-QUOTE ID IS PART OF THE RECORD, NOT A DETAIL OF THE SCREEN (a defect found): our service uses it to
242
- // tie the created escrow to the provider node's record, and without it the node cannot take the ETH
243
- // (sdk/src/swap-flow.mjs: quoteId: quote.id -> swap.orderQuoteId; markBodyOf reads exactly this field).
244
- orderQuoteId: pass(quote.id),
245
- // WHERE TO WITHDRAW XMR: part of the record, not a detail of the screen - the progress screen exports the file by it.
246
- receiveAddress: pass(request.receiveTo),
247
- // WHAT THE PERSON SAW: the maker, its spread, the fee, the rate and the quote signature. Screen data, but it must
248
- // live in the record - otherwise the progress screen shows an empty maker instead of whoever gave the price.
249
- makerId: pass(quote.makerId), makerName: pass(quote.makerName), makerSpread: pass(quote.makerSpread),
250
- quoteSignature: pass(quote.signature), fee: pass(quote.arrakisFee),
251
- dexSpread: pass(quote.dexSpread), gasUsd: pass(quote.gasUsd), rate: pass(quote.rate),
252
- };
253
- };
254
-
255
- // createRecord is the start step that requires a Monero wallet IN THE BROWSER (creating the wallet and the order of
256
- // the halves). It arrives from outside, so the whole path after it (building the file, encryption, the person's
257
- // confirmation) is written and checked already now, rather than appearing together with the browser part.
258
- export function createSwaps({ preflight, http, createRecord, mode = null, xmrNetwork = null, call = null,
259
- // THE PATHS AND LIMITS ARE BROUGHT BY THE CONFIGURATION: the "server" step's paths are named by names (config.route),
260
- // and the timeout and rate of the watch polling - in config.limits. Without the configuration the step answers with a
261
- // code rather than following invented paths.
262
- config = null,
263
- // A SIGN-IN PASS - A SEAM FROM OUTSIDE. Only a wallet can sign the sign-in message, and its role in the core is an
264
- // adapter (send/receipt): windows and message signatures remain the interface's. A READY pass arrives here (as
265
- // www/js/evm/auth.js, signIn obtains it today), and the core merely presents it.
266
- auth = null,
267
- watch: watchOptions = null } = {}) {
268
- // WHAT READS THE CHAIN AT THE LOCK IS A SEAM, as in actions.mjs: by default the engine's read, from outside you can
269
- // substitute your own. Otherwise the lock step could not be checked beyond predicting the address - and "the address
270
- // from the receipt" would remain an argument on trust, not a verified property.
271
- const reader = typeof call === "function" ? call : engine.evm.readContract;
272
- // MARKING READY GOES THROUGH THE SAME READ SEAM as the lock: checking the slots and signing must go one way,
273
- // otherwise part of the check goes past the wallet and part does not (see actions.mjs).
274
- const actions = createActions({ call: typeof call === "function" ? call : null });
275
- const routeOf = (name, params) => {
276
- if (!config || typeof config.route !== "function") throw new SdkError("bad-input", { field: "config", step: "server" });
277
- return config.route(name, params);
278
- };
279
- const limitOf = (name, fallback) => {
280
- const value = config && config.limits ? config.limits[name] : null;
281
- return Number.isFinite(Number(value)) && Number(value) > 0 ? Number(value) : fallback;
282
- };
283
- const records = () => {
284
- const state = engine.store.loadState();
285
- const all = Object.values((state && state.swaps) || {}).filter((s) => s && s.id);
286
- return all.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0));
287
- };
288
-
289
- // THE GUARDS' DATA: what the core knows itself (the network, the order amount, the token allowance) plus the balance
290
- // read by THE SAME wallet the lock will be signed with. Here too - a refusal WITH A CODE where there is no data at
291
- // all (order terms or an amount in them): silently handing a guard an empty value means hearing "the amount is
292
- // unknown" about a quantity that is simply not in the request.
293
- async function guardOptionsOf(request) {
294
- // NOT A SINGLE REFUSAL HERE UNTIL THE GUARDS ARE ASKED. The absence of order terms is NOT a reason to refuse before
295
- // the guards: the start order is held by their precedence (see the check "a guard's refusal stops the swap with its
296
- // own code"). If the data is absent, a guard will say "not checked" and not lock, and the next step will name the
297
- // missing field (contextOf -> bad-input { field: "order" }). No "the amount is unknown" about a quantity simply not
298
- // in the request: the amount is taken from the terms, and if they are absent - the refusal names EXACTLY the terms.
299
- const order = request.order && typeof request.order === "object" ? request.order : {};
300
- const amountWei = order.amount;
301
- const wei = amountWei === undefined || amountWei === null || amountWei === "" ? null : String(amountWei);
302
- const chainId = Number(order.chainId);
303
- const chain = Number.isFinite(chainId) ? engine.config.chainByChainId(chainId) : null;
304
- // tokensOf EXPECTS THE NETWORK ID (slug), NOT a chainId: a passed chainId matches no record, and the registry
305
- // silently returns the tokens of the DEFAULT NETWORK (www/js/core/config.js, chainById -> DEFAULT_CHAIN).
306
- const tokens = chain ? engine.config.tokensOf(chain.id) : [];
307
- const payId = String((request.quote && (request.quote.token || request.quote.asset)) || "").toLowerCase();
308
- const payToken = tokens.find((t) => String(t.symbol).toLowerCase() === payId) || null;
309
- const nativeToken = tokens.find((t) => !t.address) || null;
310
- const tokenIsNative = !!(payToken && !payToken.address);
311
- const options = {
312
- tokenIsNative,
313
- // A STAND-IN OR A LIVE COUNTERPARTY - THE CALLER KNOWS. If there is no side, a stand-in will build it exactly in
314
- // a mode other than live, and the guard must see that rather than consider the swap real.
315
- standIn: !request.counterparty && mode !== "live",
316
- // A PRODUCTION NETWORK - BY THE REGISTRY'S RULE (escrow.mode === "live" and no testnet marker), the same as the
317
- // page's. false means "a stand-in is allowed here", not "we did not check".
318
- liveChain: !!(chain && chain.escrow && chain.escrow.mode === "live" && !chain.testnet),
319
- };
320
- // THE ORDER AMOUNT - IN WEI AND ONLY IT: for the amount-coverage guards the quantity is one. No amount - the field
321
- // will be absent entirely (the guard honestly says "not measured"), not a substituted number.
322
- if (wei !== null) { options.payAmountWei = wei; options.requiredNativeWei = wei; }
323
- // THE BALANCE IS READ BY THE LOCK'S WALLET. The core keeps no path into the chain of its own: we take the same seam
324
- // as `wallet.balances` (the leg driver) and read exactly the needed tokens. Not read - we leave null: the guard
325
- // will call it a code (balance-unchecked), and substituting zero is forbidden (zero is an empty wallet).
326
- try {
327
- const wallet = request.wallet;
328
- const driver = wallet && typeof wallet.driver === "function" ? wallet.driver() : null;
329
- const address = wallet && typeof wallet.address === "function" ? await wallet.address() : null;
330
- const leg = legFor("evm");
331
- const idOf = (t) => String(t.symbol).toLowerCase();
332
- const wanted = [];
333
- if (nativeToken) wanted.push({ id: idOf(nativeToken), symbol: nativeToken.symbol, decimals: nativeToken.decimals, address: null });
334
- if (payToken && payToken.address) wanted.push({ id: idOf(payToken), symbol: payToken.symbol, decimals: payToken.decimals, address: payToken.address });
335
- if (driver && address && leg && typeof leg.balances === "function" && wanted.length) {
336
- const read = await leg.balances({ driver, address, tokens: wanted });
337
- const pick = (token) => { const hit = token ? read.find((b) => b.token === idOf(token)) : null; return hit && hit.amount !== null ? hit.amount : null; };
338
- const nativeWei = nativeToken ? pick(nativeToken) : null;
339
- if (nativeWei !== null) options.nativeBalanceWei = nativeWei;
340
- const payWei = tokenIsNative ? nativeWei : pick(payToken);
341
- if (payWei !== null) options.payBalanceWei = payWei;
342
- }
343
- } catch { /* not read - the guard will name the field itself; silently considering the balance sufficient is not allowed */ }
344
- return options;
345
- }
346
-
347
- /**
348
- * RECORDING THE SWAP WITH US. The path and body are the interface's (see the step description above). A refusal is
349
- * called by a CODE and carries the service's response in full: the connection never got through - server-unavailable,
350
- * the service answered and did not accept - server-refused (with the status and ITS reason). The code is named so the
351
- * interface itself decides whether to abort the swap: today it does not abort it (www/js/evm/auth.js:15-16), and that
352
- * is its decision, not the core's silence.
353
- */
354
- async function markOnServer(swap, escrow, order) {
355
- if (!http || typeof http.tryJson !== "function") throw new SdkError("bad-input", { field: "http", step: "mark" });
356
- const mark = markBodyOf(swap, escrow, order);
357
- const body = JSON.stringify(Object.fromEntries(MARK_FIELDS.map((field) => [field, mark[field]])));
358
- const headers = { "content-type": "application/json" };
359
- // THE PASS IS ASKED EVERY TIME, NOT CACHED: it is short-lived (an hour: app/server.mjs, AUTH_TOKEN_TTL_MS), and a
360
- // cached pass would give a 401 at a step that does not move money itself but shows the swap to the service.
361
- if (typeof auth === "function") {
362
- let token = null;
363
- try { token = await auth(); } catch { token = null; }
364
- if (typeof token === "string" && token) headers.authorization = "Bearer " + token;
365
- }
366
- let answer = null;
367
- try { answer = await http.tryJson(routeOf("evmSwaps"), { method: "POST", headers, body }); }
368
- catch (error) { throw new SdkError("server-unavailable", { step: "mark", why: (error && error.code) || "fetch" }); }
369
- if (!answer || answer.ok !== true) {
370
- throw new SdkError("server-refused", {
371
- step: "mark", status: answer ? answer.status : null,
372
- why: (answer && answer.body && (answer.body.error || answer.body.detail)) || null,
373
- });
374
- }
375
- return { marked: true, httpStatus: answer.status, orderBinding: (answer.body && answer.body.orderBinding) || null };
376
- }
377
-
378
- /**
379
- * THE FACTORY WATCH RECORD with our service: by the same POST /api/swaps the page makes
380
- * (www/js/ui/views/confirm.js:581). The address and the VIEW KEY go out - that is enough to see incoming payments,
381
- * and not enough to spend them.
382
- */
383
- async function openWatch(swap, order) {
384
- if (!http || typeof http.tryJson !== "function") throw new SdkError("bad-input", { field: "http", step: "watch" });
385
- const e = (swap && swap.escrow) || {};
386
- const address = e.moneroAddress || (swap.swapWallet && swap.swapWallet.address) || null;
387
- if (!address) throw new SdkError("bad-input", { field: "moneroAddress" });
388
- // THE EXPECTED AMOUNT IS MANDATORY, and this is not a formality: without it the watch cannot check the sufficiency of
389
- // the arrival, and the app does not accept such a record (app/store.mjs:85-94). A refusal is more honest than a
390
- // silent record by which an underpayment would look like a completed swap.
391
- const expectedXmr = num(order && order.xmrAmount !== undefined ? order.xmrAmount : swap.xmrAmount);
392
- if (!(expectedXmr > 0)) throw new SdkError("bad-input", { field: "expectedAmountXmr" });
393
- const network = swap.moneroNetwork || xmrNetwork || null;
394
- if (!network) throw new SdkError("bad-input", { field: "moneroNetwork" });
395
- const wanted = {
396
- swapId: swap.id || null, network, address, viewKey: jointViewKey(swap), expectedAmountXmr: expectedXmr,
397
- // THE RESTORE HEIGHT - FROM THE SWAP RECORD (the order puts it there), not invented: a scan from zero would take
398
- // tens of hours, and the arrivals would not be visible in a reasonable time.
399
- restoreHeight: Number(e.birthHeight) > 0 ? Number(e.birthHeight) : null,
400
- chain: swap.network || swap.chain || null,
401
- };
402
- const body = {};
403
- for (const field of ALLOWED_SESSION_FIELDS) if (wanted[field] !== undefined && wanted[field] !== null) body[field] = wanted[field];
404
- // THE VIEW KEY IS NOT NEEDED BY THE SERVICE FOR THE DECISION ANY MORE: since the arrival is checked by the
405
- // client (verifyArrival), the backend may keep a watch only for notifications, and even that it can do from
406
- // the maker's txid. So sending the key is an OPTION: `watch.sendViewKeyToServer === false` drops it from the
407
- // record. The default keeps the current backend flow working until it stops deciding on its own.
408
- if (watchOptions && watchOptions.sendViewKeyToServer === false) delete body.viewKey;
409
- let answer = null;
410
- try { answer = await http.tryJson(routeOf("swaps"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); }
411
- catch (error) { throw new SdkError("server-unavailable", { step: "watch", why: (error && error.code) || "fetch" }); }
412
- if (!answer || answer.ok !== true) {
413
- throw new SdkError("server-refused", {
414
- step: "watch", status: answer ? answer.status : null,
415
- why: (answer && answer.body && (answer.body.error || answer.body.detail)) || null,
416
- });
417
- }
418
- const session = incomingOf(answer.body);
419
- if (!session) throw new SdkError("server-unavailable", { step: "watch", why: "bad-response" });
420
- return { registered: true, httpStatus: answer.status, ...session,
421
- expectedXmr: session.expectedXmr !== null ? session.expectedXmr : expectedXmr };
422
- }
423
-
424
- /** THE WATCH STATE: GET /api/swaps/{id} - the same path as the page's (chainSource.js:184). */
425
- async function readWatch(id) {
426
- if (!http || typeof http.tryJson !== "function") throw new SdkError("bad-input", { field: "http", step: "watch" });
427
- let answer = null;
428
- try { answer = await http.tryJson(routeOf("swap", { id }), { method: "GET" }); }
429
- catch (error) { throw new SdkError("server-unavailable", { step: "watch", why: (error && error.code) || "fetch" }); }
430
- if (!answer || answer.ok !== true) {
431
- throw new SdkError("server-refused", {
432
- step: "watch", status: answer ? answer.status : null, id,
433
- why: (answer && answer.body && (answer.body.error || answer.body.detail)) || null,
434
- });
435
- }
436
- return answer.body;
437
- }
438
-
439
- return {
440
- // list/get DO NOT ASK THE NETWORK: the state lies in storage, and after a page reload the interface raises the swaps
441
- // from it, not from memory.
442
- async list() { return records().map(toState); },
443
- async get(id) {
444
- if (typeof id !== "string" || !id) throw new SdkError("bad-input", { field: "id" });
445
- const found = records().find((s) => s.id === id);
446
- return found ? toState(found) : null;
447
- },
448
-
449
- /**
450
- * THE DEAL-PROGRESS SNAPSHOT. The progress screen shows one swap live: which steps are done, how many Monero
451
- * confirmations are left, what is allowed now, when the refund opens, what the Monero leg is doing - plus the
452
- * record's own facts (the escrow, the maker, the amounts, the one-time address, the destination). ALL of it is
453
- * assembled HERE: the record is read (engine.swap.getSwap), the state is DERIVED by the core
454
- * (engine.swap.derive) and the wallet is rebuilt from the halves (engine.swap.halvesWalletOf). The screen only
455
- * draws this snapshot - it no longer reads the record or derives the state itself, so there is one
456
- * implementation of the deal's rules instead of two.
457
- *
458
- * `null` means "there is no such record" - it is NOT an empty state.
459
- */
460
- async progress(id) {
461
- if (typeof id !== "string" || !id) throw new SdkError("bad-input", { field: "id" });
462
- const swap = engine.swap.getSwap(id);
463
- if (!swap) return null;
464
- return {
465
- id: String(swap.id || id),
466
- // THE RECORD'S OWN FIELDS. The screen shows them and hands the escrow back as the order expectations for a
467
- // live action (markReady/claim/refund), so they travel as they are - no second copy of the record.
468
- swap,
469
- // THE WALLET REBUILT FROM THE HALVES: the recovery file is assembled from it, and the half-of-the-key rule
470
- // stays the engine's (halvesWalletOf), not a second reading on the screen.
471
- swapWallet: engine.swap.halvesWalletOf(swap),
472
- // THE DERIVED DEAL VIEW: the phase, the allowed actions, the deadlines, the steps, the Monero leg and the
473
- // amounts. The field names are the engine's (derive) - one source for the state and the words about it.
474
- view: engine.swap.derive(swap),
475
- };
476
- },
477
-
478
- /**
479
- * THE DEMONSTRATION RECORD'S ACTIONS (mode "sim"): confirming readiness, refunding the ETH, sweeping the XMR.
480
- * In a LIVE swap the same steps go through `actions` (a real escrow and the wallet adapter); here they are the
481
- * sandbox's record mutations, kept in ONE place instead of on the screen.
482
- */
483
- async confirmReady(id) {
484
- if (typeof id !== "string" || !id) throw new SdkError("bad-input", { field: "id" });
485
- return engine.swap.confirmReady(id);
486
- },
487
- async refundEth(id, by = "you") {
488
- if (typeof id !== "string" || !id) throw new SdkError("bad-input", { field: "id" });
489
- return engine.swap.refundEth(id, by);
490
- },
491
- async sweepNow(id) {
492
- if (typeof id !== "string" || !id) throw new SdkError("bad-input", { field: "id" });
493
- return engine.swap.sweepNow(id);
494
- },
495
-
496
- /**
497
- * WHERE TO WITHDRAW THE XMR. Sets the record's `receiveAddress` and saves it: the destination is part of the
498
- * recovery file, so the change is WRITTEN (named), not inferred. The order and the chain are untouched.
499
- * `null` - there is no such record.
500
- */
501
- async setDestination(id, address) {
502
- if (typeof id !== "string" || !id) throw new SdkError("bad-input", { field: "id" });
503
- if (typeof address !== "string" || !address) throw new SdkError("bad-input", { field: "address" });
504
- const swap = engine.swap.getSwap(id);
505
- if (!swap) return null;
506
- swap.receiveAddress = address;
507
- return engine.swap.saveSwap(swap);
508
- },
509
-
510
- /**
511
- * THE MONERO NETWORK OF AN ADDRESS, BY ITS SHAPE. The format and the network prefixes live in one module
512
- * (engine.xmrAddress), the SAME one the Monero wallet adapter uses - the interface must not keep a prefix table
513
- * of its own. `null` - not an address of a known network.
514
- */
515
- async addressNetwork(address) {
516
- if (typeof address !== "string" || !address) return null;
517
- try { return engine.xmrAddress.networkFromShape(address); } catch { return null; }
518
- },
519
-
520
- /**
521
- * REMOVING A RECORD FROM THE LIST. The record leaves THIS store only: on the service and in the contract
522
- * everything stays, so this is a tidy-up of the list, not a cancellation of the swap. `false` means there was
523
- * no such record - the caller tells that apart from a refusal.
524
- */
525
- async forget(id) {
526
- if (typeof id !== "string" || !id) throw new SdkError("bad-input", { field: "id" });
527
- let state = null;
528
- try { state = engine.store.loadState(); } catch { throw new SdkError("storage-unavailable", { step: "forget" }); }
529
- const swaps = state && state.swaps;
530
- if (!swaps || !swaps[id]) return false;
531
- delete swaps[id];
532
- try { engine.store.saveState(state); } catch { throw new SdkError("storage-unavailable", { step: "forget" }); }
533
- return true;
534
- },
535
-
536
- /**
537
- * THE SERVICE'S SWAP LIST, MERGED INTO THE LOCAL ONE. The path and the body are the page's
538
- * (www/js/evm/auth.js, loadServerSwaps: GET /api/evm-swaps, the address ties the list to the wallet). The
539
- * merge rule is the ENGINE'S (core/swap.js, addServerSwaps): a local record always wins, the service only
540
- * adds what this browser does not have, and the rules are not written a second time here. The pass is
541
- * obtained by the interface and only presented by the core (the `auth` seam), so an unauthenticated call is
542
- * the service's own refusal with a code, not a silent empty list.
543
- */
544
- async sync() {
545
- if (!http || typeof http.tryJson !== "function") throw new SdkError("bad-input", { field: "http", step: "sync" });
546
- const headers = {};
547
- if (typeof auth === "function") {
548
- let token = null;
549
- try { token = await auth(); } catch { token = null; }
550
- if (typeof token === "string" && token) headers.authorization = "Bearer " + token;
551
- }
552
- let answer = null;
553
- try { answer = await http.tryJson(routeOf("evmSwaps"), { method: "GET", headers }); }
554
- catch (error) { throw new SdkError("server-unavailable", { step: "sync", why: (error && error.code) || "fetch" }); }
555
- if (!answer || answer.ok !== true) {
556
- throw new SdkError("server-refused", {
557
- step: "sync", status: answer ? answer.status : null,
558
- why: (answer && answer.body && (answer.body.error || answer.body.detail)) || null,
559
- });
560
- }
561
- // A GARBAGE ANSWER IS A REFUSAL, NOT AN EMPTY LIST: a non-JSON body (an SPA fallback, a proxy error page) must
562
- // not look like "the service has no swaps" - the two are different, and only one of them means "synced".
563
- const body = answer.body;
564
- if (!body || !Array.isArray(body.swaps)) throw new SdkError("server-unavailable", { step: "sync", why: "bad-response" });
565
- const added = engine.swap.addServerSwaps(body.swaps);
566
- return { ok: true, added, seen: body.swaps.length };
567
- },
568
-
569
- // THE ARRIVAL VERDICT WITHOUT THE WATCH LOOP, and the deadline check as a separate call: an interface may
570
- // want to run both itself. The watch uses exactly these, so there is one decision, not two.
571
- verifyArrival: (request) => xmrArrival.verifyArrival(request),
572
- assertTimeForConfirmations: (request) => xmrArrival.assertTimeForConfirmations(request),
573
-
574
- /**
575
- * THE CLIENT'S OWN EARLY REFUND WHEN THE XMR DID NOT ARRIVE (issue #88). After `readyBy` the maker may claim
576
- * even without the ready mark, and the early-refund window closes; so while the interface is open a depositor
577
- * who sees no XMR must take the ETH back BEFORE `readyBy`. The arrival is decided by THE SAME rule the watch
578
- * uses (xmr-arrival.verifyArrival): two nodes of different operators on one block, and money barred by
579
- * unlock_time is not an arrival.
580
- *
581
- * ONLY A POSITIVE "NOT ARRIVED" REFUNDS (state `pending` or `one-node`). A failure to check - no reachable
582
- * node, a disagreement, an underpayment, locked money - is NOT "no money": verifyArrival throws its own code
583
- * and NOTHING is signed, so a broken or lagging witness can never look like an empty address.
584
- *
585
- * IT FIRES ONLY WHEN LESS THAN THE CONFIRMATIONS' TIME IS LEFT before `readyBy`
586
- * (`minConfirmations * blockTimeSec`, overridable with `leadSec`): refunding earlier would abandon a maker who
587
- * still has time to deliver. The refund goes through the same action seam as the screen (actions.refund), and
588
- * the order expectations are mandatory.
589
- *
590
- * The seams `deps.verify` / `deps.refund` / `deps.now` exist for the checks; in production they are the real
591
- * arrival check and the real refund.
592
- */
593
- async refundIfXmrMissing(request = {}, deps = {}) {
594
- const verify = typeof deps.verify === "function" ? deps.verify : xmrArrival.verifyArrival;
595
- const refund = typeof deps.refund === "function" ? deps.refund : actions.refund;
596
- const nowOf = typeof deps.now === "function" ? deps.now : () => Math.floor(Date.now() / 1000);
597
- if (!request || typeof request !== "object") throw new SdkError("bad-input", { field: "request" });
598
- const swap = request.swap && typeof request.swap === "object" ? request.swap : null;
599
- const e = (swap && swap.escrow) || {};
600
- const escrow = request.escrow || e.address || null;
601
- if (!escrow) throw new SdkError("bad-input", { field: "escrow" });
602
- const halfLocker = request.halfLocker || e.half || null;
603
- if (!halfLocker) throw new SdkError("bad-input", { field: "halfLocker" });
604
- if (!request.expect || typeof request.expect !== "object") throw new SdkError("bad-input", { field: "expect" });
605
- const address = request.address || e.moneroAddress || null;
606
- if (!address) throw new SdkError("bad-input", { field: "address" });
607
- const viewSecret = request.viewSecret !== undefined && request.viewSecret !== null
608
- ? request.viewSecret : (swap ? jointViewKey(swap) : null);
609
- if (viewSecret === undefined || viewSecret === null) throw new SdkError("bad-input", { field: "viewSecret" });
610
- const spendPub = request.spendPub || e.moneroSpendPub || null;
611
- if (!spendPub) throw new SdkError("bad-input", { field: "spendPub" });
612
- // THE EXPECTED SUM IN ATOMIC UNITS. Given explicitly, or derived from the record's XMR amount (1 XMR = 10^12).
613
- let expectedAtomic = request.expectedAtomic !== undefined && request.expectedAtomic !== null
614
- ? request.expectedAtomic : (e.expectedAtomic !== undefined && e.expectedAtomic !== null ? e.expectedAtomic : null);
615
- if (expectedAtomic === null && swap && Number.isFinite(Number(swap.xmrAmount))) {
616
- expectedAtomic = BigInt(Math.round(Number(swap.xmrAmount) * 1e12)).toString();
617
- }
618
- if (expectedAtomic === null) throw new SdkError("bad-input", { field: "expectedAtomic" });
619
- const readyBy = Number(request.readyBySec !== undefined && request.readyBySec !== null ? request.readyBySec : e.readyBy);
620
- if (!Number.isFinite(readyBy) || readyBy <= 0) throw new SdkError("bad-input", { field: "readyBySec" });
621
- const minConfirmations = Number.isFinite(Number(request.minConfirmations)) && Number(request.minConfirmations) > 0
622
- ? Math.trunc(Number(request.minConfirmations)) : limitOf("xmrConfirmations", 10);
623
- const blockTimeSec = Number.isFinite(Number(request.blockTimeSec)) && Number(request.blockTimeSec) > 0
624
- ? Number(request.blockTimeSec) : limitOf("xmrBlockTimeSec", 120);
625
- const leadSec = Number.isFinite(Number(request.leadSec)) && Number(request.leadSec) >= 0
626
- ? Number(request.leadSec) : minConfirmations * blockTimeSec;
627
- const nowSec = Number.isFinite(Number(request.nowSec)) ? Number(request.nowSec) : nowOf();
628
- const leftSec = Math.floor(readyBy - nowSec);
629
- // PAST `readyBy` THE EARLY WINDOW IS GONE and the claim window is open: refunding is no longer ours to open.
630
- if (leftSec <= 0) return { refunded: false, state: "claim-window", leftSec, readyBy, nowSec };
631
- // TIME STILL LEFT BEYOND THE RESERVE - the maker has room to deliver; a refund now would abandon a live trade.
632
- if (leftSec > leadSec) return { refunded: false, state: "waiting", leftSec, needSec: leadSec, readyBy, nowSec };
633
-
634
- const verdict = await verify({
635
- nodes: request.nodes, address, viewSecret, spendPub, expectedAtomic, minConfirmations,
636
- txid: request.txid || null, fromHeight: request.fromHeight === undefined ? null : request.fromHeight,
637
- timeoutMs: request.timeoutMs, nowSec,
638
- });
639
- // THE XMR IS THERE - NOTHING IS REFUNDED. This is the decisive condition of the guard: without it the
640
- // client would take the ETH back from under a maker who has already sent the XMR.
641
- if (verdict.seen) return { refunded: false, state: "arrived", leftSec, verdict };
642
- // NOT ARRIVED (pending / one-node) - the depositor takes the ETH back through the same action seam as the
643
- // screen. A throw from `verify` above never reaches here and signs nothing.
644
- const res = await refund({ escrow, halfLocker, expect: request.expect, onStep: request.onStep }, request.wallet);
645
- return { refunded: true, state: "refunded", hash: (res && res.hash) || null, leftSec, verified: verdict };
646
- },
647
-
648
- async start(request) {
649
- if (!request || typeof request !== "object") throw new SdkError("bad-input", { field: "request" });
650
- if (!request.quote || typeof request.quote !== "object") throw new SdkError("bad-input", { field: "quote" });
651
- if (typeof request.onRecoveryFile !== "function") throw new SdkError("bad-input", { field: "onRecoveryFile" });
652
- if (!request.receiveTo || typeof request.receiveTo !== "string") throw new SdkError("bad-input", { field: "receiveTo" });
653
- // THE ORDER CONTEXT IS MANDATORY AND NOT INVENTED. It is assembled by the engine FROM THE VERY FIELDS the firm
654
- // quote's signature is bound to (orderContext.js, FIELDS), because the DLEQ proof proves a half FOR THESE TERMS.
655
- // Substituting an empty value here would mean issuing a key bound to nothing.
656
-
657
- // 1. THE GUARDS. THE DECISION IS BY `blocks`, AS THE ENGINE ITSELF DOES: the signature is locked only by a check
658
- // that directly forbids (preSignGuards, signingGateVerdict: blocks === true), while "not measured" (checked ===
659
- // false) is a state of the data, not a prohibition. The previous revision called the guards WITH AN EMPTY DATA SET
660
- // and counted any `ok === false` as a refusal, which made a live start run into it at the very first step.
661
- //
662
- // The guards are fed what the core HAS: the network (their guards take it themselves - the registry plus the
663
- // wallet state), the order amount in wei (it is in the terms) and the token allowance (native or ERC-20 - by the
664
- // network registry).
665
- const guardOptions = await guardOptionsOf(request);
666
- const report = preflight ? await preflight(request.quote, guardOptions) : null;
667
- const checks = (report && (report.checks || report.results)) || [];
668
- const blocking = checks.filter((c) => c && c.blocks === true);
669
- if (blocking.length) {
670
- const first = blocking[0];
671
- throw new SdkError(first.code || "bad-input", { ...(first.params || {}), kind: first.kind, surface: "swaps.start" });
672
- }
673
-
674
- // 2. THE SWAP RECORD. For now only the interface with a browser wallet can do it - and that is NAMED, not covered
675
- // by a stub.
676
- // THE SWAP RECORD IS CREATED ITSELF if the interface did not bring its own: the engine can do it as a pure state
677
- // record, and our side of the order is computed from the core's primitives.
678
- // THE RECORD IS CREATED TOGETHER WITH OUR SIDE: the order terms go into it, and the side is built by the engine's
679
- // builder (with the DLEQ proof and the encryption key), not by the core's own arithmetic.
680
- const makeRecord = typeof createRecord === "function" ? createRecord
681
- : async (req) => recordModule.createRecord(recordInputOf(req), { order: req.order });
682
- const passphrase = typeof request.passphrase === "string" ? request.passphrase : "";
683
- if (passphrase.length < MIN_PASSPHRASE) throw new SdkError("bad-input", { field: "passphrase", min: MIN_PASSPHRASE });
684
- // THE ORDER TERMS ARE CHECKED BEFORE THE RECORD. The context is assembled by the engine from the very fields the
685
- // firm quote's signature is bound to; incomplete terms - a refusal with a named field, not a substitution of empty
686
- // values: the DLEQ proof would then be bound to nothing.
687
- const orderContext = contextOf(request.order);
688
-
689
- let made = null;
690
- try { made = await makeRecord(request); } catch (error) {
691
- // THE CODE MATTERS, NOT THE CLASS: an error with a code (including from another instance of the module) passes
692
- // as is; without a code it means a breakdown inside the record, and it is named as a storage refusal.
693
- if (error && typeof error.code === "string") throw error;
694
- throw new SdkError("storage-unavailable", { step: "create-record" });
695
- }
696
- if (!made || !made.swap) throw new SdkError("bad-input", { field: "record" });
697
-
698
- // 2b. THE ORDER. IT GOES BEFORE THE FILE and this is not a rearrangement for convenience: the halves and the
699
- // counterparty's open points, without which the recovery file is empty, appear exactly here. The counterparty's
700
- // side comes from outside; in the sandbox the engine builds it as a stand-in, but on a live chain a stand-in would
701
- // mean the money goes against a non-existent side, so there it is a refusal.
702
- let record = made;
703
- // THE ORDER SIDE BUILT BY THE CORE STAYS UNTIL THE END OF THE START: the points and commitments for the lock terms
704
- // are taken from it - otherwise the record and the order would diverge, and the slot check against the chain would
705
- // go red (and rightly so). There is no second build of the side here.
706
- let builtSide = null;
707
- if (!record.swapWallet) {
708
- const step = orderStep({
709
- order: request.order, side: made.side || null, counterparty: request.counterparty || null,
710
- standIn: mode !== "live", network: (record.swap.moneroNetwork || xmrNetwork || null),
711
- });
712
- // THE RECORD FIELDS ARE UNDER THE VERY NAMES THE ENGINE READS. So the record, after a page reload, again yields a
713
- // wallet from the halves (`halvesWalletOf`), rather than losing the address together with the tab.
714
- const escrow = record.swap.escrow && typeof record.swap.escrow === "object" ? record.swap.escrow : {};
715
- record.swap.escrow = { ...escrow,
716
- half: step.wallet.spendHalf, viewHalf: step.wallet.viewHalf,
717
- edPointLocker: step.own.spendPoint, edViewPointLocker: step.own.viewPoint,
718
- edPointClaimer: step.wallet.otherSpendPoint, counterViewPoint: step.wallet.otherViewPoint,
719
- counterViewHalf: step.wallet.otherViewHalf,
720
- moneroAddress: step.wallet.address, moneroSpendPub: step.wallet.spendPub, moneroViewPub: step.wallet.viewPub,
721
- moneroNetwork: step.wallet.network, standIn: step.counterparty.standIn,
722
- providerId: step.counterparty.providerId, orderContext: step.context,
723
- };
724
- try { engine.swap.saveSwap(record.swap); } catch { throw new SdkError("storage-unavailable", { step: "save-swap" }); }
725
- record = { ...record, swapWallet: engine.swap.halvesWalletOf(record.swap) };
726
- if (!record.swapWallet) throw new SdkError("bad-input", { field: "swapWallet" });
727
- builtSide = step;
728
- }
729
-
730
- // 2a. THE RECOVERY FILE IS ASSEMBLED AND CHECKED BEFORE THE PERSON. A file that will not open is worse than no
731
- // file: the person will rely on it. So we check on our side, before the screen.
732
- let payload = null;
733
- try {
734
- payload = engine.recoveryFile.buildPayload({
735
- swap: record.swap, swapWallet: record.swapWallet, escrow: record.escrow,
736
- receiveAddress: record.receiveTo || request.receiveTo, restoreHeight: record.restoreHeight ?? null,
737
- });
738
- engine.recoveryFile.validatePayload(payload);
739
- } catch { throw new SdkError("recovery-failed", { step: "build-payload" }); }
740
-
741
- let file = null;
742
- try {
743
- file = { name: engine.recoveryFile.recoveryFileName(payload.swapId), contents: await engine.recoveryFile.createRecoveryFile(payload, passphrase) };
744
- } catch { throw new SdkError("recovery-failed", { step: "encrypt" }); }
745
-
746
- // 3. THE PERSON'S CONFIRMATION - THE GATE. No action that locks funds goes earlier: otherwise the lock would end
747
- // up without a key if the browser is lost. "Did not answer" is a refusal, not consent.
748
- const confirmed = await request.onRecoveryFile(file);
749
- if (confirmed !== true) throw new SdkError("recovery-declined", { swapId: payload.swapId });
750
-
751
- // 4. LOCKING THE FUNDS. The wallet comes FROM OUTSIDE (an adapter with `send`/`receipt`): signing is possible only
752
- // in it, and the core keeps no path into the chain of its own. The absence of a wallet is a REFUSAL WITH A CODE,
753
- // not a substitution of something by default: a lock without a signature would either not happen or go out with
754
- // the wrong key. The recovery file has by now been assembled and confirmed by the person - a lock without a file
755
- // is not done.
756
- const wallet = request.wallet;
757
- if (!wallet || typeof wallet.send !== "function" || typeof wallet.receipt !== "function") {
758
- throw new SdkError("bad-input", { field: "wallet", missing: ["send", "receipt"], surface: "swaps.start", step: "lock" });
759
- }
760
- // THE LOCK TERMS ARE THE SAME AS THE ORDER'S, and are taken from the caller: exactly these fields enter the
761
- // context the DLEQ proof is bound to (orderContext.js, FIELDS). The deadlines and composition are checked by the
762
- // engine (lock.send -> escrow.orderTerms) BEFORE sending, not by our own arithmetic: the contract sets the rules.
763
- // In the order terms the amount is already in wei, so amountWei is that same one, without recomputation.
764
- // THE LOCK TERMS ARE FROM THE SAME SIDE THAT WENT INTO THE RECORD. The points and commitments are computed by the
765
- // core (orderStep), not the caller: they do not know the half the core just built, and a second build of it would
766
- // be a second side. The deadlines, composition, amounts and addresses remain the caller's; the core merely ADDS
767
- // what follows from the built side. When the caller brought the side (createRecord returned a ready record with
768
- // its own side), builtSide is empty - then the terms are taken as passed.
769
- const derived = builtSide ? {
770
- commitHalfLocker: builtSide.wallet.commitHalfLocker,
771
- commitHalfClaimer: builtSide.wallet.commitHalfClaimer,
772
- edPointLocker: builtSide.own.spendPoint,
773
- edPointClaimer: builtSide.wallet.otherSpendPoint,
774
- edViewPointLocker: builtSide.own.viewPoint,
775
- } : {};
776
- const lockRequest = { ...request.order, ...derived, amountWei: request.order.amount };
777
- // THE SIGNED QUOTE: the fee, the registry address and the provider live in IT, not in the factory
778
- // (decision #76). The caller brings it (the interface got it from the maker); without it the lock is not signed.
779
- lockRequest.quote = request.orderQuote || null;
780
- let funded = null;
781
- try { funded = await lock.send(lockRequest, wallet, { call: reader }); }
782
- catch (error) {
783
- if (error && typeof error.code === "string") throw error;
784
- throw new SdkError("server-unavailable", { step: "lock" });
785
- }
786
- // THE ESCROW ADDRESS IS TAKEN FROM THE RECEIPT (lock.send does this), not from a prediction. It is also written
787
- // into the swap: otherwise the progress screen and the explorer link would show an address that is not on the
788
- // chain (the page does this on its side: www/js/ui/views/confirm.js:463).
789
- const escrowAddress = (funded && funded.escrow) || null;
790
- if (escrowAddress) {
791
- const escrowRecord = record.swap.escrow && typeof record.swap.escrow === "object" ? record.swap.escrow : {};
792
- record.swap.escrow = { ...escrowRecord, address: escrowAddress,
793
- // THE FUNDING HEIGHT - FROM THE RECEIPT: the XMR watch and the recovery file take it from here.
794
- birthHeight: Number(funded.birthBlock) > 0 ? Number(funded.birthBlock) : (escrowRecord.birthHeight ?? null) };
795
- try { engine.swap.saveSwap(record.swap); } catch { throw new SdkError("storage-unavailable", { step: "save-escrow" }); }
796
- }
797
-
798
- // 5. THE SERVER: RECORDING THE SWAP WITH US, THEN THE FACTORY WATCH RECORD FOR THE MONERO ADDRESS.
799
- // THE ORDER IS EXACTLY THIS: first the swap becomes known to the service (by it, the service checks the escrow
800
- // address against the CHAIN), and only then is the watch opened. The reverse order would leave a watch on a swap
801
- // the service does not know.
802
- // IF ANYTHING OF THIS FAILED - THE REFUSAL GOES OUT AS A CODE (server-unavailable / server-refused) with the step
803
- // and the service's reason: the funds are already locked, and this must not be hidden in silence or in "success".
804
- const server = await markOnServer(record.swap, escrowAddress, request.order);
805
- const watch = await openWatch(record.swap, request.order);
806
- // THE STATE IS NAMED, NOT PASSED OFF AS COMPLETION: the swap reached exactly "waiting for XMR at the joint address",
807
- // and there is no arrival yet - `receivedXmr` is taken from the service's response, not implied by zero.
808
- return {
809
- id: record.swap.id || null,
810
- escrow: escrowAddress,
811
- address: watch.address,
812
- step: "xmr-incoming",
813
- server,
814
- watch,
815
- next: "watch",
816
- };
817
- },
818
-
819
- /**
820
- * WAIT FOR THE XMR TO ARRIVE AT THE JOINT ADDRESS - the whole point of opening the watch.
821
- *
822
- * The polling uses THE SAME request as the page's (GET /api/swaps/{id}), the rate and the timeout - from the
823
- * configuration (config.limits.watchPollMs/watchTimeoutMs, the engine's defaults too) and are overridable here.
824
- * IT ARRIVED - we return the FACT (the amount, the confirmations, the txid); WE DID NOT WAIT - a refusal with the
825
- * code `xmr-timeout`, separate from the others, so "the wait expired" is confused with neither a breakdown nor an
826
- * empty response. XMR ON THE ADDRESS BUT BARRED BY unlock_time is a THIRD answer with its own code `xmr-locked`
827
- * (the boundary - a height or a date - is in `until`): such funds are not an arrival, and the ready mark is not
828
- * signed under them.
829
- *
830
- * MARKING READY HAPPENS RIGHT HERE, AND ONLY AFTER THE ARRIVAL: `actions.markReady` allows a foreign address to
831
- * take the settlement, so it requires the order expectations (`expect`) - the slot check against the chain stands in
832
- * it BEFORE the signature. The caller passes them: the core does not invent the commitments of the order it signs.
833
- */
834
- async watch(request) {
835
- if (!request || typeof request !== "object") throw new SdkError("bad-input", { field: "request" });
836
- const id = typeof request.id === "string" && request.id ? request.id : null;
837
- if (!id) throw new SdkError("bad-input", { field: "id" });
838
- // THE EXPECTATIONS AND THE WALLET COME AS A PAIR: with one of them there is nothing to check against, with the
839
- // other nothing to sign with. Skipping silently is not allowed - a signature without a check would allow a foreign
840
- // order to take the settlement.
841
- const wantExpect = request.expect !== undefined && request.expect !== null;
842
- const wantWallet = request.wallet !== undefined && request.wallet !== null;
843
- if (wantExpect !== wantWallet) throw new SdkError("bad-input", { field: wantExpect ? "wallet" : "expect" });
844
- if (wantExpect && !(request.escrow || request.address)) throw new SdkError("bad-input", { field: "escrow" });
845
- const pollMs = Number.isFinite(Number(request.pollMs)) && Number(request.pollMs) > 0 ? Number(request.pollMs) : limitOf("watchPollMs", 5000);
846
- const timeoutMs = Number.isFinite(Number(request.timeoutMs)) && Number(request.timeoutMs) > 0 ? Number(request.timeoutMs) : limitOf("watchTimeoutMs", 1_200_000);
847
- // THE CONFIRMATION TARGET - FROM THE CONFIGURATION (one number, the owner's decision: 10).
848
- const minConfirmations = Number.isFinite(Number(request.minConfirmations)) && Number(request.minConfirmations) > 0
849
- ? Math.trunc(Number(request.minConfirmations)) : limitOf("xmrConfirmations", 10);
850
- // THE CLIENT-SIDE CHECK IS OPT-IN BY ITS DATA: it needs the joint address, the joint view secret and the
851
- // joint spend public key - all of which the client has and the backend no longer needs. Without them the
852
- // watch keeps the service's answer (notifications only); with them the CLIENT decides.
853
- const verifyRequest = request.verify && typeof request.verify === "object"
854
- ? { ...request.verify,
855
- nodes: Array.isArray(request.verify.nodes) ? request.verify.nodes
856
- : (config && typeof config.nodesFor === "function" ? config.nodesFor(request.verify.network || request.network || xmrNetwork) : []),
857
- minConfirmations }
858
- : null;
859
- // LESS TIME LEFT THAN THE CONFIRMATIONS TAKE - NAMED AND NOT STARTED (issue #85, the owner's decision).
860
- if (verifyRequest) {
861
- const readyBy = request.readyBySec !== undefined && request.readyBySec !== null ? Number(request.readyBySec)
862
- : (request.deadline && request.deadline.readyBy !== undefined ? Number(request.deadline.readyBy) : null);
863
- if (readyBy) xmrArrival.assertTimeForConfirmations({ readyBySec: readyBy, nowSec: Math.floor(Date.now() / 1000), minConfirmations, blockTimeSec: limitOf("xmrBlockTimeSec", 120) });
864
- }
865
- const started = Date.now();
866
- let last = null;
867
- for (;;) {
868
- const body = await readWatch(id);
869
- last = incomingOf(body);
870
- // AN EMPTY OR FOREIGN RESPONSE IS NOT AN ARRIVAL: counting it as incoming would mean declaring money that was
871
- // never seen. This is a named refusal, not "let us wait some more".
872
- if (!last) throw new SdkError("server-unavailable", { step: "watch", why: "bad-response", id });
873
- // XMR IS ON THE ADDRESS BUT LOCKED BY THE TRANSACTION'S unlock_time: a NAMED refusal, not "keep waiting".
874
- // The ready mark hands the escrow to the maker; under funds that cannot be spent the maker would take the
875
- // ETH and the XMR would stay locked (issue #84), so nothing is signed. The boundary is in `until`.
876
- if (last.locked) {
877
- const until = last.lockedUntil || {};
878
- throw new SdkError("xmr-locked", { id, address: last.address, status: last.status,
879
- expectedXmr: last.expectedXmr, receivedXmr: last.receivedXmr, lockedXmr: last.lockedXmr,
880
- // The boundary goes as NUMBERS and their kind - a block height or unix seconds - never as text:
881
- // turning them into a date the person reads is the interface's dictionary (www/js/sdk/codes.js).
882
- untilHeight: until.untilHeight ?? null, untilTime: until.untilTime ?? null,
883
- waitedMs: Date.now() - started });
884
- }
885
- // THE CLIENT'S OWN ANSWER COMES FIRST AND DECIDES. The backend's `seen` may still be false (it no longer
886
- // holds the view key), so it is not consulted when the client verifies. A disagreement or locked money
887
- // throws its own code above and closes the button; one operator / few confirmations name a WAITING state.
888
- if (verifyRequest) {
889
- const verdict = await xmrArrival.verifyArrival({ ...verifyRequest, nowSec: Math.floor(Date.now() / 1000) });
890
- last.verify = verdict;
891
- if (!verdict.seen) {
892
- if (Date.now() - started >= timeoutMs) {
893
- throw new SdkError("xmr-timeout", { id, address: last.address, expectedXmr: last.expectedXmr,
894
- state: verdict.state, confirmations: verdict.confirmations ?? 0, waitedMs: Date.now() - started, timeoutMs, pollMs });
895
- }
896
- await new Promise((resolve) => setTimeout(resolve, pollMs));
897
- continue;
898
- }
899
- const verified = { seen: true, id, address: last.address, status: last.status,
900
- expectedXmr: last.expectedXmr, receivedXmr: Number(verdict.amountAtomic) / 1e12, confirmations: verdict.confirmations,
901
- txids: [verdict.txid], at: Date.now(),
902
- verified: { height: verdict.height, blockHash: verdict.blockHash, operators: verdict.operators, agreedBy: verdict.agreedBy },
903
- source: "client" };
904
- if (wantExpect && wantWallet) {
905
- verified.marked = await actions.markReady(
906
- { escrow: request.escrow || last.address, expect: request.expect, onStep: request.onStep }, request.wallet);
907
- } else {
908
- verified.next = "mark-ready";
909
- }
910
- return verified;
911
- }
912
- if (last.seen) {
913
- const result = { seen: true, id, address: last.address, status: last.status,
914
- expectedXmr: last.expectedXmr, receivedXmr: last.receivedXmr, confirmations: last.confirmations,
915
- txids: last.txids, at: Date.now() };
916
- if (wantExpect && wantWallet) {
917
- result.marked = await actions.markReady(
918
- { escrow: request.escrow || last.address, expect: request.expect, onStep: request.onStep }, request.wallet);
919
- } else {
920
- // WITHOUT EXPECTATIONS AND A WALLET, THE WATCH HONESTLY NAMES THE NEXT STEP, rather than pretending the mark
921
- // has already been placed.
922
- result.next = "mark-ready";
923
- }
924
- return result;
925
- }
926
- if (Date.now() - started >= timeoutMs) {
927
- throw new SdkError("xmr-timeout", { id, address: last.address, expectedXmr: last.expectedXmr,
928
- receivedXmr: last.receivedXmr, confirmations: last.confirmations, waitedMs: Date.now() - started,
929
- timeoutMs, pollMs });
930
- }
931
- await new Promise((resolve) => setTimeout(resolve, pollMs));
932
- }
933
- },
934
- };
935
- }
1
+ // SWAPS: READING STATE AND STARTING.
2
+ //
3
+ // Here the SDK shows what already lies in storage (the engine's swap records) and starts a new swap. The SDK holds
4
+ // the step order of a start, not the interface: guards -> record and recovery file -> the person's confirmation ->
5
+ // order and halves -> server -> watching the Monero address. A lock without a recovery file is a lock with nothing
6
+ // to open it with if the browser is lost, so the confirmation stands BEFORE anything that locks funds.
7
+ import { SdkError } from "./errors.mjs";
8
+ import * as engine from "./engine.mjs";
9
+ // The name is NOT `record`: inside start there is a variable with that name, and referencing the module before it
10
+ // ran into "Cannot access 'record' before initialization" - a refusal without a code, that is, a breakdown. A check
11
+ // showed exactly that.
12
+ import * as recordModule from "./record.mjs";
13
+ // The order: our own side is built by the engine's builder, the counterparty's side is verified and accepted, and
14
+ // the joint address is assembled from the halves. There are no formulas of our own in the core - see order.mjs.
15
+ import { contextOf, orderStep } from "./order.mjs";
16
+ // Locking the funds: sending goes through the already-finished step (`lock.send`), the wallet is an adapter from outside.
17
+ import * as lock from "./lock.mjs";
18
+ import { vmOfChain, legFor } from "./legs/index.mjs";
19
+ // THE VIEW HALVES - from the core's primitives: the sum of two halves is the key the wallet sees incoming payments
20
+ // with. There is no arithmetic of the core's own here either (see order.mjs) - we use the same halves builder.
21
+ import { primitives } from "./primitives.mjs";
22
+ // MARKING READY - A FINISHED STEP, NOT A SECOND IMPLEMENTATION OF IT: it requires the order expectations and checks
23
+ // the slots against the chain BEFORE signing (actions.markReady). The watch must call exactly it, otherwise "we saw
24
+ // the XMR" and "we allowed the settlement to be taken" would diverge.
25
+ import { createActions } from "./actions.mjs";
26
+ // THE XMR ARRIVAL CHECK ON TWO NODES (issue #85) - IN THE CORE, from the same mirrored rule and block parser.
27
+ import * as xmrArrival from "./xmr-arrival.mjs";
28
+
29
+ // The engine's terminal states -> the contract's codes. An unknown kind is not invented: it comes as "closed".
30
+ const TERMINAL = { success: "success", refunded: "refunded_eth", xmr_returned: "xmr_returned", closed: "closed" };
31
+
32
+ const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : null; };
33
+
34
+ // THE SERVICE'S NAMED STATE FOR FUNDS THAT ARE ON THE ADDRESS BUT BARRED BY THE TRANSACTION'S unlock_time
35
+ // (app/store.mjs, app/watcher.mjs; the rule itself is app/indexer.mjs, unlockStateOf). "Has the XMR arrived" is
36
+ // decided by the SERVICE, which alone reads the chain's outputs; the core does NOT re-derive it from the amount -
37
+ // it reads THIS state. That is the one rule: the indexer and the wallet path classify unlock_time, and the core
38
+ // treats locked funds as not-an-arrival instead of inventing a second rule of its own.
39
+ const LOCKED_STATUS = "xmr_locked";
40
+
41
+ // --- THE "SERVER" STEP: RECORDING THE SWAP WITH US AND WATCHING THE MONERO ADDRESS -------------------
42
+ //
43
+ // WHAT THIS STEP IS AND WHY AFTER THE LOCK. The funds are already locked (the escrow was created by the factory
44
+ // call), but only the browser knows about the swap: until the record reaches the service, the swap list is tied to
45
+ // the tab, and nobody notices incoming XMR. So the step consists of two actions, and both are THE SAME REQUESTS the
46
+ // current interface makes:
47
+ //
48
+ // 1) RECORDING: POST /api/evm-swaps, body { id, network, escrow, quoteId, providerId, hashlock, amount, t0, t1 }
49
+ // (www/js/evm/auth.js:111-125, saveServerSwap). Our app accepts it by the template /^\/api\/evm-swaps$/
50
+ // (app/server.mjs:1230) and requires a pass: without Authorization: Bearer *** 401 (app/server.mjs:1232).
51
+ // It checks the escrow address against the CHAIN (locker() versus the address from the pass) - that is, it
52
+ // confirms the record by the chain, not by the client's words.
53
+ // 2) WATCHING: POST /api/swaps (address + VIEW KEY + expected amount; www/js/ui/views/confirm.js:581,
54
+ // www/js/core/chainSource.js:178) and then polling GET /api/swaps/{id} (chainSource.js:184). The spend key does
55
+ // not leave and cannot: no one has it until the half is revealed.
56
+ //
57
+ // WHAT IS DELIBERATELY ABSENT HERE: a waiting policy of its own inside the step. How long to wait for the XMR to
58
+ // arrive is the caller's decision, so the watch is exposed as a separate method (`swaps.watch`) with a timeout from
59
+ // the configuration.
60
+ const MARK_FIELDS = ["id", "network", "escrow", "quoteId", "providerId", "hashlock", "amount", "t0", "t1"];
61
+ // THE FIELDS OF THE FACTORY WATCH RECORD - the same whitelist as the page client's
62
+ // (www/js/core/chainSource.js:33, ALLOWED_SESSION_FIELDS). The body is assembled FROM IT anew, so neither the spend
63
+ // half nor the spend key can get into the request, even if someone adds them to the record "just in case".
64
+ const ALLOWED_SESSION_FIELDS = ["swapId", "network", "address", "viewKey", "expectedAmountXmr", "restoreHeight", "chain"];
65
+
66
+ /**
67
+ * The mark body from the swap record. THE FIELDS ARE THE SAME AND WITH THE SAME MEANING as the interface's
68
+ * (auth.js:114-125), not a private list: only public data goes out (the escrow address, amounts, deadlines, the
69
+ * quote reference), and nothing secret - the spend key never leaves the browser under any configuration.
70
+ */
71
+ export function markBodyOf(swap, escrow, order = null) {
72
+ const e = (swap && swap.escrow) || {};
73
+ const o = order && typeof order === "object" ? order : {};
74
+ // A BIGINT AS A STRING: the order amount in wei comes both as a string and as a BigInt number (the order terms),
75
+ // and the request body is serialized to JSON, where a BigInt does not live at all (a TypeError on the very first
76
+ // call). The app expects a string.
77
+ const first = (...vals) => {
78
+ for (const v of vals) {
79
+ if (v === undefined || v === null || v === "") continue;
80
+ return typeof v === "bigint" ? v.toString() : v;
81
+ }
82
+ return null;
83
+ };
84
+ return {
85
+ id: swap.id || null,
86
+ network: swap.network || swap.chain || null,
87
+ escrow: escrow || e.address || null,
88
+ quoteId: first(swap.orderQuoteId, e.quoteId),
89
+ providerId: first(swap.providerId, e.providerId),
90
+ hashlock: first(e.hashlock, swap.hashlock),
91
+ amount: first(e.amountWei, o.amount, swap.amountWei),
92
+ t0: first(e.t0, o.t0),
93
+ t1: first(e.t1, o.t1),
94
+ };
95
+ }
96
+
97
+ /**
98
+ * THE VIEW KEY OF THE JOINT ADDRESS - THE SUM OF TWO HALVES. That is how the interface assembles it
99
+ * (www/js/ui/views/confirm.js:559; math - www/js/monero/swapKeys.js, watchViewSeedHex), and only with it does the
100
+ * wallet see incoming payments: the view and spend halves are independent in Monero.
101
+ * Recorded as 64 hex WITHOUT the 0x prefix: the app checks the key by this form (app/store.mjs:74, HEX64).
102
+ */
103
+ export function jointViewKey(swap) {
104
+ const e = (swap && swap.escrow) || {};
105
+ if (typeof e.viewHalf !== "string" || !e.viewHalf) throw new SdkError("bad-input", { field: "viewHalf" });
106
+ if (typeof e.counterViewHalf !== "string" || !e.counterViewHalf) throw new SdkError("bad-input", { field: "counterViewHalf" });
107
+ try {
108
+ const halves = engine.halves.createHalves(primitives);
109
+ return halves.combineHalves(BigInt(e.viewHalf), BigInt(e.counterViewHalf)).toString(16).padStart(64, "0");
110
+ } catch { throw new SdkError("bad-input", { field: "viewKey" }); }
111
+ }
112
+
113
+ /**
114
+ * What is visible in the app's watch response. AN EMPTY RESPONSE IS NOT COUNTED AS AN ARRIVAL: it has no receivedXmr
115
+ * field at all, and "the field is absent" and "zero" are different things (zero means "nothing arrived", absence -
116
+ * "not read"). We return null if this is not a watch response at all - emptiness must not be taken for data.
117
+ */
118
+ export function incomingOf(session) {
119
+ if (!session || typeof session !== "object") return null;
120
+ const received = Number(session.receivedXmr);
121
+ if (!Number.isFinite(received)) return null;
122
+ const confirmations = Number(session.confirmations);
123
+ // LOCKED FUNDS ARE ON THE ADDRESS BUT ARE NOT AN ARRIVAL THE SWAP CAN USE: the sender set unlock_time, and the
124
+ // service names that state. `receivedXmr` counts only what can be spent; the barred sum is `lockedXmr`, and the
125
+ // boundary (a height or a date) travels as `lockedUntil` so the interface can say until WHEN.
126
+ const locked = session.status === LOCKED_STATUS;
127
+ const lockedXmr = num(session.lockedXmr);
128
+ return {
129
+ id: session.id || null,
130
+ address: session.address || null,
131
+ status: session.status || null,
132
+ expectedXmr: num(session.expectedAmountXmr),
133
+ receivedXmr: received,
134
+ confirmations: Number.isFinite(confirmations) ? confirmations : 0,
135
+ txids: Array.isArray(session.fundingTxids) ? session.fundingTxids : [],
136
+ locked,
137
+ lockedXmr: lockedXmr === null ? 0 : lockedXmr,
138
+ lockedUntil: session.lockedUntil || null,
139
+ // AN ARRIVAL IS MONEY AT THE ADDRESS THAT IS NOT BARRED BY unlock_time. Zero, or locked, means none.
140
+ seen: received > 0 && !locked,
141
+ };
142
+ }
143
+
144
+ /** Translating the engine's record into the contract state: our names, the engine's meaning. */
145
+ export function toState(swap) {
146
+ if (!swap || typeof swap !== "object" || !swap.id) throw new SdkError("bad-input", { field: "swap" });
147
+ const d = engine.swap.derive(swap);
148
+ const steps = [];
149
+ const engineSteps = Array.isArray(d.steps) ? d.steps : [];
150
+ const funded = engineSteps.find((s) => s.key === "funded") || null;
151
+ // THE FIRST STEP IS IN THE CONTRACT BUT NOT IN THE ENGINE: the engine considers the swap already created from the
152
+ // "funded" step. Waiting for payment is a state the interface must show, so we add the step ourselves.
153
+ steps.push({ code: "awaiting_funding", done: !!(funded && funded.done), active: !(funded && funded.done), at: null });
154
+ for (const s of engineSteps) {
155
+ steps.push({
156
+ code: s.key, done: !!s.done, active: !!s.active, failed: !!s.failed,
157
+ // The step time is taken from the record only in the sandbox: in a live swap it is the demonstration time.
158
+ at: d.simulated && d.simulated.evm ? (s.atSim ?? null) : null,
159
+ });
160
+ }
161
+ const active = steps.find((s) => s.active) || [...steps].reverse().find((s) => s.done) || steps[0];
162
+ const chain = swap.network || swap.chain || null;
163
+ // The virtual machine is taken from the network registry. An unknown network has none, and "unknown" is more honest
164
+ // than the network's name: it is visible in the state and does not turn into "probably EVM".
165
+ const vm = chain ? (vmOfChain(chain) || "unknown") : "unknown";
166
+ return {
167
+ id: swap.id,
168
+ direction: swap.side === "sell" ? "sell" : "buy",
169
+ // WHAT THE LIST SHOWS, NOT ONLY THE STEPPER (www/js/ui/views/info.js, swapsView). The list needs the date,
170
+ // who gave the price, the human name of the phase and whether the swap is settled - and it must take them
171
+ // from the CORE, not by reaching into the record itself. The names repeat the record's (core/swap.js,
172
+ // createSwap: createdAt, maker, escrow).
173
+ createdAt: swap.createdAt ?? null,
174
+ maker: {
175
+ id: (swap.makerId !== undefined && swap.makerId !== null) ? swap.makerId : ((swap.maker && swap.maker.id) ?? null),
176
+ name: (swap.makerName !== undefined && swap.makerName !== null) ? swap.makerName : ((swap.maker && swap.maker.name) ?? null),
177
+ },
178
+ // THE HUMAN NAME OF THE PHASE IS THE ENGINE'S (derive -> phaseLabel): the words on the screen come from the
179
+ // same place as the state they describe, so they cannot drift apart.
180
+ phase: d.phase || null,
181
+ phaseLabel: d.phaseLabel || null,
182
+ // "LIVE OR SETTLED" IS THE RECORD'S SETTLEMENT, exactly as the screen split it before. It is NOT read from
183
+ // the phase's text: the text is for the person, the flag is for the code.
184
+ settled: !!swap.settlement,
185
+ // WHAT HAS ALREADY MOVED ON THE SWAP - the two signs the removal prompt warns about. Computed HERE from the
186
+ // record: the screen must not reach into it for them.
187
+ funds: {
188
+ receivedXmr: num(d.xmrAmount && d.xmrAmount.seen) ?? 0,
189
+ escrowLocked: !!(swap.escrow && (swap.escrow.fundTx || swap.escrow.secret)),
190
+ },
191
+ pay: { token: swap.payToken || "eth", amount: num(swap.payAmount) ?? 0 },
192
+ get: { xmr: num(swap.xmrAmount) ?? 0 },
193
+ step: active.code,
194
+ steps,
195
+ deadlines: {
196
+ readyBy: d.deadlines && d.deadlines.readyBy !== undefined ? num(d.deadlines.readyBy) : null,
197
+ t1: d.deadlines && d.deadlines.t1 !== undefined ? num(d.deadlines.t1) : null,
198
+ },
199
+ xmr: {
200
+ confirmations: num(d.xmr && (d.xmr.confirmations !== undefined ? d.xmr.confirmations : d.conf)) ?? 0,
201
+ target: num(d.xmr && d.xmr.confTarget) ?? num(d.confTarget) ?? 0,
202
+ txid: (d.xmr && Array.isArray(d.xmr.txids) && d.xmr.txids[0]) || null,
203
+ amount: {
204
+ expected: d.xmrAmount ? d.xmrAmount.expected ?? null : null,
205
+ seen: d.xmrAmount ? d.xmrAmount.seen ?? null : null,
206
+ },
207
+ },
208
+ counterparty: {
209
+ chain,
210
+ vm,
211
+ // The address of whatever secures the leg. While there is none - null, not an empty string: the interface must
212
+ // distinguish "not created yet" from "created, but the address was not read".
213
+ leg: (swap.escrow && (swap.escrow.address || swap.escrow.leg)) || null,
214
+ status: d.phase || null,
215
+ txs: (swap.escrow && (swap.escrow.txids || swap.escrow.txs)) || [],
216
+ },
217
+ actions: { markReady: !!(d.can && d.can.ready), sweep: !!(d.can && d.can.sweep), refund: !!(d.can && d.can.refund) },
218
+ simulated: { evm: !!(d.simulated && d.simulated.evm), xmr: !!(d.simulated && d.simulated.xmr) },
219
+ terminal: d.terminal ? (TERMINAL[d.terminal.kind] ?? "closed") : null,
220
+ };
221
+ }
222
+
223
+ // A PASSPHRASE SHORTER THAN THIS IS NOT ACCEPTED: a file with a weak passphrase is a file strangers will open.
224
+ const MIN_PASSPHRASE = 12;
225
+
226
+ // The record fields are taken from the request: what exactly the person changes is set by the interface, not the core.
227
+ // A FIXED DEFECT: the record lost WHERE TO WITHDRAW XMR AND WHAT IT OWES. `receiveTo` and the quote data (maker, fee,
228
+ // signature, rate) were not carried into the record at all, so the progress screen - which reads EXACTLY the record -
229
+ // knew neither the withdrawal address nor the maker, and exporting the recovery file from that screen went out with an
230
+ // empty address. The record fields stay the same as the engine's (core/swap.js, createSwap): the field name is
231
+ // `receiveAddress`.
232
+ const recordInputOf = (request) => {
233
+ const quote = request && request.quote && typeof request.quote === "object" ? request.quote : {};
234
+ const pass = (v) => (v === undefined || v === null || v === "" ? undefined : v);
235
+ return {
236
+ side: request.side || "buy",
237
+ network: quote.chain ? quote.chain : undefined,
238
+ payToken: quote.asset ? String(quote.asset).toLowerCase() : undefined,
239
+ payAmount: quote.amount !== undefined ? quote.amount : undefined,
240
+ xmrAmount: quote.size !== undefined ? quote.size : undefined,
241
+ // THE ORDER-QUOTE ID IS PART OF THE RECORD, NOT A DETAIL OF THE SCREEN (a defect found): our service uses it to
242
+ // tie the created escrow to the provider node's record, and without it the node cannot take the ETH
243
+ // (sdk/src/swap-flow.mjs: quoteId: quote.id -> swap.orderQuoteId; markBodyOf reads exactly this field).
244
+ orderQuoteId: pass(quote.id),
245
+ // WHERE TO WITHDRAW XMR: part of the record, not a detail of the screen - the progress screen exports the file by it.
246
+ receiveAddress: pass(request.receiveTo),
247
+ // WHAT THE PERSON SAW: the maker, its spread, the fee, the rate and the quote signature. Screen data, but it must
248
+ // live in the record - otherwise the progress screen shows an empty maker instead of whoever gave the price.
249
+ makerId: pass(quote.makerId), makerName: pass(quote.makerName), makerSpread: pass(quote.makerSpread),
250
+ quoteSignature: pass(quote.signature), fee: pass(quote.arrakisFee),
251
+ dexSpread: pass(quote.dexSpread), gasUsd: pass(quote.gasUsd), rate: pass(quote.rate),
252
+ };
253
+ };
254
+
255
+ // createRecord is the start step that requires a Monero wallet IN THE BROWSER (creating the wallet and the order of
256
+ // the halves). It arrives from outside, so the whole path after it (building the file, encryption, the person's
257
+ // confirmation) is written and checked already now, rather than appearing together with the browser part.
258
+ export function createSwaps({ preflight, http, createRecord, mode = null, xmrNetwork = null, call = null,
259
+ // THE PATHS AND LIMITS ARE BROUGHT BY THE CONFIGURATION: the "server" step's paths are named by names (config.route),
260
+ // and the timeout and rate of the watch polling - in config.limits. Without the configuration the step answers with a
261
+ // code rather than following invented paths.
262
+ config = null,
263
+ // A SIGN-IN PASS - A SEAM FROM OUTSIDE. Only a wallet can sign the sign-in message, and its role in the core is an
264
+ // adapter (send/receipt): windows and message signatures remain the interface's. A READY pass arrives here (as
265
+ // www/js/evm/auth.js, signIn obtains it today), and the core merely presents it.
266
+ auth = null,
267
+ watch: watchOptions = null } = {}) {
268
+ // WHAT READS THE CHAIN AT THE LOCK IS A SEAM, as in actions.mjs: by default the engine's read, from outside you can
269
+ // substitute your own. Otherwise the lock step could not be checked beyond predicting the address - and "the address
270
+ // from the receipt" would remain an argument on trust, not a verified property.
271
+ const reader = typeof call === "function" ? call : engine.evm.readContract;
272
+ // MARKING READY GOES THROUGH THE SAME READ SEAM as the lock: checking the slots and signing must go one way,
273
+ // otherwise part of the check goes past the wallet and part does not (see actions.mjs).
274
+ const actions = createActions({ call: typeof call === "function" ? call : null });
275
+ const routeOf = (name, params) => {
276
+ if (!config || typeof config.route !== "function") throw new SdkError("bad-input", { field: "config", step: "server" });
277
+ return config.route(name, params);
278
+ };
279
+ const limitOf = (name, fallback) => {
280
+ const value = config && config.limits ? config.limits[name] : null;
281
+ return Number.isFinite(Number(value)) && Number(value) > 0 ? Number(value) : fallback;
282
+ };
283
+ const records = () => {
284
+ const state = engine.store.loadState();
285
+ const all = Object.values((state && state.swaps) || {}).filter((s) => s && s.id);
286
+ return all.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0));
287
+ };
288
+
289
+ // THE GUARDS' DATA: what the core knows itself (the network, the order amount, the token allowance) plus the balance
290
+ // read by THE SAME wallet the lock will be signed with. Here too - a refusal WITH A CODE where there is no data at
291
+ // all (order terms or an amount in them): silently handing a guard an empty value means hearing "the amount is
292
+ // unknown" about a quantity that is simply not in the request.
293
+ async function guardOptionsOf(request) {
294
+ // NOT A SINGLE REFUSAL HERE UNTIL THE GUARDS ARE ASKED. The absence of order terms is NOT a reason to refuse before
295
+ // the guards: the start order is held by their precedence (see the check "a guard's refusal stops the swap with its
296
+ // own code"). If the data is absent, a guard will say "not checked" and not lock, and the next step will name the
297
+ // missing field (contextOf -> bad-input { field: "order" }). No "the amount is unknown" about a quantity simply not
298
+ // in the request: the amount is taken from the terms, and if they are absent - the refusal names EXACTLY the terms.
299
+ const order = request.order && typeof request.order === "object" ? request.order : {};
300
+ const amountWei = order.amount;
301
+ const wei = amountWei === undefined || amountWei === null || amountWei === "" ? null : String(amountWei);
302
+ const chainId = Number(order.chainId);
303
+ const chain = Number.isFinite(chainId) ? engine.config.chainByChainId(chainId) : null;
304
+ // tokensOf EXPECTS THE NETWORK ID (slug), NOT a chainId: a passed chainId matches no record, and the registry
305
+ // silently returns the tokens of the DEFAULT NETWORK (www/js/core/config.js, chainById -> DEFAULT_CHAIN).
306
+ const tokens = chain ? engine.config.tokensOf(chain.id) : [];
307
+ const payId = String((request.quote && (request.quote.token || request.quote.asset)) || "").toLowerCase();
308
+ const payToken = tokens.find((t) => String(t.symbol).toLowerCase() === payId) || null;
309
+ const nativeToken = tokens.find((t) => !t.address) || null;
310
+ const tokenIsNative = !!(payToken && !payToken.address);
311
+ const options = {
312
+ tokenIsNative,
313
+ // A STAND-IN OR A LIVE COUNTERPARTY - THE CALLER KNOWS. If there is no side, a stand-in will build it exactly in
314
+ // a mode other than live, and the guard must see that rather than consider the swap real.
315
+ standIn: !request.counterparty && mode !== "live",
316
+ // A PRODUCTION NETWORK - BY THE REGISTRY'S RULE (escrow.mode === "live" and no testnet marker), the same as the
317
+ // page's. false means "a stand-in is allowed here", not "we did not check".
318
+ liveChain: !!(chain && chain.escrow && chain.escrow.mode === "live" && !chain.testnet),
319
+ };
320
+ // THE ORDER AMOUNT - IN WEI AND ONLY IT: for the amount-coverage guards the quantity is one. No amount - the field
321
+ // will be absent entirely (the guard honestly says "not measured"), not a substituted number.
322
+ if (wei !== null) { options.payAmountWei = wei; options.requiredNativeWei = wei; }
323
+ // THE BALANCE IS READ BY THE LOCK'S WALLET. The core keeps no path into the chain of its own: we take the same seam
324
+ // as `wallet.balances` (the leg driver) and read exactly the needed tokens. Not read - we leave null: the guard
325
+ // will call it a code (balance-unchecked), and substituting zero is forbidden (zero is an empty wallet).
326
+ try {
327
+ const wallet = request.wallet;
328
+ const driver = wallet && typeof wallet.driver === "function" ? wallet.driver() : null;
329
+ const address = wallet && typeof wallet.address === "function" ? await wallet.address() : null;
330
+ const leg = legFor("evm");
331
+ const idOf = (t) => String(t.symbol).toLowerCase();
332
+ const wanted = [];
333
+ if (nativeToken) wanted.push({ id: idOf(nativeToken), symbol: nativeToken.symbol, decimals: nativeToken.decimals, address: null });
334
+ if (payToken && payToken.address) wanted.push({ id: idOf(payToken), symbol: payToken.symbol, decimals: payToken.decimals, address: payToken.address });
335
+ if (driver && address && leg && typeof leg.balances === "function" && wanted.length) {
336
+ const read = await leg.balances({ driver, address, tokens: wanted });
337
+ const pick = (token) => { const hit = token ? read.find((b) => b.token === idOf(token)) : null; return hit && hit.amount !== null ? hit.amount : null; };
338
+ const nativeWei = nativeToken ? pick(nativeToken) : null;
339
+ if (nativeWei !== null) options.nativeBalanceWei = nativeWei;
340
+ const payWei = tokenIsNative ? nativeWei : pick(payToken);
341
+ if (payWei !== null) options.payBalanceWei = payWei;
342
+ }
343
+ } catch { /* not read - the guard will name the field itself; silently considering the balance sufficient is not allowed */ }
344
+ return options;
345
+ }
346
+
347
+ /**
348
+ * RECORDING THE SWAP WITH US. The path and body are the interface's (see the step description above). A refusal is
349
+ * called by a CODE and carries the service's response in full: the connection never got through - server-unavailable,
350
+ * the service answered and did not accept - server-refused (with the status and ITS reason). The code is named so the
351
+ * interface itself decides whether to abort the swap: today it does not abort it (www/js/evm/auth.js:15-16), and that
352
+ * is its decision, not the core's silence.
353
+ */
354
+ async function markOnServer(swap, escrow, order) {
355
+ if (!http || typeof http.tryJson !== "function") throw new SdkError("bad-input", { field: "http", step: "mark" });
356
+ const mark = markBodyOf(swap, escrow, order);
357
+ const body = JSON.stringify(Object.fromEntries(MARK_FIELDS.map((field) => [field, mark[field]])));
358
+ const headers = { "content-type": "application/json" };
359
+ // THE PASS IS ASKED EVERY TIME, NOT CACHED: it is short-lived (an hour: app/server.mjs, AUTH_TOKEN_TTL_MS), and a
360
+ // cached pass would give a 401 at a step that does not move money itself but shows the swap to the service.
361
+ if (typeof auth === "function") {
362
+ let token = null;
363
+ try { token = await auth(); } catch { token = null; }
364
+ if (typeof token === "string" && token) headers.authorization = "Bearer " + token;
365
+ }
366
+ let answer = null;
367
+ try { answer = await http.tryJson(routeOf("evmSwaps"), { method: "POST", headers, body }); }
368
+ catch (error) { throw new SdkError("server-unavailable", { step: "mark", why: (error && error.code) || "fetch" }); }
369
+ if (!answer || answer.ok !== true) {
370
+ throw new SdkError("server-refused", {
371
+ step: "mark", status: answer ? answer.status : null,
372
+ why: (answer && answer.body && (answer.body.error || answer.body.detail)) || null,
373
+ });
374
+ }
375
+ return { marked: true, httpStatus: answer.status, orderBinding: (answer.body && answer.body.orderBinding) || null };
376
+ }
377
+
378
+ /**
379
+ * THE FACTORY WATCH RECORD with our service: by the same POST /api/swaps the page makes
380
+ * (www/js/ui/views/confirm.js:581). The address and the VIEW KEY go out - that is enough to see incoming payments,
381
+ * and not enough to spend them.
382
+ */
383
+ async function openWatch(swap, order) {
384
+ if (!http || typeof http.tryJson !== "function") throw new SdkError("bad-input", { field: "http", step: "watch" });
385
+ const e = (swap && swap.escrow) || {};
386
+ const address = e.moneroAddress || (swap.swapWallet && swap.swapWallet.address) || null;
387
+ if (!address) throw new SdkError("bad-input", { field: "moneroAddress" });
388
+ // THE EXPECTED AMOUNT IS MANDATORY, and this is not a formality: without it the watch cannot check the sufficiency of
389
+ // the arrival, and the app does not accept such a record (app/store.mjs:85-94). A refusal is more honest than a
390
+ // silent record by which an underpayment would look like a completed swap.
391
+ const expectedXmr = num(order && order.xmrAmount !== undefined ? order.xmrAmount : swap.xmrAmount);
392
+ if (!(expectedXmr > 0)) throw new SdkError("bad-input", { field: "expectedAmountXmr" });
393
+ const network = swap.moneroNetwork || xmrNetwork || null;
394
+ if (!network) throw new SdkError("bad-input", { field: "moneroNetwork" });
395
+ const wanted = {
396
+ swapId: swap.id || null, network, address, viewKey: jointViewKey(swap), expectedAmountXmr: expectedXmr,
397
+ // THE RESTORE HEIGHT - FROM THE SWAP RECORD (the order puts it there), not invented: a scan from zero would take
398
+ // tens of hours, and the arrivals would not be visible in a reasonable time.
399
+ restoreHeight: Number(e.birthHeight) > 0 ? Number(e.birthHeight) : null,
400
+ chain: swap.network || swap.chain || null,
401
+ };
402
+ const body = {};
403
+ for (const field of ALLOWED_SESSION_FIELDS) if (wanted[field] !== undefined && wanted[field] !== null) body[field] = wanted[field];
404
+ // THE VIEW KEY IS NOT NEEDED BY THE SERVICE FOR THE DECISION ANY MORE: since the arrival is checked by the
405
+ // client (verifyArrival), the backend may keep a watch only for notifications, and even that it can do from
406
+ // the maker's txid. So sending the key is an OPTION: `watch.sendViewKeyToServer === false` drops it from the
407
+ // record. The default keeps the current backend flow working until it stops deciding on its own.
408
+ if (watchOptions && watchOptions.sendViewKeyToServer === false) delete body.viewKey;
409
+ let answer = null;
410
+ try { answer = await http.tryJson(routeOf("swaps"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); }
411
+ catch (error) { throw new SdkError("server-unavailable", { step: "watch", why: (error && error.code) || "fetch" }); }
412
+ if (!answer || answer.ok !== true) {
413
+ throw new SdkError("server-refused", {
414
+ step: "watch", status: answer ? answer.status : null,
415
+ why: (answer && answer.body && (answer.body.error || answer.body.detail)) || null,
416
+ });
417
+ }
418
+ const session = incomingOf(answer.body);
419
+ if (!session) throw new SdkError("server-unavailable", { step: "watch", why: "bad-response" });
420
+ return { registered: true, httpStatus: answer.status, ...session,
421
+ expectedXmr: session.expectedXmr !== null ? session.expectedXmr : expectedXmr };
422
+ }
423
+
424
+ /** THE WATCH STATE: GET /api/swaps/{id} - the same path as the page's (chainSource.js:184). */
425
+ async function readWatch(id) {
426
+ if (!http || typeof http.tryJson !== "function") throw new SdkError("bad-input", { field: "http", step: "watch" });
427
+ let answer = null;
428
+ try { answer = await http.tryJson(routeOf("swap", { id }), { method: "GET" }); }
429
+ catch (error) { throw new SdkError("server-unavailable", { step: "watch", why: (error && error.code) || "fetch" }); }
430
+ if (!answer || answer.ok !== true) {
431
+ throw new SdkError("server-refused", {
432
+ step: "watch", status: answer ? answer.status : null, id,
433
+ why: (answer && answer.body && (answer.body.error || answer.body.detail)) || null,
434
+ });
435
+ }
436
+ return answer.body;
437
+ }
438
+
439
+ return {
440
+ // list/get DO NOT ASK THE NETWORK: the state lies in storage, and after a page reload the interface raises the swaps
441
+ // from it, not from memory.
442
+ async list() { return records().map(toState); },
443
+ async get(id) {
444
+ if (typeof id !== "string" || !id) throw new SdkError("bad-input", { field: "id" });
445
+ const found = records().find((s) => s.id === id);
446
+ return found ? toState(found) : null;
447
+ },
448
+
449
+ /**
450
+ * THE DEAL-PROGRESS SNAPSHOT. The progress screen shows one swap live: which steps are done, how many Monero
451
+ * confirmations are left, what is allowed now, when the refund opens, what the Monero leg is doing - plus the
452
+ * record's own facts (the escrow, the maker, the amounts, the one-time address, the destination). ALL of it is
453
+ * assembled HERE: the record is read (engine.swap.getSwap), the state is DERIVED by the core
454
+ * (engine.swap.derive) and the wallet is rebuilt from the halves (engine.swap.halvesWalletOf). The screen only
455
+ * draws this snapshot - it no longer reads the record or derives the state itself, so there is one
456
+ * implementation of the deal's rules instead of two.
457
+ *
458
+ * `null` means "there is no such record" - it is NOT an empty state.
459
+ */
460
+ async progress(id) {
461
+ if (typeof id !== "string" || !id) throw new SdkError("bad-input", { field: "id" });
462
+ const swap = engine.swap.getSwap(id);
463
+ if (!swap) return null;
464
+ return {
465
+ id: String(swap.id || id),
466
+ // THE RECORD'S OWN FIELDS. The screen shows them and hands the escrow back as the order expectations for a
467
+ // live action (markReady/claim/refund), so they travel as they are - no second copy of the record.
468
+ swap,
469
+ // THE WALLET REBUILT FROM THE HALVES: the recovery file is assembled from it, and the half-of-the-key rule
470
+ // stays the engine's (halvesWalletOf), not a second reading on the screen.
471
+ swapWallet: engine.swap.halvesWalletOf(swap),
472
+ // THE DERIVED DEAL VIEW: the phase, the allowed actions, the deadlines, the steps, the Monero leg and the
473
+ // amounts. The field names are the engine's (derive) - one source for the state and the words about it.
474
+ view: engine.swap.derive(swap),
475
+ };
476
+ },
477
+
478
+ /**
479
+ * THE DEMONSTRATION RECORD'S ACTIONS (mode "sim"): confirming readiness, refunding the ETH, sweeping the XMR.
480
+ * In a LIVE swap the same steps go through `actions` (a real escrow and the wallet adapter); here they are the
481
+ * sandbox's record mutations, kept in ONE place instead of on the screen.
482
+ */
483
+ async confirmReady(id) {
484
+ if (typeof id !== "string" || !id) throw new SdkError("bad-input", { field: "id" });
485
+ return engine.swap.confirmReady(id);
486
+ },
487
+ async refundEth(id, by = "you") {
488
+ if (typeof id !== "string" || !id) throw new SdkError("bad-input", { field: "id" });
489
+ return engine.swap.refundEth(id, by);
490
+ },
491
+ async sweepNow(id) {
492
+ if (typeof id !== "string" || !id) throw new SdkError("bad-input", { field: "id" });
493
+ return engine.swap.sweepNow(id);
494
+ },
495
+
496
+ /**
497
+ * WHERE TO WITHDRAW THE XMR. Sets the record's `receiveAddress` and saves it: the destination is part of the
498
+ * recovery file, so the change is WRITTEN (named), not inferred. The order and the chain are untouched.
499
+ * `null` - there is no such record.
500
+ */
501
+ async setDestination(id, address) {
502
+ if (typeof id !== "string" || !id) throw new SdkError("bad-input", { field: "id" });
503
+ if (typeof address !== "string" || !address) throw new SdkError("bad-input", { field: "address" });
504
+ const swap = engine.swap.getSwap(id);
505
+ if (!swap) return null;
506
+ swap.receiveAddress = address;
507
+ return engine.swap.saveSwap(swap);
508
+ },
509
+
510
+ /**
511
+ * THE MONERO NETWORK OF AN ADDRESS, BY ITS SHAPE. The format and the network prefixes live in one module
512
+ * (engine.xmrAddress), the SAME one the Monero wallet adapter uses - the interface must not keep a prefix table
513
+ * of its own. `null` - not an address of a known network.
514
+ */
515
+ async addressNetwork(address) {
516
+ if (typeof address !== "string" || !address) return null;
517
+ try { return engine.xmrAddress.networkFromShape(address); } catch { return null; }
518
+ },
519
+
520
+ /**
521
+ * REMOVING A RECORD FROM THE LIST. The record leaves THIS store only: on the service and in the contract
522
+ * everything stays, so this is a tidy-up of the list, not a cancellation of the swap. `false` means there was
523
+ * no such record - the caller tells that apart from a refusal.
524
+ */
525
+ async forget(id) {
526
+ if (typeof id !== "string" || !id) throw new SdkError("bad-input", { field: "id" });
527
+ let state = null;
528
+ try { state = engine.store.loadState(); } catch { throw new SdkError("storage-unavailable", { step: "forget" }); }
529
+ const swaps = state && state.swaps;
530
+ if (!swaps || !swaps[id]) return false;
531
+ delete swaps[id];
532
+ try { engine.store.saveState(state); } catch { throw new SdkError("storage-unavailable", { step: "forget" }); }
533
+ return true;
534
+ },
535
+
536
+ /**
537
+ * THE SERVICE'S SWAP LIST, MERGED INTO THE LOCAL ONE. The path and the body are the page's
538
+ * (www/js/evm/auth.js, loadServerSwaps: GET /api/evm-swaps, the address ties the list to the wallet). The
539
+ * merge rule is the ENGINE'S (core/swap.js, addServerSwaps): a local record always wins, the service only
540
+ * adds what this browser does not have, and the rules are not written a second time here. The pass is
541
+ * obtained by the interface and only presented by the core (the `auth` seam), so an unauthenticated call is
542
+ * the service's own refusal with a code, not a silent empty list.
543
+ */
544
+ async sync() {
545
+ if (!http || typeof http.tryJson !== "function") throw new SdkError("bad-input", { field: "http", step: "sync" });
546
+ const headers = {};
547
+ if (typeof auth === "function") {
548
+ let token = null;
549
+ try { token = await auth(); } catch { token = null; }
550
+ if (typeof token === "string" && token) headers.authorization = "Bearer " + token;
551
+ }
552
+ let answer = null;
553
+ try { answer = await http.tryJson(routeOf("evmSwaps"), { method: "GET", headers }); }
554
+ catch (error) { throw new SdkError("server-unavailable", { step: "sync", why: (error && error.code) || "fetch" }); }
555
+ if (!answer || answer.ok !== true) {
556
+ throw new SdkError("server-refused", {
557
+ step: "sync", status: answer ? answer.status : null,
558
+ why: (answer && answer.body && (answer.body.error || answer.body.detail)) || null,
559
+ });
560
+ }
561
+ // A GARBAGE ANSWER IS A REFUSAL, NOT AN EMPTY LIST: a non-JSON body (an SPA fallback, a proxy error page) must
562
+ // not look like "the service has no swaps" - the two are different, and only one of them means "synced".
563
+ const body = answer.body;
564
+ if (!body || !Array.isArray(body.swaps)) throw new SdkError("server-unavailable", { step: "sync", why: "bad-response" });
565
+ const added = engine.swap.addServerSwaps(body.swaps);
566
+ return { ok: true, added, seen: body.swaps.length };
567
+ },
568
+
569
+ // THE ARRIVAL VERDICT WITHOUT THE WATCH LOOP, and the deadline check as a separate call: an interface may
570
+ // want to run both itself. The watch uses exactly these, so there is one decision, not two.
571
+ verifyArrival: (request) => xmrArrival.verifyArrival(request),
572
+ assertTimeForConfirmations: (request) => xmrArrival.assertTimeForConfirmations(request),
573
+
574
+ /**
575
+ * THE CLIENT'S OWN EARLY REFUND WHEN THE XMR DID NOT ARRIVE (issue #88). After `readyBy` the maker may claim
576
+ * even without the ready mark, and the early-refund window closes; so while the interface is open a depositor
577
+ * who sees no XMR must take the ETH back BEFORE `readyBy`. The arrival is decided by THE SAME rule the watch
578
+ * uses (xmr-arrival.verifyArrival): two nodes of different operators on one block, and money barred by
579
+ * unlock_time is not an arrival.
580
+ *
581
+ * ONLY A POSITIVE "NOT ARRIVED" REFUNDS (state `pending` or `one-node`). A failure to check - no reachable
582
+ * node, a disagreement, an underpayment, locked money - is NOT "no money": verifyArrival throws its own code
583
+ * and NOTHING is signed, so a broken or lagging witness can never look like an empty address.
584
+ *
585
+ * IT FIRES ONLY WHEN LESS THAN THE CONFIRMATIONS' TIME IS LEFT before `readyBy`
586
+ * (`minConfirmations * blockTimeSec`, overridable with `leadSec`): refunding earlier would abandon a maker who
587
+ * still has time to deliver. The refund goes through the same action seam as the screen (actions.refund), and
588
+ * the order expectations are mandatory.
589
+ *
590
+ * The seams `deps.verify` / `deps.refund` / `deps.now` exist for the checks; in production they are the real
591
+ * arrival check and the real refund.
592
+ */
593
+ async refundIfXmrMissing(request = {}, deps = {}) {
594
+ const verify = typeof deps.verify === "function" ? deps.verify : xmrArrival.verifyArrival;
595
+ const refund = typeof deps.refund === "function" ? deps.refund : actions.refund;
596
+ const nowOf = typeof deps.now === "function" ? deps.now : () => Math.floor(Date.now() / 1000);
597
+ if (!request || typeof request !== "object") throw new SdkError("bad-input", { field: "request" });
598
+ const swap = request.swap && typeof request.swap === "object" ? request.swap : null;
599
+ const e = (swap && swap.escrow) || {};
600
+ const escrow = request.escrow || e.address || null;
601
+ if (!escrow) throw new SdkError("bad-input", { field: "escrow" });
602
+ const halfLocker = request.halfLocker || e.half || null;
603
+ if (!halfLocker) throw new SdkError("bad-input", { field: "halfLocker" });
604
+ if (!request.expect || typeof request.expect !== "object") throw new SdkError("bad-input", { field: "expect" });
605
+ const address = request.address || e.moneroAddress || null;
606
+ if (!address) throw new SdkError("bad-input", { field: "address" });
607
+ const viewSecret = request.viewSecret !== undefined && request.viewSecret !== null
608
+ ? request.viewSecret : (swap ? jointViewKey(swap) : null);
609
+ if (viewSecret === undefined || viewSecret === null) throw new SdkError("bad-input", { field: "viewSecret" });
610
+ const spendPub = request.spendPub || e.moneroSpendPub || null;
611
+ if (!spendPub) throw new SdkError("bad-input", { field: "spendPub" });
612
+ // THE EXPECTED SUM IN ATOMIC UNITS. Given explicitly, or derived from the record's XMR amount (1 XMR = 10^12).
613
+ let expectedAtomic = request.expectedAtomic !== undefined && request.expectedAtomic !== null
614
+ ? request.expectedAtomic : (e.expectedAtomic !== undefined && e.expectedAtomic !== null ? e.expectedAtomic : null);
615
+ if (expectedAtomic === null && swap && Number.isFinite(Number(swap.xmrAmount))) {
616
+ expectedAtomic = BigInt(Math.round(Number(swap.xmrAmount) * 1e12)).toString();
617
+ }
618
+ if (expectedAtomic === null) throw new SdkError("bad-input", { field: "expectedAtomic" });
619
+ const readyBy = Number(request.readyBySec !== undefined && request.readyBySec !== null ? request.readyBySec : e.readyBy);
620
+ if (!Number.isFinite(readyBy) || readyBy <= 0) throw new SdkError("bad-input", { field: "readyBySec" });
621
+ const minConfirmations = Number.isFinite(Number(request.minConfirmations)) && Number(request.minConfirmations) > 0
622
+ ? Math.trunc(Number(request.minConfirmations)) : limitOf("xmrConfirmations", 10);
623
+ const blockTimeSec = Number.isFinite(Number(request.blockTimeSec)) && Number(request.blockTimeSec) > 0
624
+ ? Number(request.blockTimeSec) : limitOf("xmrBlockTimeSec", 120);
625
+ const leadSec = Number.isFinite(Number(request.leadSec)) && Number(request.leadSec) >= 0
626
+ ? Number(request.leadSec) : minConfirmations * blockTimeSec;
627
+ const nowSec = Number.isFinite(Number(request.nowSec)) ? Number(request.nowSec) : nowOf();
628
+ const leftSec = Math.floor(readyBy - nowSec);
629
+ // PAST `readyBy` THE EARLY WINDOW IS GONE and the claim window is open: refunding is no longer ours to open.
630
+ if (leftSec <= 0) return { refunded: false, state: "claim-window", leftSec, readyBy, nowSec };
631
+ // TIME STILL LEFT BEYOND THE RESERVE - the maker has room to deliver; a refund now would abandon a live trade.
632
+ if (leftSec > leadSec) return { refunded: false, state: "waiting", leftSec, needSec: leadSec, readyBy, nowSec };
633
+
634
+ const verdict = await verify({
635
+ nodes: request.nodes, address, viewSecret, spendPub, expectedAtomic, minConfirmations,
636
+ txid: request.txid || null, fromHeight: request.fromHeight === undefined ? null : request.fromHeight,
637
+ timeoutMs: request.timeoutMs, nowSec,
638
+ });
639
+ // THE XMR IS THERE - NOTHING IS REFUNDED. This is the decisive condition of the guard: without it the
640
+ // client would take the ETH back from under a maker who has already sent the XMR.
641
+ if (verdict.seen) return { refunded: false, state: "arrived", leftSec, verdict };
642
+ // NOT ARRIVED (pending / one-node) - the depositor takes the ETH back through the same action seam as the
643
+ // screen. A throw from `verify` above never reaches here and signs nothing.
644
+ const res = await refund({ escrow, halfLocker, expect: request.expect, onStep: request.onStep }, request.wallet);
645
+ return { refunded: true, state: "refunded", hash: (res && res.hash) || null, leftSec, verified: verdict };
646
+ },
647
+
648
+ async start(request) {
649
+ if (!request || typeof request !== "object") throw new SdkError("bad-input", { field: "request" });
650
+ if (!request.quote || typeof request.quote !== "object") throw new SdkError("bad-input", { field: "quote" });
651
+ if (typeof request.onRecoveryFile !== "function") throw new SdkError("bad-input", { field: "onRecoveryFile" });
652
+ if (!request.receiveTo || typeof request.receiveTo !== "string") throw new SdkError("bad-input", { field: "receiveTo" });
653
+ // THE ORDER CONTEXT IS MANDATORY AND NOT INVENTED. It is assembled by the engine FROM THE VERY FIELDS the firm
654
+ // quote's signature is bound to (orderContext.js, FIELDS), because the DLEQ proof proves a half FOR THESE TERMS.
655
+ // Substituting an empty value here would mean issuing a key bound to nothing.
656
+
657
+ // 1. THE GUARDS. THE DECISION IS BY `blocks`, AS THE ENGINE ITSELF DOES: the signature is locked only by a check
658
+ // that directly forbids (preSignGuards, signingGateVerdict: blocks === true), while "not measured" (checked ===
659
+ // false) is a state of the data, not a prohibition. The previous revision called the guards WITH AN EMPTY DATA SET
660
+ // and counted any `ok === false` as a refusal, which made a live start run into it at the very first step.
661
+ //
662
+ // The guards are fed what the core HAS: the network (their guards take it themselves - the registry plus the
663
+ // wallet state), the order amount in wei (it is in the terms) and the token allowance (native or ERC-20 - by the
664
+ // network registry).
665
+ const guardOptions = await guardOptionsOf(request);
666
+ const report = preflight ? await preflight(request.quote, guardOptions) : null;
667
+ const checks = (report && (report.checks || report.results)) || [];
668
+ const blocking = checks.filter((c) => c && c.blocks === true);
669
+ if (blocking.length) {
670
+ const first = blocking[0];
671
+ throw new SdkError(first.code || "bad-input", { ...(first.params || {}), kind: first.kind, surface: "swaps.start" });
672
+ }
673
+
674
+ // 2. THE SWAP RECORD. For now only the interface with a browser wallet can do it - and that is NAMED, not covered
675
+ // by a stub.
676
+ // THE SWAP RECORD IS CREATED ITSELF if the interface did not bring its own: the engine can do it as a pure state
677
+ // record, and our side of the order is computed from the core's primitives.
678
+ // THE RECORD IS CREATED TOGETHER WITH OUR SIDE: the order terms go into it, and the side is built by the engine's
679
+ // builder (with the DLEQ proof and the encryption key), not by the core's own arithmetic.
680
+ const makeRecord = typeof createRecord === "function" ? createRecord
681
+ : async (req) => recordModule.createRecord(recordInputOf(req), { order: req.order });
682
+ const passphrase = typeof request.passphrase === "string" ? request.passphrase : "";
683
+ if (passphrase.length < MIN_PASSPHRASE) throw new SdkError("bad-input", { field: "passphrase", min: MIN_PASSPHRASE });
684
+ // THE ORDER TERMS ARE CHECKED BEFORE THE RECORD. The context is assembled by the engine from the very fields the
685
+ // firm quote's signature is bound to; incomplete terms - a refusal with a named field, not a substitution of empty
686
+ // values: the DLEQ proof would then be bound to nothing.
687
+ const orderContext = contextOf(request.order);
688
+
689
+ let made = null;
690
+ try { made = await makeRecord(request); } catch (error) {
691
+ // THE CODE MATTERS, NOT THE CLASS: an error with a code (including from another instance of the module) passes
692
+ // as is; without a code it means a breakdown inside the record, and it is named as a storage refusal.
693
+ if (error && typeof error.code === "string") throw error;
694
+ throw new SdkError("storage-unavailable", { step: "create-record" });
695
+ }
696
+ if (!made || !made.swap) throw new SdkError("bad-input", { field: "record" });
697
+
698
+ // 2b. THE ORDER. IT GOES BEFORE THE FILE and this is not a rearrangement for convenience: the halves and the
699
+ // counterparty's open points, without which the recovery file is empty, appear exactly here. The counterparty's
700
+ // side comes from outside; in the sandbox the engine builds it as a stand-in, but on a live chain a stand-in would
701
+ // mean the money goes against a non-existent side, so there it is a refusal.
702
+ let record = made;
703
+ // THE ORDER SIDE BUILT BY THE CORE STAYS UNTIL THE END OF THE START: the points and commitments for the lock terms
704
+ // are taken from it - otherwise the record and the order would diverge, and the slot check against the chain would
705
+ // go red (and rightly so). There is no second build of the side here.
706
+ let builtSide = null;
707
+ if (!record.swapWallet) {
708
+ const step = orderStep({
709
+ order: request.order, side: made.side || null, counterparty: request.counterparty || null,
710
+ standIn: mode !== "live", network: (record.swap.moneroNetwork || xmrNetwork || null),
711
+ });
712
+ // THE RECORD FIELDS ARE UNDER THE VERY NAMES THE ENGINE READS. So the record, after a page reload, again yields a
713
+ // wallet from the halves (`halvesWalletOf`), rather than losing the address together with the tab.
714
+ const escrow = record.swap.escrow && typeof record.swap.escrow === "object" ? record.swap.escrow : {};
715
+ record.swap.escrow = { ...escrow,
716
+ half: step.wallet.spendHalf, viewHalf: step.wallet.viewHalf,
717
+ edPointLocker: step.own.spendPoint, edViewPointLocker: step.own.viewPoint,
718
+ edPointClaimer: step.wallet.otherSpendPoint, counterViewPoint: step.wallet.otherViewPoint,
719
+ counterViewHalf: step.wallet.otherViewHalf,
720
+ moneroAddress: step.wallet.address, moneroSpendPub: step.wallet.spendPub, moneroViewPub: step.wallet.viewPub,
721
+ moneroNetwork: step.wallet.network, standIn: step.counterparty.standIn,
722
+ providerId: step.counterparty.providerId, orderContext: step.context,
723
+ };
724
+ try { engine.swap.saveSwap(record.swap); } catch { throw new SdkError("storage-unavailable", { step: "save-swap" }); }
725
+ record = { ...record, swapWallet: engine.swap.halvesWalletOf(record.swap) };
726
+ if (!record.swapWallet) throw new SdkError("bad-input", { field: "swapWallet" });
727
+ builtSide = step;
728
+ }
729
+
730
+ // 2a. THE RECOVERY FILE IS ASSEMBLED AND CHECKED BEFORE THE PERSON. A file that will not open is worse than no
731
+ // file: the person will rely on it. So we check on our side, before the screen.
732
+ let payload = null;
733
+ try {
734
+ payload = engine.recoveryFile.buildPayload({
735
+ swap: record.swap, swapWallet: record.swapWallet, escrow: record.escrow,
736
+ receiveAddress: record.receiveTo || request.receiveTo, restoreHeight: record.restoreHeight ?? null,
737
+ });
738
+ engine.recoveryFile.validatePayload(payload);
739
+ } catch { throw new SdkError("recovery-failed", { step: "build-payload" }); }
740
+
741
+ let file = null;
742
+ try {
743
+ file = { name: engine.recoveryFile.recoveryFileName(payload.swapId), contents: await engine.recoveryFile.createRecoveryFile(payload, passphrase) };
744
+ } catch { throw new SdkError("recovery-failed", { step: "encrypt" }); }
745
+
746
+ // 3. THE PERSON'S CONFIRMATION - THE GATE. No action that locks funds goes earlier: otherwise the lock would end
747
+ // up without a key if the browser is lost. "Did not answer" is a refusal, not consent.
748
+ const confirmed = await request.onRecoveryFile(file);
749
+ if (confirmed !== true) throw new SdkError("recovery-declined", { swapId: payload.swapId });
750
+
751
+ // 4. LOCKING THE FUNDS. The wallet comes FROM OUTSIDE (an adapter with `send`/`receipt`): signing is possible only
752
+ // in it, and the core keeps no path into the chain of its own. The absence of a wallet is a REFUSAL WITH A CODE,
753
+ // not a substitution of something by default: a lock without a signature would either not happen or go out with
754
+ // the wrong key. The recovery file has by now been assembled and confirmed by the person - a lock without a file
755
+ // is not done.
756
+ const wallet = request.wallet;
757
+ if (!wallet || typeof wallet.send !== "function" || typeof wallet.receipt !== "function") {
758
+ throw new SdkError("bad-input", { field: "wallet", missing: ["send", "receipt"], surface: "swaps.start", step: "lock" });
759
+ }
760
+ // THE LOCK TERMS ARE THE SAME AS THE ORDER'S, and are taken from the caller: exactly these fields enter the
761
+ // context the DLEQ proof is bound to (orderContext.js, FIELDS). The deadlines and composition are checked by the
762
+ // engine (lock.send -> escrow.orderTerms) BEFORE sending, not by our own arithmetic: the contract sets the rules.
763
+ // In the order terms the amount is already in wei, so amountWei is that same one, without recomputation.
764
+ // THE LOCK TERMS ARE FROM THE SAME SIDE THAT WENT INTO THE RECORD. The points and commitments are computed by the
765
+ // core (orderStep), not the caller: they do not know the half the core just built, and a second build of it would
766
+ // be a second side. The deadlines, composition, amounts and addresses remain the caller's; the core merely ADDS
767
+ // what follows from the built side. When the caller brought the side (createRecord returned a ready record with
768
+ // its own side), builtSide is empty - then the terms are taken as passed.
769
+ const derived = builtSide ? {
770
+ commitHalfLocker: builtSide.wallet.commitHalfLocker,
771
+ commitHalfClaimer: builtSide.wallet.commitHalfClaimer,
772
+ edPointLocker: builtSide.own.spendPoint,
773
+ edPointClaimer: builtSide.wallet.otherSpendPoint,
774
+ edViewPointLocker: builtSide.own.viewPoint,
775
+ } : {};
776
+ const lockRequest = { ...request.order, ...derived, amountWei: request.order.amount };
777
+ // THE SIGNED QUOTE: the fee, the registry address and the provider live in IT, not in the factory
778
+ // (decision #76). The caller brings it (the interface got it from the maker); without it the lock is not signed.
779
+ lockRequest.quote = request.orderQuote || null;
780
+ let funded = null;
781
+ try { funded = await lock.send(lockRequest, wallet, { call: reader }); }
782
+ catch (error) {
783
+ if (error && typeof error.code === "string") throw error;
784
+ throw new SdkError("server-unavailable", { step: "lock" });
785
+ }
786
+ // THE ESCROW ADDRESS IS TAKEN FROM THE RECEIPT (lock.send does this), not from a prediction. It is also written
787
+ // into the swap: otherwise the progress screen and the explorer link would show an address that is not on the
788
+ // chain (the page does this on its side: www/js/ui/views/confirm.js:463).
789
+ const escrowAddress = (funded && funded.escrow) || null;
790
+ if (escrowAddress) {
791
+ const escrowRecord = record.swap.escrow && typeof record.swap.escrow === "object" ? record.swap.escrow : {};
792
+ record.swap.escrow = { ...escrowRecord, address: escrowAddress,
793
+ // THE FUNDING HEIGHT - FROM THE RECEIPT: the XMR watch and the recovery file take it from here.
794
+ birthHeight: Number(funded.birthBlock) > 0 ? Number(funded.birthBlock) : (escrowRecord.birthHeight ?? null) };
795
+ try { engine.swap.saveSwap(record.swap); } catch { throw new SdkError("storage-unavailable", { step: "save-escrow" }); }
796
+ }
797
+
798
+ // 5. THE SERVER: RECORDING THE SWAP WITH US, THEN THE FACTORY WATCH RECORD FOR THE MONERO ADDRESS.
799
+ // THE ORDER IS EXACTLY THIS: first the swap becomes known to the service (by it, the service checks the escrow
800
+ // address against the CHAIN), and only then is the watch opened. The reverse order would leave a watch on a swap
801
+ // the service does not know.
802
+ // IF ANYTHING OF THIS FAILED - THE REFUSAL GOES OUT AS A CODE (server-unavailable / server-refused) with the step
803
+ // and the service's reason: the funds are already locked, and this must not be hidden in silence or in "success".
804
+ const server = await markOnServer(record.swap, escrowAddress, request.order);
805
+ const watch = await openWatch(record.swap, request.order);
806
+ // THE STATE IS NAMED, NOT PASSED OFF AS COMPLETION: the swap reached exactly "waiting for XMR at the joint address",
807
+ // and there is no arrival yet - `receivedXmr` is taken from the service's response, not implied by zero.
808
+ return {
809
+ id: record.swap.id || null,
810
+ escrow: escrowAddress,
811
+ address: watch.address,
812
+ step: "xmr-incoming",
813
+ server,
814
+ watch,
815
+ next: "watch",
816
+ };
817
+ },
818
+
819
+ /**
820
+ * WAIT FOR THE XMR TO ARRIVE AT THE JOINT ADDRESS - the whole point of opening the watch.
821
+ *
822
+ * The polling uses THE SAME request as the page's (GET /api/swaps/{id}), the rate and the timeout - from the
823
+ * configuration (config.limits.watchPollMs/watchTimeoutMs, the engine's defaults too) and are overridable here.
824
+ * IT ARRIVED - we return the FACT (the amount, the confirmations, the txid); WE DID NOT WAIT - a refusal with the
825
+ * code `xmr-timeout`, separate from the others, so "the wait expired" is confused with neither a breakdown nor an
826
+ * empty response. XMR ON THE ADDRESS BUT BARRED BY unlock_time is a THIRD answer with its own code `xmr-locked`
827
+ * (the boundary - a height or a date - is in `until`): such funds are not an arrival, and the ready mark is not
828
+ * signed under them.
829
+ *
830
+ * MARKING READY HAPPENS RIGHT HERE, AND ONLY AFTER THE ARRIVAL: `actions.markReady` allows a foreign address to
831
+ * take the settlement, so it requires the order expectations (`expect`) - the slot check against the chain stands in
832
+ * it BEFORE the signature. The caller passes them: the core does not invent the commitments of the order it signs.
833
+ */
834
+ async watch(request) {
835
+ if (!request || typeof request !== "object") throw new SdkError("bad-input", { field: "request" });
836
+ const id = typeof request.id === "string" && request.id ? request.id : null;
837
+ if (!id) throw new SdkError("bad-input", { field: "id" });
838
+ // THE EXPECTATIONS AND THE WALLET COME AS A PAIR: with one of them there is nothing to check against, with the
839
+ // other nothing to sign with. Skipping silently is not allowed - a signature without a check would allow a foreign
840
+ // order to take the settlement.
841
+ const wantExpect = request.expect !== undefined && request.expect !== null;
842
+ const wantWallet = request.wallet !== undefined && request.wallet !== null;
843
+ if (wantExpect !== wantWallet) throw new SdkError("bad-input", { field: wantExpect ? "wallet" : "expect" });
844
+ if (wantExpect && !(request.escrow || request.address)) throw new SdkError("bad-input", { field: "escrow" });
845
+ const pollMs = Number.isFinite(Number(request.pollMs)) && Number(request.pollMs) > 0 ? Number(request.pollMs) : limitOf("watchPollMs", 5000);
846
+ const timeoutMs = Number.isFinite(Number(request.timeoutMs)) && Number(request.timeoutMs) > 0 ? Number(request.timeoutMs) : limitOf("watchTimeoutMs", 1_200_000);
847
+ // THE CONFIRMATION TARGET - FROM THE CONFIGURATION (one number, the owner's decision: 10).
848
+ const minConfirmations = Number.isFinite(Number(request.minConfirmations)) && Number(request.minConfirmations) > 0
849
+ ? Math.trunc(Number(request.minConfirmations)) : limitOf("xmrConfirmations", 10);
850
+ // THE CLIENT-SIDE CHECK IS OPT-IN BY ITS DATA: it needs the joint address, the joint view secret and the
851
+ // joint spend public key - all of which the client has and the backend no longer needs. Without them the
852
+ // watch keeps the service's answer (notifications only); with them the CLIENT decides.
853
+ const verifyRequest = request.verify && typeof request.verify === "object"
854
+ ? { ...request.verify,
855
+ nodes: Array.isArray(request.verify.nodes) ? request.verify.nodes
856
+ : (config && typeof config.nodesFor === "function" ? config.nodesFor(request.verify.network || request.network || xmrNetwork) : []),
857
+ minConfirmations }
858
+ : null;
859
+ // LESS TIME LEFT THAN THE CONFIRMATIONS TAKE - NAMED AND NOT STARTED (issue #85, the owner's decision).
860
+ if (verifyRequest) {
861
+ const readyBy = request.readyBySec !== undefined && request.readyBySec !== null ? Number(request.readyBySec)
862
+ : (request.deadline && request.deadline.readyBy !== undefined ? Number(request.deadline.readyBy) : null);
863
+ if (readyBy) xmrArrival.assertTimeForConfirmations({ readyBySec: readyBy, nowSec: Math.floor(Date.now() / 1000), minConfirmations, blockTimeSec: limitOf("xmrBlockTimeSec", 120) });
864
+ }
865
+ const started = Date.now();
866
+ let last = null;
867
+ for (;;) {
868
+ const body = await readWatch(id);
869
+ last = incomingOf(body);
870
+ // AN EMPTY OR FOREIGN RESPONSE IS NOT AN ARRIVAL: counting it as incoming would mean declaring money that was
871
+ // never seen. This is a named refusal, not "let us wait some more".
872
+ if (!last) throw new SdkError("server-unavailable", { step: "watch", why: "bad-response", id });
873
+ // XMR IS ON THE ADDRESS BUT LOCKED BY THE TRANSACTION'S unlock_time: a NAMED refusal, not "keep waiting".
874
+ // The ready mark hands the escrow to the maker; under funds that cannot be spent the maker would take the
875
+ // ETH and the XMR would stay locked (issue #84), so nothing is signed. The boundary is in `until`.
876
+ if (last.locked) {
877
+ const until = last.lockedUntil || {};
878
+ throw new SdkError("xmr-locked", { id, address: last.address, status: last.status,
879
+ expectedXmr: last.expectedXmr, receivedXmr: last.receivedXmr, lockedXmr: last.lockedXmr,
880
+ // The boundary goes as NUMBERS and their kind - a block height or unix seconds - never as text:
881
+ // turning them into a date the person reads is the interface's dictionary (www/js/sdk/codes.js).
882
+ untilHeight: until.untilHeight ?? null, untilTime: until.untilTime ?? null,
883
+ waitedMs: Date.now() - started });
884
+ }
885
+ // THE CLIENT'S OWN ANSWER COMES FIRST AND DECIDES. The backend's `seen` may still be false (it no longer
886
+ // holds the view key), so it is not consulted when the client verifies. A disagreement or locked money
887
+ // throws its own code above and closes the button; one operator / few confirmations name a WAITING state.
888
+ if (verifyRequest) {
889
+ const verdict = await xmrArrival.verifyArrival({ ...verifyRequest, nowSec: Math.floor(Date.now() / 1000) });
890
+ last.verify = verdict;
891
+ if (!verdict.seen) {
892
+ if (Date.now() - started >= timeoutMs) {
893
+ throw new SdkError("xmr-timeout", { id, address: last.address, expectedXmr: last.expectedXmr,
894
+ state: verdict.state, confirmations: verdict.confirmations ?? 0, waitedMs: Date.now() - started, timeoutMs, pollMs });
895
+ }
896
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
897
+ continue;
898
+ }
899
+ const verified = { seen: true, id, address: last.address, status: last.status,
900
+ expectedXmr: last.expectedXmr, receivedXmr: Number(verdict.amountAtomic) / 1e12, confirmations: verdict.confirmations,
901
+ txids: [verdict.txid], at: Date.now(),
902
+ verified: { height: verdict.height, blockHash: verdict.blockHash, operators: verdict.operators, agreedBy: verdict.agreedBy },
903
+ source: "client" };
904
+ if (wantExpect && wantWallet) {
905
+ verified.marked = await actions.markReady(
906
+ { escrow: request.escrow || last.address, expect: request.expect, onStep: request.onStep }, request.wallet);
907
+ } else {
908
+ verified.next = "mark-ready";
909
+ }
910
+ return verified;
911
+ }
912
+ if (last.seen) {
913
+ const result = { seen: true, id, address: last.address, status: last.status,
914
+ expectedXmr: last.expectedXmr, receivedXmr: last.receivedXmr, confirmations: last.confirmations,
915
+ txids: last.txids, at: Date.now() };
916
+ if (wantExpect && wantWallet) {
917
+ result.marked = await actions.markReady(
918
+ { escrow: request.escrow || last.address, expect: request.expect, onStep: request.onStep }, request.wallet);
919
+ } else {
920
+ // WITHOUT EXPECTATIONS AND A WALLET, THE WATCH HONESTLY NAMES THE NEXT STEP, rather than pretending the mark
921
+ // has already been placed.
922
+ result.next = "mark-ready";
923
+ }
924
+ return result;
925
+ }
926
+ if (Date.now() - started >= timeoutMs) {
927
+ throw new SdkError("xmr-timeout", { id, address: last.address, expectedXmr: last.expectedXmr,
928
+ receivedXmr: last.receivedXmr, confirmations: last.confirmations, waitedMs: Date.now() - started,
929
+ timeoutMs, pollMs });
930
+ }
931
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
932
+ }
933
+ },
934
+ };
935
+ }