@ninsei-labs/sdk 0.39.0 → 0.39.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +115 -0
- package/README.md +21 -0
- package/engine/core/chainSource.js +306 -306
- package/engine/core/config.js +29 -2
- package/engine/core/sellWindow.js +43 -43
- package/engine/core/swap.js +1153 -1153
- package/engine/evm/claimGas.js +88 -87
- package/engine/evm/dex.js +836 -836
- package/engine/evm/factory.js +106 -0
- package/engine/evm/funding.js +11 -11
- package/engine/evm/gasReserve.js +88 -88
- package/engine/evm/prices.js +179 -179
- package/engine/recovery/recoveryFile.js +4 -1
- package/engine/sdk/bridge.js +7 -2
- package/index.d.ts +18 -2
- package/package.json +4 -4
- package/src/actions.mjs +72 -72
- package/src/adapters/node-test-evm-wallet.mjs +345 -345
- package/src/config.mjs +9 -6
- package/src/engine.mjs +53 -53
- package/src/index.mjs +10 -1
- package/src/levelsWalkSpec.mjs +21 -4
- package/src/lock.mjs +140 -140
- package/src/order.mjs +212 -212
- package/src/quotes.mjs +495 -465
- package/src/swaps.mjs +935 -935
- package/src/sweep.mjs +29 -4
- package/src/xmr-arrival.mjs +289 -289
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
|
+
}
|