@ninsei-labs/sdk 0.39.3 → 0.40.0
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 +55 -0
- package/engine/atomic/dleq2.js +86 -111
- package/engine/atomic/halfenc.js +58 -72
- package/engine/atomic/halves.js +31 -53
- package/engine/atomic/order-client.js +15 -16
- package/engine/atomic/order.js +44 -62
- package/engine/atomic/orderContext.js +26 -50
- package/engine/atomic/orderHalfCommit.js +17 -18
- package/engine/core/chainSource.js +54 -69
- package/engine/core/clock.js +11 -12
- package/engine/core/config.js +561 -451
- package/engine/core/finality.js +34 -53
- package/engine/core/format.js +35 -36
- package/engine/core/preSignGuards.js +109 -117
- package/engine/core/rfqSource.js +57 -75
- package/engine/core/sellWindow.js +23 -24
- package/engine/core/store.js +20 -21
- package/engine/core/swap.js +242 -243
- package/engine/evm/amounts.js +50 -51
- package/engine/evm/asyncExec.js +577 -0
- package/engine/evm/asyncLegs.js +308 -0
- package/engine/evm/asyncRoute.js +179 -0
- package/engine/evm/auth.js +41 -48
- package/engine/evm/claimGas.js +28 -47
- package/engine/evm/depositor.js +39 -43
- package/engine/evm/dex.js +266 -267
- package/engine/evm/escrow.js +113 -129
- package/engine/evm/factory.js +71 -77
- package/engine/evm/fees.js +41 -42
- package/engine/evm/funding.js +160 -162
- package/engine/evm/gasReserve.js +67 -36
- package/engine/evm/index.js +46 -53
- package/engine/evm/injected.js +34 -42
- package/engine/evm/permit.js +129 -56
- package/engine/evm/prices.js +30 -45
- package/engine/evm/session.js +188 -189
- package/engine/mock/chain.js +17 -18
- package/engine/monero/address.js +33 -48
- package/engine/monero/halvesSweep.js +25 -43
- package/engine/monero/node.js +14 -15
- package/engine/monero/scan.js +82 -95
- package/engine/monero/swapKeys.js +30 -33
- package/engine/monero/unlock.js +17 -20
- package/engine/monero/wallet.js +32 -42
- package/engine/recovery/recoveryFile.js +154 -161
- package/engine/recovery/restore.js +50 -61
- package/engine/sdk/bridge.js +244 -234
- package/engine/ui/dom.js +4 -5
- package/engine/ui/walletModal.js +20 -31
- package/index.d.ts +8 -0
- package/package.json +1 -1
- package/src/adapters/evm-wallet.mjs +16 -0
- package/src/adapters/monero-wallet.mjs +1 -1
- package/src/adapters/monero.mjs +1 -1
- package/src/adapters/node-test-evm-wallet.mjs +20 -8
- package/src/config.mjs +1 -1
- package/src/engine.mjs +4 -0
- package/src/legs/cow-spec.mjs +254 -0
- package/src/legs/cow.mjs +315 -0
- package/src/legs/evm.mjs +86 -2
- package/src/legs/kyber-spec.mjs +265 -0
- package/src/legs/kyber.mjs +425 -0
- package/src/legs/shape.mjs +35 -0
- package/src/legs/uniswaprelay-spec.mjs +541 -0
- package/src/legs/uniswaprelay.mjs +422 -0
- package/src/legs/uniswapx-spec.mjs +329 -0
- package/src/legs/uniswapx.mjs +362 -0
- package/src/quotes.mjs +1 -1
- package/src/swaps.mjs +1 -1
package/engine/core/swap.js
CHANGED
|
@@ -1,35 +1,34 @@
|
|
|
1
1
|
// GENERATED FILE - a byte-for-byte copy of the engine module www/js/core/swap.js for the SDK package.
|
|
2
|
-
// Edit the source under www/js
|
|
3
|
-
// tools/check-sdk-engine.mjs reddens on any drift, so a stale copy cannot ship silently.
|
|
2
|
+
// Edit the source under www/js and regenerate the mirror; a drift guard reddens on any difference.
|
|
4
3
|
// Everything below this header is identical to the source.
|
|
5
4
|
|
|
6
|
-
//
|
|
5
|
+
// The demo core: the deal state over time.
|
|
7
6
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// перезагрузку страницы, а «закрыть вкладку и вернуться» работает по-настоящему.
|
|
7
|
+
// PRINCIPLE: the state is NOT kept as a "current step". Timestamps and events are kept, and the phase is derived
|
|
8
|
+
// by derive() from the sim time. So the demo survives a page reload, and "close the tab and come back" truly works.
|
|
11
9
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// -
|
|
15
|
-
// -
|
|
16
|
-
// -
|
|
17
|
-
// -
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
//
|
|
21
|
-
//
|
|
10
|
+
//
|
|
11
|
+
// The model follows litepaper §4.2 and §5.3:
|
|
12
|
+
// - T0: the maker's XMR lock deadline. Did not lock - the user refunds the ETH (permissionless).
|
|
13
|
+
// - ready: the user confirms he saw the lock with his view key.
|
|
14
|
+
// - T1: the maker's claim deadline. Did not claim - the user refunds the ETH.
|
|
15
|
+
// - If the user did not mark ready - the maker takes the XMR back and the user refunds the ETH.
|
|
16
|
+
// - A watchtower can refund the user's money without his involvement.
|
|
17
|
+
|
|
18
|
+
// DEMO IS NEEDED HERE, AND IT IS NOT A TRIFLE: the default chain mode is taken from it below. The name was not
|
|
19
|
+
// imported, so ANY deal created without an explicit chainMode failed with "DEMO is not defined" - while the live
|
|
20
|
+
// deal page passes the mode explicitly, so the break stayed invisible until the first check that creates a deal without it.
|
|
22
21
|
import { TIMING, MONERO, SCENARIO_LABELS, DEFAULT_CHAIN, API, DEMO, chainById, SIGNING_GUARDS } from "./config.js";
|
|
23
|
-
//
|
|
22
|
+
// THE XMR SELL WINDOW - ONE rule for the node and the page. Here it is only read for the countdown.
|
|
24
23
|
import { sellWindowSec, SELL_WINDOW } from "./sellWindow.js";
|
|
25
24
|
import { simElapsed, simWall, nowReal, rescaleStart } from "./clock.js";
|
|
26
25
|
import { loadState, saveState } from "./store.js";
|
|
27
26
|
import { shortId, randomHex } from "./format.js";
|
|
28
27
|
import * as chain from "../mock/chain.js";
|
|
29
28
|
import * as node from "../monero/node.js";
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
29
|
+
// The Monero-leg source: in "app" mode confirmations and arrivals come from the backend,
|
|
30
|
+
// in "mock" mode - from the demo simulator. Exactly what goes outward (only the address and view key)
|
|
31
|
+
// is checked mechanically.
|
|
33
32
|
import { XMR_STATUS, explorerXmrTx, fetchSession, isLiveSource, openSession, pollInterval, xmrView } from "./chainSource.js";
|
|
34
33
|
import { watchOnlyShareFromWallet } from "../recovery/restore.js";
|
|
35
34
|
|
|
@@ -56,8 +55,8 @@ export function createSwap(input) {
|
|
|
56
55
|
side: input.side || "buy",
|
|
57
56
|
scenario: input.scenario || "happy",
|
|
58
57
|
speed: input.speed || 60,
|
|
59
|
-
//
|
|
60
|
-
//
|
|
58
|
+
// THERE IS NO QUOTE KIND IN THE RECORD. The former field mode ("floating"/"fixed") was a choice of price
|
|
59
|
+
// kind, and in version 4 there is no second kind: a firm quote is exactly one - the order quote.
|
|
61
60
|
network: input.network || DEFAULT_CHAIN,
|
|
62
61
|
moneroNetwork: MONERO.networkType,
|
|
63
62
|
realStart: now,
|
|
@@ -73,7 +72,7 @@ export function createSwap(input) {
|
|
|
73
72
|
gasUsd: input.gasUsd,
|
|
74
73
|
receiveAddress: input.receiveAddress,
|
|
75
74
|
quoteSignature: input.quoteSignature,
|
|
76
|
-
swapWallet: input.swapWallet || null, //
|
|
75
|
+
swapWallet: input.swapWallet || null, // the one-shot Monero wallet's keys
|
|
77
76
|
timeline: {
|
|
78
77
|
fundAtSim: 0,
|
|
79
78
|
lockAtSim: input.scenario === "maker_no_lock" ? null : TIMING.makerLock,
|
|
@@ -81,13 +80,13 @@ export function createSwap(input) {
|
|
|
81
80
|
confTarget: MONERO.confirmTarget,
|
|
82
81
|
t0Sim: TIMING.t0Refund,
|
|
83
82
|
},
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
chainMode: input.chainMode || DEMO.defaultChainMode, // mock | live (
|
|
87
|
-
//
|
|
83
|
+
// THE SAME DEFAULT AS IN THE SETTINGS: "live" - the real chain height, "mock" - a sandbox timer.
|
|
84
|
+
// A second default here would mean a deal without an explicit mode is timed.
|
|
85
|
+
chainMode: input.chainMode || DEMO.defaultChainMode, // mock | live (the real Monero mainnet height)
|
|
86
|
+
// The Monero-leg source: "app" - the real backend (see core/chainSource.js), "mock" - the simulator.
|
|
88
87
|
xmrSource: input.xmrSource || API.xmrSource,
|
|
89
|
-
xmrSession: null, //
|
|
90
|
-
xmrSessionError: null, //
|
|
88
|
+
xmrSession: null, // the last backend poll: status, amount, confirmations, txid
|
|
89
|
+
xmrSessionError: null, // the backend refusal cause: shown as a state, not a crash
|
|
91
90
|
events: [],
|
|
92
91
|
escrow: null,
|
|
93
92
|
xmr: null,
|
|
@@ -95,11 +94,11 @@ export function createSwap(input) {
|
|
|
95
94
|
log: [],
|
|
96
95
|
};
|
|
97
96
|
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
97
|
+
// DEMO FUNDING ONLY IN THE DEMO. An unconditional call to the mock branch (chain.fundEscrow) used to stand
|
|
98
|
+
// here and ran INCLUDING on the live path: the record got an INVENTED escrow address and an INVENTED
|
|
99
|
+
// transaction hash, and the live flow then fought them. From outside it looked like this: a real order was
|
|
100
|
+
// created and paid, while the progress screen, the explorer link, the recovery file and the payer looked at
|
|
101
|
+
// an address not on chain. On the live path only the live flow may give the address and hash.
|
|
103
102
|
if (swap.chainMode !== "live") {
|
|
104
103
|
const fund = chain.fundEscrow({ swapId: swap.id, ethAmountUsd: swap.payAmount, ethUsd: 3212.4 });
|
|
105
104
|
swap.escrow = {
|
|
@@ -111,9 +110,9 @@ export function createSwap(input) {
|
|
|
111
110
|
};
|
|
112
111
|
pushLog(swap, 0, "Escrow funded", `eth escrow ${fund.escrow}`);
|
|
113
112
|
} else {
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
113
|
+
// A PLACEHOLDER FOR A LIVE ORDER - WITH NOT ONE INVENTED VALUE. The address and hash will come from the live
|
|
114
|
+
// funding flow (confirm.js writes them from the flow's answer). The empty fields are here not for beauty:
|
|
115
|
+
// without the object the record had nowhere for the live path to write, and the address was lost silently.
|
|
117
116
|
swap.escrow = { address: null, fundTx: null, method: null, fundedAtSim: 0, gasUsd: input.gasUsd ?? null };
|
|
118
117
|
}
|
|
119
118
|
|
|
@@ -123,7 +122,7 @@ export function createSwap(input) {
|
|
|
123
122
|
return swap;
|
|
124
123
|
}
|
|
125
124
|
|
|
126
|
-
// REVERSE-FLOW DEAL RECORD
|
|
125
|
+
// REVERSE-FLOW DEAL RECORD . The sell screen used to stop at the request, and there was
|
|
127
126
|
// nowhere to show a refund: a reverse deal had no record. It is created here - from exactly what the screen
|
|
128
127
|
// already has (the node ticket plus the computed halves) - and lands in the same store as the buy flow, so the
|
|
129
128
|
// deal card, the recovery file and the XMR sweep all work off it.
|
|
@@ -197,21 +196,21 @@ export function createReverseSwap(input = {}) {
|
|
|
197
196
|
|
|
198
197
|
function pushLog(swap, atSim, title, detail) {
|
|
199
198
|
swap.log.push({ atSim, wall: simWall(swap, atSim), title, detail: detail || "" });
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
199
|
+
// We duplicate to the CONSOLE too, and it is the main channel for diagnosis. The journal used to be shown only
|
|
200
|
+
// on the page, where it lived in the screen state and vanished on a redraw - exactly when it was needed most.
|
|
201
|
+
// In the console the history is kept and can be copied whole.
|
|
203
202
|
console.log(`[swap ${swap.id}] ${title}${detail ? " - " + detail : ""}`);
|
|
204
203
|
}
|
|
205
204
|
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
205
|
+
// Drop a deal from the browser state. Locally only: the server keeps the record, and so do the wallet files the
|
|
206
|
+
// backend manages. They must not be mass-deleted by a pattern - those are real swaps' wallets - so server cleanup
|
|
207
|
+
// is a separate script on purpose.
|
|
208
|
+
// THE HALVES WALLET FROM THE RECORD - FOR THE RECOVERY FILE. The halves are saved in the deal record at funding
|
|
209
|
+
// time (escrow.half and neighbouring fields), and that is their only home: no full spend key exists for either
|
|
210
|
+
// side, and one's own half is random - a mnemonic cannot restore it. The export used to take swapWallet (the
|
|
211
|
+
// former scheme's wallet), and the file came out version 2 - from which one's XMR cannot be claimed. We assemble
|
|
212
|
+
// exactly the fields recoveryFile.js version 3 expects, in one place, so the export and the checks do not diverge.
|
|
213
|
+
//
|
|
215
214
|
export function halvesWalletOf(swap) {
|
|
216
215
|
const e = (swap && swap.escrow) || {};
|
|
217
216
|
if (!e.half || !e.viewHalf || !e.counterViewHalf || !e.moneroAddress) return null;
|
|
@@ -250,20 +249,20 @@ export function allSwaps() {
|
|
|
250
249
|
return Object.values(s.swaps).sort((a, b) => (a.realStart < b.realStart ? 1 : -1));
|
|
251
250
|
}
|
|
252
251
|
|
|
253
|
-
// ----
|
|
252
|
+
// ---- MERGING WITH THE SERVER LIST -----------------------------------------------------------------
|
|
253
|
+
//
|
|
254
|
+
// Why. While records live only in localStorage the list is tied to the browser: cleared site data or another
|
|
255
|
+
// device - no deals visible, although the money is in the escrow. The server list removes that.
|
|
254
256
|
//
|
|
255
|
-
// Зачем. Пока записи живут только в localStorage, список привязан к браузеру: очистили данные сайта или
|
|
256
|
-
// открыли с другого устройства - сделок не видно, хотя деньги в эскроу на месте. Серверный список это
|
|
257
|
-
// снимает.
|
|
258
257
|
//
|
|
259
|
-
//
|
|
260
|
-
// 1)
|
|
261
|
-
//
|
|
262
|
-
// 2)
|
|
263
|
-
//
|
|
264
|
-
// 3)
|
|
265
|
-
//
|
|
266
|
-
//
|
|
258
|
+
// MERGE RULES, deliberately cautious:
|
|
259
|
+
// 1) a local record ALWAYS wins: it knows more (secret, wallet, stages). A server row only ADDS what this
|
|
260
|
+
// browser lacks and never overwrites what was found;
|
|
261
|
+
// 2) a record that came from the server is marked source: "server" - so a person and the code see where it
|
|
262
|
+
// came from, and the screen can say so honestly;
|
|
263
|
+
// 3) from a server row we take only public fields. There is no secret there and there cannot be - so such a
|
|
264
|
+
// deal will be visible but claiming funds from it cannot be signed: the secret lies in the recovery file.
|
|
265
|
+
// That is not an omission but a consequence of the secret never leaving for the server.
|
|
267
266
|
export function serverRowToSwap(row) {
|
|
268
267
|
const escrow = String((row && row.escrow) || "").toLowerCase();
|
|
269
268
|
return {
|
|
@@ -285,15 +284,15 @@ export function serverRowToSwap(row) {
|
|
|
285
284
|
};
|
|
286
285
|
}
|
|
287
286
|
|
|
288
|
-
//
|
|
289
|
-
//
|
|
287
|
+
// Add server records the browser lacks. Returns the number added: the caller decides from it whether to redraw
|
|
288
|
+
// the list, rather than guessing.
|
|
290
289
|
export function addServerSwaps(rows) {
|
|
291
290
|
if (!Array.isArray(rows) || rows.length === 0) return 0;
|
|
292
291
|
const s = loadState();
|
|
293
292
|
let added = 0;
|
|
294
293
|
for (const row of rows) {
|
|
295
294
|
if (!row || !row.id || !row.escrow) continue;
|
|
296
|
-
if (s.swaps[row.id]) continue; //
|
|
295
|
+
if (s.swaps[row.id]) continue; // a local record wins - do not touch
|
|
297
296
|
s.swaps[row.id] = serverRowToSwap(row);
|
|
298
297
|
added += 1;
|
|
299
298
|
}
|
|
@@ -301,16 +300,16 @@ export function addServerSwaps(rows) {
|
|
|
301
300
|
return added;
|
|
302
301
|
}
|
|
303
302
|
|
|
304
|
-
//
|
|
303
|
+
// Find a deal by escrow address: needed to tie a server-list record to a real order.
|
|
305
304
|
|
|
306
305
|
export function activeSwaps() {
|
|
307
306
|
return allSwaps().filter((sw) => !sw.settlement);
|
|
308
307
|
}
|
|
309
308
|
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
309
|
+
// SAVE A DEAL AFTER OUTSIDE EDITS. persist() was internal, and calling modules (e.g. confirm.js) edited record
|
|
310
|
+
// fields - escrow address, salt, deadlines, secret - and did NOT save it: the edits lived in memory and vanished
|
|
311
|
+
// with the tab, while the progress screen read the OLD record from storage. That is exactly why the interface
|
|
312
|
+
// showed an address not on chain even when funding had succeeded.
|
|
314
313
|
export function saveSwap(swap) {
|
|
315
314
|
return persist(swap);
|
|
316
315
|
}
|
|
@@ -332,14 +331,14 @@ function findEvent(swap, type) {
|
|
|
332
331
|
}
|
|
333
332
|
|
|
334
333
|
// ---------------------------------------------------------------------------
|
|
335
|
-
//
|
|
334
|
+
// User actions
|
|
336
335
|
// ---------------------------------------------------------------------------
|
|
337
336
|
|
|
338
337
|
export function confirmReady(id) {
|
|
339
338
|
const swap = getSwap(id);
|
|
340
339
|
if (!swap || swap.settlement) return swap;
|
|
341
340
|
const view = derive(swap);
|
|
342
|
-
if (!view.can.ready) throw new Error("ready()
|
|
341
|
+
if (!view.can.ready) throw new Error("ready() is not available in this phase");
|
|
343
342
|
const atSim = simElapsed(swap);
|
|
344
343
|
event(swap, "ready", atSim);
|
|
345
344
|
pushLog(swap, atSim, "ready() called by you", "maker may now claim the ETH escrow");
|
|
@@ -350,14 +349,14 @@ export function confirmReady(id) {
|
|
|
350
349
|
// THE COUNTERPARTY'S SPEND HALF - THE ONE PLACE THAT SHOWS WHETHER THERE IS ANYTHING TO ASSEMBLE. The Monero
|
|
351
350
|
// spend key is the sum of two halves, and the user holds their own; sweeping XMR back after a refund is possible
|
|
352
351
|
// exactly when the second half is ALREADY revealed. In the record it is stored as counterHalf (the engine sets it
|
|
353
|
-
// while the counterparty is a stand-in) or as revealedHalf (read from the chain after claim/refund
|
|
352
|
+
// while the counterparty is a stand-in) or as revealedHalf (read from the chain after claim/refund).
|
|
354
353
|
export function refundHalfRevealed(swap) {
|
|
355
354
|
const e = (swap && swap.escrow) || {};
|
|
356
355
|
return e.revealedHalf || e.counterHalf || null;
|
|
357
356
|
}
|
|
358
357
|
|
|
359
358
|
// XMR SWEEP. In the ordinary phase this happens after the other side reveals its half through settlement. A
|
|
360
|
-
// REVERSE-FLOW REFUND
|
|
359
|
+
// REVERSE-FLOW REFUND is the SECOND case: the order was refunded, the user's coins
|
|
361
360
|
// stayed on the swap's one-time address, and the contract revealed the DEPOSITOR's half on refund. Then the same
|
|
362
361
|
// sweep takes our coins back, and the settlement is marked recovered: the money returned to the user rather
|
|
363
362
|
// than leaving through the swap.
|
|
@@ -368,7 +367,7 @@ export function sweepNow(id) {
|
|
|
368
367
|
// back.
|
|
369
368
|
if (swap.settlement && !(swap.settlement.kind === "refund_eth" && swap.side === "reverse")) return swap;
|
|
370
369
|
const view = derive(swap);
|
|
371
|
-
if (!view.can.sweep) throw new Error("sweep
|
|
370
|
+
if (!view.can.sweep) throw new Error("sweep is not available in this phase");
|
|
372
371
|
const atSim = simElapsed(swap);
|
|
373
372
|
const tx = chain.sweepXmr({ swapId: swap.id });
|
|
374
373
|
swap.xmr = swap.xmr || {};
|
|
@@ -384,7 +383,7 @@ export function refundEth(id, by = "you") {
|
|
|
384
383
|
const swap = getSwap(id);
|
|
385
384
|
if (!swap || swap.settlement) return swap;
|
|
386
385
|
const view = derive(swap);
|
|
387
|
-
if (!view.can.refund) throw new Error("refund
|
|
386
|
+
if (!view.can.refund) throw new Error("refund is not available in this phase");
|
|
388
387
|
const atSim = simElapsed(swap);
|
|
389
388
|
const tx = chain.refundEscrow({ swapId: swap.id, by });
|
|
390
389
|
swap.settlement = { kind: "refund_eth", phase: TERMINAL.refunded_eth.phase, atSim, txHash: tx.txHash, by };
|
|
@@ -393,26 +392,26 @@ export function refundEth(id, by = "you") {
|
|
|
393
392
|
}
|
|
394
393
|
|
|
395
394
|
// ---------------------------------------------------------------------------
|
|
396
|
-
//
|
|
395
|
+
// State output
|
|
397
396
|
// ---------------------------------------------------------------------------
|
|
398
397
|
|
|
399
|
-
//
|
|
400
|
-
//
|
|
398
|
+
// Whether the deal's network is live: escrow.mode = live in the network config. The network setting is a fact,
|
|
399
|
+
// the same for old and new deals, unlike the chainMode field inside a record.
|
|
401
400
|
function isLiveNetwork(swap) {
|
|
402
401
|
const conf = (chainById((swap && swap.network) || DEFAULT_CHAIN) || {}).escrow || {};
|
|
403
402
|
return conf.mode === "live";
|
|
404
403
|
}
|
|
405
404
|
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
405
|
+
// THE AMOUNT AT THE ADDRESS AGAINST THE QUOTE'S PROMISE. The ready mark lets the claimer settle by his half -
|
|
406
|
+
// i.e. gives him the ETH - so it is made not on "something arrived" but on a SUFFICIENT amount. The 1e-6 XMR
|
|
407
|
+
// tolerance covers display rounding, not a discount. An unknown amount is also a refusal: not knowing is not permission.
|
|
409
408
|
const XMR_AMOUNT_EPS = 1e-6;
|
|
410
409
|
export function xmrAmountCheck(swap, xmr) {
|
|
411
410
|
const expected = Number(swap && swap.xmrAmount);
|
|
412
411
|
const hasExpected = Number.isFinite(expected) && expected > 0;
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
//
|
|
412
|
+
// "THE SEEN AMOUNT" IS EVERYTHING AT THE ADDRESS: both unlocked and locked by unlock_time. Otherwise a locked
|
|
413
|
+
// arrival would show its amount as "not seen", although the money is at the address. What of it can be spent
|
|
414
|
+
// is a separate question, decided by the status (ready does not enable while the money is locked).
|
|
416
415
|
const seenSource = xmr && xmr.arrived !== null && xmr.arrived !== undefined ? xmr.arrived : (xmr ? xmr.received : null);
|
|
417
416
|
const seen = seenSource !== null && seenSource !== undefined && Number.isFinite(Number(seenSource))
|
|
418
417
|
? Number(seenSource) : null;
|
|
@@ -424,11 +423,11 @@ export function xmrAmountCheck(swap, xmr) {
|
|
|
424
423
|
return { ok: false, checked: true, expected, seen: null,
|
|
425
424
|
why: "the amount of XMR on the address is not known (" + expected + " XMR was quoted): ready stays closed until the sum is seen" };
|
|
426
425
|
}
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
426
|
+
// THE COMPARISON IS TWO-SIDED, AND THIS IS A FIX. It used to be one-sided ("arrived no less") and therefore
|
|
427
|
+
// BLIND to a node overpayment: the node counted the debt by a raw reference without spread and gave 5,000 XMR
|
|
428
|
+
// where it promised 4,950 - the arrival was MORE than promised and the check stayed silent. The number we
|
|
429
|
+
// compare against is the one the node writes into the journal as the debt (amount_xmr): it comes from the same
|
|
430
|
+
// price feed as the one shown to the person. A divergence in EITHER direction means the recorded and shown numbers differ.
|
|
432
431
|
if (seen + XMR_AMOUNT_EPS < expected) {
|
|
433
432
|
return { ok: false, checked: true, expected, seen,
|
|
434
433
|
why: "only " + seen + " XMR arrived, the quote promised " + expected + " XMR: ready is blocked - the escrow would pay out for less than agreed" };
|
|
@@ -446,35 +445,35 @@ export function derive(swap, now = nowReal()) {
|
|
|
446
445
|
const ev = (type) => findEvent(swap, type);
|
|
447
446
|
const readyEv = ev("ready");
|
|
448
447
|
|
|
449
|
-
//
|
|
450
|
-
//
|
|
448
|
+
// The Monero-leg snapshot: in mock mode computed from sim time, in live read from the last backend poll.
|
|
449
|
+
// Beyond this the engine works the same and does not know where the data came from.
|
|
451
450
|
const live = isLiveSource(swap.xmrSource);
|
|
452
451
|
const xmr = xmrView(swap, sim);
|
|
453
|
-
//
|
|
454
|
-
//
|
|
452
|
+
// ARRIVAL IS EVERYTHING VISIBLE AT THE ADDRESS (arrived), not just the unlocked: money locked by unlock_time
|
|
453
|
+
// has arrived but cannot be spent - a separate state, named below, that does not let "done" through.
|
|
455
454
|
const arrived = Number(xmr.arrived !== undefined && xmr.arrived !== null ? xmr.arrived : (xmr.received || 0));
|
|
456
|
-
const lockAt = live ? (arrived > 0 ? 0 : null) : t.lockAtSim; // null =>
|
|
455
|
+
const lockAt = live ? (arrived > 0 ? 0 : null) : t.lockAtSim; // null => the maker does not lock (the maker_no_lock scenario)
|
|
457
456
|
const conf = xmr.confirmations;
|
|
458
457
|
const lockDone = live ? arrived > 0 : lockAt !== null && sim >= lockAt;
|
|
459
458
|
|
|
460
459
|
const readyAt = readyEv ? readyEv.atSim : null;
|
|
461
|
-
//
|
|
462
|
-
//
|
|
460
|
+
// DEADLINES FROM CHAIN - IN REAL TIME: readyBy and t1 are set by the contract and have nothing to do with the
|
|
461
|
+
// accelerated demo time or the simulator.
|
|
463
462
|
const chainReadyByMs = swap.escrow && swap.escrow.readyBy ? Number(swap.escrow.readyBy) * 1000 : null;
|
|
464
463
|
const chainT1Ms = swap.escrow && swap.escrow.t1 ? Number(swap.escrow.t1) * 1000 : null;
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
464
|
+
// THE XMR SELL WINDOW. The countdown runs FROM the contract readyBy (set by neither us nor the simulator),
|
|
465
|
+
// not from TIMING.readyWindow: that lives in the accelerated demo time and on a live deal would show demo
|
|
466
|
+
// time. The soft promise (600 s) is extended to the contract one (1200 s) while the pool lacks the FULL amount
|
|
467
|
+
// - by the same rule as the node (sellWindowSec). This does NOT change the right to act: the contract takes
|
|
468
|
+
// the mark up to readyBy, and the window here is an on-screen promise.
|
|
470
469
|
const sellSeen = Number(xmr.arrived !== undefined && xmr.arrived !== null ? xmr.arrived : (xmr.received || 0)) > 0;
|
|
471
470
|
const sellWindow = (() => {
|
|
472
471
|
if (swap.side !== "reverse" || chainReadyByMs === null) return null;
|
|
473
|
-
const startMs = chainReadyByMs - SELL_WINDOW.hardSec * 1000; //
|
|
472
|
+
const startMs = chainReadyByMs - SELL_WINDOW.hardSec * 1000; // window start = readyBy minus the contract maximum
|
|
474
473
|
const softAtMs = startMs + SELL_WINDOW.softSec * 1000;
|
|
475
474
|
const windowSec = sellWindowSec({ poolSeen: sellSeen, soft: SELL_WINDOW.softSec, hard: SELL_WINDOW.hardSec });
|
|
476
|
-
const extended = !sellSeen && nowReal() >= softAtMs; //
|
|
477
|
-
const atMs = extended ? startMs + windowSec * 1000 : softAtMs; //
|
|
475
|
+
const extended = !sellSeen && nowReal() >= softAtMs; // the promise extended to the contract window
|
|
476
|
+
const atMs = extended ? startMs + windowSec * 1000 : softAtMs; // before the extension the count goes to the soft promise
|
|
478
477
|
return { startMs, atMs, leftMs: atMs - nowReal(), seen: sellSeen, extended,
|
|
479
478
|
softSec: SELL_WINDOW.softSec, hardSec: SELL_WINDOW.hardSec, windowSec };
|
|
480
479
|
})();
|
|
@@ -485,10 +484,10 @@ export function derive(swap, now = nowReal()) {
|
|
|
485
484
|
const makerReclaimAt = readyDeadline !== null ? readyDeadline + TIMING.makerReclaim : null;
|
|
486
485
|
const t1At = readyAt !== null ? readyAt + TIMING.t1Refund : null;
|
|
487
486
|
|
|
488
|
-
//
|
|
487
|
+
// The "user did not confirm" scenario: the maker takes the XMR back.
|
|
489
488
|
const xmrReclaimed = readyAt === null && makerReclaimAt !== null && sim >= makerReclaimAt;
|
|
490
489
|
|
|
491
|
-
//
|
|
490
|
+
// Available actions/deadlines
|
|
492
491
|
const can = { ready: false, sweep: false, refund: false };
|
|
493
492
|
let phase = "xmr_locked";
|
|
494
493
|
let phaseLabel = "Waiting";
|
|
@@ -498,7 +497,7 @@ export function derive(swap, now = nowReal()) {
|
|
|
498
497
|
if (swap.settlement) {
|
|
499
498
|
phase = swap.settlement.phase;
|
|
500
499
|
phaseLabel = Object.values(TERMINAL).find((x) => x.phase === swap.settlement.phase)?.label || "Settled";
|
|
501
|
-
// SWEEPING OUR OWN XMR AFTER A REFUND
|
|
500
|
+
// SWEEPING OUR OWN XMR AFTER A REFUND . On the reverse flow a refund leaves the
|
|
502
501
|
// user's coins on the swap's one-time address: the claimer's ETH came back, and nobody moved the XMR. They can
|
|
503
502
|
// only be taken by assembling the spend key from OUR half and the OTHER half that the chain revealed on
|
|
504
503
|
// settlement. While the revealed half is missing we do NOT grant the right - can.sweep stays false precisely
|
|
@@ -517,11 +516,11 @@ export function derive(swap, now = nowReal()) {
|
|
|
517
516
|
}
|
|
518
517
|
} else if (live && readyAt === null && chainReadyByMs !== null && nowReal() >= chainReadyByMs
|
|
519
518
|
&& (chainT1Ms === null || nowReal() < chainT1Ms)) {
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
//
|
|
519
|
+
// THERE WAS A DEAD ZONE - NOW AN OPEN DOOR. The depositor will not mark anymore, and the contract forbids a
|
|
520
|
+
// claim without it, so the claim window NEVER OPENED - and a refund is allowed right after readyBy (the
|
|
521
|
+
// contract condition: block.timestamp < readyBy || (block.timestamp < t1 && ready)). It also publishes the
|
|
522
|
+
// depositor's half, so the maker takes his XMR back without waiting for t1. That is the answer to the dead
|
|
523
|
+
// zone: before, the money and the half waited for t1, and the Monero leg was unavailable to both sides.
|
|
525
524
|
phase = "dead_zone";
|
|
526
525
|
phaseLabel = "Ready deadline passed - the refund is open";
|
|
527
526
|
can.ready = false;
|
|
@@ -532,13 +531,13 @@ export function derive(swap, now = nowReal()) {
|
|
|
532
531
|
"the XMR from Monero right away. " +
|
|
533
532
|
(chainT1Ms !== null ? "T1 closes the order for good: " + new Date(chainT1Ms).toISOString() + "." : "");
|
|
534
533
|
} else if (live && readyAt === null) {
|
|
535
|
-
// Live
|
|
536
|
-
//
|
|
537
|
-
//
|
|
534
|
+
// Live mode: Monero-leg states come from the backend. The simulated EVM-leg timeouts (T0/T1, watchtower)
|
|
535
|
+
// are deliberately NOT shown here: the contract does not exist yet, and a "refund ETH" button would be a
|
|
536
|
+
// lie. The "EVM leg - simulation" mark hangs in the UI separately (derive().simulated).
|
|
538
537
|
if (xmr.status === XMR_STATUS.LOCKED) {
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
//
|
|
538
|
+
// THE MONEY IS AT THE ADDRESS BUT LOCKED BY unlock_time. This is a NAMED state, not "waiting for arrival":
|
|
539
|
+
// otherwise the screen would be silent and the amount would look zero. A readiness mark is impossible here
|
|
540
|
+
// - claiming ETH against locked XMR would give the maker ETH for money that cannot be paid with.
|
|
542
541
|
phase = "xmr_unlock_pending";
|
|
543
542
|
phaseLabel = "XMR is on your address but locked on-chain";
|
|
544
543
|
can.ready = false;
|
|
@@ -560,8 +559,8 @@ export function derive(swap, now = nowReal()) {
|
|
|
560
559
|
} else if (xmr.status === XMR_STATUS.READY) {
|
|
561
560
|
phase = "xmr_locked";
|
|
562
561
|
phaseLabel = "XMR received and confirmed (" + conf + "/" + t.confTarget + ")";
|
|
563
|
-
//
|
|
564
|
-
//
|
|
562
|
+
// THE AMOUNT IS CHECKED TOGETHER WITH READINESS. The backend sees the XMR by the view key and names the
|
|
563
|
+
// amount, while the quote promised its own number: a divergence means the mark would give ETH cheaper than the agreement.
|
|
565
564
|
const paid = xmrAmountCheck(swap, xmr);
|
|
566
565
|
can.ready = paid.ok;
|
|
567
566
|
can.readyAmount = paid;
|
|
@@ -577,7 +576,7 @@ export function derive(swap, now = nowReal()) {
|
|
|
577
576
|
note = "About " + Math.max(1, Math.round(((t.confTarget - conf) * t.blockSim) / 60000)) + " min left of confirmations.";
|
|
578
577
|
}
|
|
579
578
|
} else if (lockAt === null) {
|
|
580
|
-
//
|
|
579
|
+
// The maker did not lock the XMR. After T0 the user can refund the ETH himself.
|
|
581
580
|
if (sim >= t.t0Sim) {
|
|
582
581
|
phase = "refund_available";
|
|
583
582
|
phaseLabel = "Maker did not lock XMR";
|
|
@@ -619,43 +618,43 @@ export function derive(swap, now = nowReal()) {
|
|
|
619
618
|
phaseLabel = "Secret revealed - sweeping XMR";
|
|
620
619
|
can.sweep = true;
|
|
621
620
|
} else {
|
|
622
|
-
// maker_stall: ready
|
|
621
|
+
// maker_stall: ready is done, but no claim happens
|
|
623
622
|
phase = "refund_available";
|
|
624
623
|
phaseLabel = "Maker did not claim in time";
|
|
625
624
|
refundAfterSim = t1At;
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
const escrowT0 = swap.escrow && swap.escrow.t0 ? swap.escrow.t0 * 1000 : null; //
|
|
625
|
+
// THE RIGHT TO REFUND IS DETERMINED BY THE CONTRACT, AND THE RULE DEPENDS ON THE ORDER'S AGE - and rightly
|
|
626
|
+
// so, this is not branching for branching's sake. New orders are created with ONE boundary t1: before it
|
|
627
|
+
// nobody may refund, from it anyone may. Orders created earlier carry t0 in their record and keep the old
|
|
628
|
+
// rule: from t0 only the locker may refund, from t1 anyone. Roles and deadlines in old orders are immutable,
|
|
629
|
+
// so there is no migration - they live out their term under the old rules. Compare against real time and by
|
|
630
|
+
// the record's deadlines, not by simulator time (which is accelerated).
|
|
631
|
+
const escrowT0 = swap.escrow && swap.escrow.t0 ? swap.escrow.t0 * 1000 : null; // only in old orders
|
|
633
632
|
const chainT1 = swap.escrow && swap.escrow.t1 ? swap.escrow.t1 * 1000 : null;
|
|
634
633
|
const realNow = nowReal();
|
|
635
634
|
if (chainT1 !== null) {
|
|
636
635
|
if (escrowT0 !== null) {
|
|
637
|
-
can.refund = realNow >= escrowT0; //
|
|
638
|
-
can.refundByAnyone = realNow >= chainT1; //
|
|
636
|
+
can.refund = realNow >= escrowT0; // an old order: from t0, and only the locker
|
|
637
|
+
can.refundByAnyone = realNow >= chainT1; // from t1 - anyone
|
|
639
638
|
} else {
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
//
|
|
643
|
-
//
|
|
644
|
-
//
|
|
639
|
+
// A NEW ORDER: TWO BOUNDARIES. From t1 - as before, a refund is open to all. But if the ready mark is
|
|
640
|
+
// absent, the claim window never opened, and a refund is open right after readyBy: otherwise the dead
|
|
641
|
+
// zone would hold the money and the half until t1. The mark is taken from the RECORD (readyAt) - the
|
|
642
|
+
// user sets it himself, and in this branch it is empty. If the record is lost while the chain has the
|
|
643
|
+
// mark, the contract refuses: an honest refusal instead of a promise we would not keep.
|
|
645
644
|
const chainReadyBy = swap.escrow && swap.escrow.readyBy ? Number(swap.escrow.readyBy) * 1000 : null;
|
|
646
645
|
const earlyRefund = chainReadyBy !== null && realNow >= chainReadyBy && readyAt === null;
|
|
647
646
|
can.refund = realNow >= chainT1 || earlyRefund;
|
|
648
|
-
can.refundByAnyone = can.refund; //
|
|
649
|
-
if (earlyRefund && realNow < chainT1) refundAfterSim = null; //
|
|
647
|
+
can.refundByAnyone = can.refund; // and from t1 a refund is open to all at once
|
|
648
|
+
if (earlyRefund && realNow < chainT1) refundAfterSim = null; // the refund is already open: no countdown
|
|
650
649
|
}
|
|
651
650
|
} else if (!isLiveNetwork(swap)) {
|
|
652
|
-
can.refund = sim >= t1At; //
|
|
651
|
+
can.refund = sim >= t1At; // a demo deal: as before, from the simulator
|
|
653
652
|
} else {
|
|
654
|
-
can.refund = false; //
|
|
653
|
+
can.refund = false; // a live deal with unread deadlines: we do not promise
|
|
655
654
|
}
|
|
656
|
-
//
|
|
657
|
-
//
|
|
658
|
-
//
|
|
655
|
+
// WHAT CAN BE DONE NOW - WITHOUT PROMISES WE WILL NOT KEEP. The watchtower is a manual call right now, not a
|
|
656
|
+
// service: it does NOT refund by itself. So we say not "will be refunded automatically" but that the right
|
|
657
|
+
// has appeared for anyone, the watchtower included, and the button still works.
|
|
659
658
|
note = can.refund
|
|
660
659
|
? (can.refundByAnyone
|
|
661
660
|
? "The claim deadline passed: the refund is open to anyone who can present the half of the depositor - " +
|
|
@@ -667,7 +666,7 @@ export function derive(swap, now = nowReal()) {
|
|
|
667
666
|
: "Waiting for T1: before it the ETH can only be claimed with the secret. From T1 the claim closes and the refund opens - and it always pays you, never the caller.");
|
|
668
667
|
}
|
|
669
668
|
|
|
670
|
-
//
|
|
669
|
+
// Steps for the stepper
|
|
671
670
|
const confDone = conf >= t.confTarget;
|
|
672
671
|
const claimed = claimAt !== null && sim >= claimAt;
|
|
673
672
|
const steps = [
|
|
@@ -689,18 +688,18 @@ export function derive(swap, now = nowReal()) {
|
|
|
689
688
|
conf,
|
|
690
689
|
confTarget: t.confTarget,
|
|
691
690
|
confProgress: t.confTarget ? conf / t.confTarget : 0,
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
//
|
|
695
|
-
//
|
|
696
|
-
//
|
|
697
|
-
//
|
|
698
|
-
//
|
|
699
|
-
//
|
|
691
|
+
// THE DEADLINES FOR DISPLAY ARE TAKEN FROM THE SAME SOURCE AS THE RIGHT TO REFUND - FROM THE DEAL RECORD.
|
|
692
|
+
// Here stood simWall(swap, t.t0Sim): the DEMO SIMULATOR time (accelerated 60x), unrelated to a live deal.
|
|
693
|
+
// That produced a contradiction on one screen: can.refund was computed by the REAL deadline from escrow
|
|
694
|
+
// (saying "not yet"), while the button timer and the "T0 (refund if no XMR lock)" row showed the simulated
|
|
695
|
+
// one (saying "available now" and "in 00:00").
|
|
696
|
+
// Two indicators of the same event must be computed from one quantity: otherwise the screen contradicts
|
|
697
|
+
// itself and the person does not know which deadline to believe.
|
|
698
|
+
// For a LIVE deal the deadlines are contractual (the same as can.refund above), for a demo one - from the simulator.
|
|
700
699
|
deadlines: {
|
|
701
|
-
// t0
|
|
702
|
-
//
|
|
703
|
-
//
|
|
700
|
+
// t0 EXISTS ONLY IN OLD ORDERS. New ones have one boundary, and for a live deal without t0 it is more
|
|
701
|
+
// honest to show nothing (null) than an invented number: the T0 row is not drawn on screen by this mark.
|
|
702
|
+
// In demo mode the deadline comes from the simulator - it has its own time, deliberately accelerated.
|
|
704
703
|
t0: swap.escrow && swap.escrow.t0 ? swap.escrow.t0 * 1000 : (swap.escrow ? null : simWall(swap, t.t0Sim)),
|
|
705
704
|
t0Sim: t.t0Sim,
|
|
706
705
|
lockAtSim: lockAt,
|
|
@@ -712,13 +711,13 @@ export function derive(swap, now = nowReal()) {
|
|
|
712
711
|
refundAfterWall: refundAfterSim !== null ? simWall(swap, refundAfterSim) : null,
|
|
713
712
|
},
|
|
714
713
|
refundLeftSim: refundAfterSim !== null ? refundAfterSim - sim : null,
|
|
715
|
-
//
|
|
714
|
+
// Monero-leg data for the screen: status, address, txid and an explorer link.
|
|
716
715
|
xmr: {
|
|
717
716
|
live: xmr.live,
|
|
718
717
|
source: swap.xmrSource,
|
|
719
718
|
status: xmr.status,
|
|
720
719
|
received: xmr.received,
|
|
721
|
-
//
|
|
720
|
+
// Locks by unlock_time and everything visible at the address: the screen shows the cause and the boundary.
|
|
722
721
|
locked: xmr.locked || 0,
|
|
723
722
|
lockedUntil: xmr.lockedUntil || null,
|
|
724
723
|
arrived: Number(xmr.arrived !== undefined && xmr.arrived !== null ? xmr.arrived : (xmr.received || 0)),
|
|
@@ -730,15 +729,15 @@ export function derive(swap, now = nowReal()) {
|
|
|
730
729
|
error: xmr.error,
|
|
731
730
|
sweepTxid: xmr.sweepTxid,
|
|
732
731
|
},
|
|
733
|
-
//
|
|
734
|
-
// EVM
|
|
735
|
-
//
|
|
736
|
-
//
|
|
737
|
-
//
|
|
732
|
+
// What is real now and what is a simulation. The UI must show this, not guess.
|
|
733
|
+
// The EVM leg is simulated if and only if the deal's network is not switched to live. It used to stand as a
|
|
734
|
+
// hard true, and on a LIVE deal with a real escrow the screen wrote "EVM leg ... is still simulated" - i.e.
|
|
735
|
+
// lied, although the order existed in the contract. The mark comes from the network settings, not from a
|
|
736
|
+
// field in the deal: the field is set at creation, and deals created earlier still have "mock" there.
|
|
738
737
|
simulated: { evm: !isLiveNetwork(swap), xmr: !xmr.live },
|
|
739
738
|
can,
|
|
740
|
-
//
|
|
741
|
-
// SIGNING_GUARDS.minReadyLeadSec
|
|
739
|
+
// HOW LONG TO THE MARK BOUNDARY and whether it has passed: the boundary exists, the mark does not. A warning
|
|
740
|
+
// SIGNING_GUARDS.minReadyLeadSec seconds before it - because after the boundary NOT ONE button works.
|
|
742
741
|
readyDeadline: {
|
|
743
742
|
atMs: chainReadyByMs,
|
|
744
743
|
soonMs: chainReadyByMs !== null && readyAt === null ? chainReadyByMs - nowReal() : null,
|
|
@@ -746,11 +745,11 @@ export function derive(swap, now = nowReal()) {
|
|
|
746
745
|
&& chainReadyByMs - nowReal() > 0
|
|
747
746
|
&& chainReadyByMs - nowReal() <= Number(SIGNING_GUARDS.readyLeadWarnSec || 0) * 1000,
|
|
748
747
|
},
|
|
749
|
-
//
|
|
748
|
+
// BOTH AMOUNTS OUTWARD: the one promised by the quote and the one seen at the address. The screen must show both, not one.
|
|
750
749
|
xmrAmount: { expected: (() => { const e = Number(swap.xmrAmount); return Number.isFinite(e) && e > 0 ? e : null; })(),
|
|
751
750
|
seen: xmr.received !== null && xmr.received !== undefined ? Number(xmr.received) : null },
|
|
752
751
|
steps,
|
|
753
|
-
//
|
|
752
|
+
// THE SELL WINDOW: the address is already in xmr.address, here is the countdown and its bounds. null on the direct flow.
|
|
754
753
|
sellWindow,
|
|
755
754
|
terminal: swap.settlement,
|
|
756
755
|
xmrReclaimed,
|
|
@@ -762,8 +761,8 @@ function swappedAtOrNull(swap) {
|
|
|
762
761
|
}
|
|
763
762
|
|
|
764
763
|
// ---------------------------------------------------------------------------
|
|
765
|
-
//
|
|
766
|
-
// (
|
|
764
|
+
// The ticker: finishes deals that must finish without the user
|
|
765
|
+
// (auto-sweep and watchtower) and sends changes to subscribers.
|
|
767
766
|
// ---------------------------------------------------------------------------
|
|
768
767
|
|
|
769
768
|
let timer = null;
|
|
@@ -781,11 +780,11 @@ function emit() {
|
|
|
781
780
|
}
|
|
782
781
|
}
|
|
783
782
|
|
|
784
|
-
// --- live
|
|
783
|
+
// --- live mode: the swap session on the backend -----------------------------------------------
|
|
785
784
|
//
|
|
786
|
-
//
|
|
787
|
-
//
|
|
788
|
-
//
|
|
785
|
+
// The poll runs without waiting: the screen ticker must not wait for the network, else with an unavailable backend
|
|
786
|
+
// the demo starts to lag. The result is put into the swap itself (xmrSession / xmrSessionError), and derive()
|
|
787
|
+
// reads it as ordinary state - it stays a pure function.
|
|
789
788
|
const liveInFlight = new Set();
|
|
790
789
|
|
|
791
790
|
function applyLive(id, patch) {
|
|
@@ -795,21 +794,21 @@ function applyLive(id, patch) {
|
|
|
795
794
|
saveState(s);
|
|
796
795
|
}
|
|
797
796
|
|
|
798
|
-
//
|
|
799
|
-
//
|
|
797
|
+
// The time of the last ATTEMPT to reach the backend per swap. Attempts, not successes: rate limiting must work
|
|
798
|
+
// even when the request fails.
|
|
800
799
|
const liveLastTry = new Map();
|
|
801
800
|
|
|
802
801
|
|
|
803
|
-
//
|
|
804
|
-
//
|
|
805
|
-
//
|
|
806
|
-
//
|
|
807
|
-
//
|
|
808
|
-
//
|
|
809
|
-
//
|
|
810
|
-
//
|
|
811
|
-
//
|
|
812
|
-
//
|
|
802
|
+
// THE DEAL KEY: THE PAGE AND THE APP NAME IT DIFFERENTLY. The page has its own id (lives in the browser), the
|
|
803
|
+
// app its own (localtest-...): the id is set by the app when it creates the watch record. The page asked for the
|
|
804
|
+
// XMR state by ITS OWN id, which the app does not know and answers with a refusal. From outside it looks like
|
|
805
|
+
// this: the XMR arrived, a dozen confirmations, while the deal screen shows the previous state and the readiness
|
|
806
|
+
// button does not open - and the deal window closes in vain (verified by a live run).
|
|
807
|
+
// WE TIE BY THE MONERO ADDRESS: the page already reports it to the app when creating the watch, so by it the page
|
|
808
|
+
// finds its record and then asks the state by its id. No change in the app is needed.
|
|
809
|
+
// ALL RECORDS OF ONE DEAL IN THE BROWSER. There are two (swap and escrow), and one must follow the deal, not
|
|
810
|
+
// whichever record the screen happened to land on: the escrow record has no expected XMR amount, so its session
|
|
811
|
+
// is not created and the state never reaches the screen. The link is the shared Monero address, one per deal.
|
|
813
812
|
export function dealSwapIds(swap) {
|
|
814
813
|
const ids = new Set([String((swap && swap.id) || "")].filter(Boolean));
|
|
815
814
|
const w0 = (() => { try { return halvesWalletOf(swap) || {}; } catch { return {}; } })();
|
|
@@ -825,37 +824,37 @@ export function dealSwapIds(swap) {
|
|
|
825
824
|
|
|
826
825
|
const serverIdByAddress = new Map();
|
|
827
826
|
|
|
828
|
-
//
|
|
829
|
-
//
|
|
830
|
-
//
|
|
831
|
-
//
|
|
827
|
+
// WE LOOK FOR THE DEAL'S FACTS ACROSS ALL ITS RECORDS IN THE BROWSER. The page keeps TWO records per deal: one
|
|
828
|
+
// created by the swap (Monero address, expected XMR amount), another by the escrow (halves, escrow address). A
|
|
829
|
+
// screen opened on the escrow record did not know the expected amount, the session was not created at all and the
|
|
830
|
+
// readiness button did not turn on - although the app was already reporting readiness. The records are tied by the SHARED Monero address: one per deal.
|
|
832
831
|
function dealFactsFor(swap) {
|
|
833
832
|
const w0 = (() => { try { return halvesWalletOf(swap) || {}; } catch { return {}; } })();
|
|
834
833
|
let address = w0.address || (swap && swap.escrow && swap.escrow.moneroAddress) || (swap && swap.xmrSession && swap.xmrSession.address) || null;
|
|
835
834
|
let amountXmr = Number(swap && swap.xmrAmount) > 0 ? Number(swap.xmrAmount) : null;
|
|
836
|
-
let source = "
|
|
835
|
+
let source = "own record";
|
|
837
836
|
if (address && amountXmr === null) {
|
|
838
837
|
for (const other of Object.values((loadState() || {}).swaps || {})) {
|
|
839
838
|
if (!other || other === swap) continue;
|
|
840
839
|
const w = (() => { try { return halvesWalletOf(other) || {}; } catch { return {}; } })();
|
|
841
840
|
if (!w.address || w.address !== address) continue;
|
|
842
|
-
if (Number(other.xmrAmount) > 0) { amountXmr = Number(other.xmrAmount); source = "
|
|
841
|
+
if (Number(other.xmrAmount) > 0) { amountXmr = Number(other.xmrAmount); source = "another record of the same deal (" + String(other.id || "?") + ")"; break; }
|
|
843
842
|
}
|
|
844
843
|
}
|
|
845
844
|
if (!address) {
|
|
846
845
|
for (const other of Object.values((loadState() || {}).swaps || {})) {
|
|
847
846
|
const w = (() => { try { return halvesWalletOf(other) || {}; } catch { return {}; } })();
|
|
848
|
-
if (w.address && Number(other.xmrAmount) > 0) { address = w.address; amountXmr = amountXmr || Number(other.xmrAmount); source = "
|
|
847
|
+
if (w.address && Number(other.xmrAmount) > 0) { address = w.address; amountXmr = amountXmr || Number(other.xmrAmount); source = "another record of the same deal"; break; }
|
|
849
848
|
}
|
|
850
849
|
}
|
|
851
850
|
return { address, amountXmr, source };
|
|
852
851
|
}
|
|
853
852
|
|
|
854
|
-
//
|
|
855
|
-
//
|
|
856
|
-
//
|
|
857
|
-
// escrow.moneroAddress.
|
|
858
|
-
//
|
|
853
|
+
// WE TAKE THE DEAL ADDRESS FROM EVERYWHERE IT MAY LIE. A browser deal and the app's watch record are DIFFERENT
|
|
854
|
+
// records about one deal (the page creates its own for the escrow, the app one for watching the address), so the
|
|
855
|
+
// page's record does not always have the address: on the escrow record it lies in the halves, not in the
|
|
856
|
+
// escrow.moneroAddress field. A live run therefore polled a dead id, got 404 and left the readiness button off.
|
|
857
|
+
// Extracted separately so the same address is used by the server height lookup too.
|
|
859
858
|
function dealMoneroAddress(swap) {
|
|
860
859
|
const fromHalves = (() => { try { return (halvesWalletOf(swap) || {}).address || null; } catch { return null; } })();
|
|
861
860
|
return (swap && swap.escrow && swap.escrow.moneroAddress)
|
|
@@ -865,17 +864,17 @@ function dealMoneroAddress(swap) {
|
|
|
865
864
|
|| null;
|
|
866
865
|
}
|
|
867
866
|
|
|
868
|
-
//
|
|
869
|
-
//
|
|
870
|
-
//
|
|
871
|
-
//
|
|
867
|
+
// THE SCAN HEIGHT THE APP KNOWS. Needed by OLD deals' files: their record has no birthHeight (the field appeared
|
|
868
|
+
// later), while the app resolved and stored the height when registering the watch. The source is ours, so it does
|
|
869
|
+
// not depend on someone else's Monero node. The server is silent - we return null and the caller keeps the
|
|
870
|
+
// previous behaviour: the height comes from other sources or the file honestly warns of its absence.
|
|
872
871
|
export async function serverRestoreHeightFor(swap, { attempts = 3, pauseMs = 1200 } = {}) {
|
|
873
872
|
const addr = dealMoneroAddress(swap);
|
|
874
873
|
if (!addr) return null;
|
|
875
|
-
//
|
|
876
|
-
//
|
|
877
|
-
//
|
|
878
|
-
//
|
|
874
|
+
// NOT ONE ATTEMPT. The app's watch row appears the same instant as the escrow, and the file may be issued a
|
|
875
|
+
// hair earlier: then the fallback source is silent and null goes into the file - and there is nowhere else to
|
|
876
|
+
// get the height, the file is already with the owner. A few short attempts close this gap instead of turning
|
|
877
|
+
// it into an empty field; the price is fractions of a second in the rare case when there is no row at all.
|
|
879
878
|
for (let i = 0; i < attempts; i++) {
|
|
880
879
|
try {
|
|
881
880
|
const r = await fetch("/api/swaps", { headers: { accept: "application/json" } });
|
|
@@ -887,7 +886,7 @@ export async function serverRestoreHeightFor(swap, { attempts = 3, pauseMs = 120
|
|
|
887
886
|
if (Number.isFinite(h) && h > 0) return h;
|
|
888
887
|
}
|
|
889
888
|
} catch {
|
|
890
|
-
/*
|
|
889
|
+
/* try again: the service may have been busy with the first request */
|
|
891
890
|
}
|
|
892
891
|
if (i < attempts - 1) await new Promise((r) => setTimeout(r, pauseMs));
|
|
893
892
|
}
|
|
@@ -908,16 +907,16 @@ async function resolveServerSwapId(swap) {
|
|
|
908
907
|
serverIdByAddress.set(addr, String(row.id));
|
|
909
908
|
return String(row.id);
|
|
910
909
|
} catch {
|
|
911
|
-
return null; //
|
|
910
|
+
return null; // the server is silent - we keep the previous behaviour, the screen does not wait for the network
|
|
912
911
|
}
|
|
913
912
|
}
|
|
914
913
|
|
|
915
914
|
function refreshLiveSession(id) {
|
|
916
915
|
if (liveInFlight.has(id)) return;
|
|
917
|
-
//
|
|
918
|
-
//
|
|
919
|
-
//
|
|
920
|
-
//
|
|
916
|
+
// No more than once per pollMs per swap, REGARDLESS of whether the session survived. The limit used to stand
|
|
917
|
+
// only in the "session exists" branch: if the request failed (429 from the proxy, 502 from a timeout), the
|
|
918
|
+
// session was not saved, the limit did not apply - and the tick repeated the request every 5 seconds for EACH
|
|
919
|
+
// swap. With eight accumulated swaps that produced a stream of 429s: the app fought itself.
|
|
921
920
|
const now = Date.now();
|
|
922
921
|
const lastTry = liveLastTry.get(id) || 0;
|
|
923
922
|
if (now - lastTry < pollInterval()) return;
|
|
@@ -928,18 +927,18 @@ function refreshLiveSession(id) {
|
|
|
928
927
|
const fresh = loadState().swaps[id];
|
|
929
928
|
if (!fresh || fresh.settlement) return;
|
|
930
929
|
if (!fresh.xmrSession) {
|
|
931
|
-
//
|
|
932
|
-
//
|
|
933
|
-
//
|
|
934
|
-
//
|
|
935
|
-
//
|
|
930
|
+
// The swap wallet is created in the browser; only the address and view key go outward (see chainSource).
|
|
931
|
+
// THE EXPECTED AMOUNT IS MANDATORY. Without it we do not create a session at all: the wallet is the same
|
|
932
|
+
// as on the confirmation screen, so the address is already watched. Otherwise a second truth about one
|
|
933
|
+
// deal would appear at the same address, and a record without an amount would let an underpayment through.
|
|
934
|
+
// As soon as the amount appears, the next tick creates the session itself.
|
|
936
935
|
const facts = dealFactsFor(fresh);
|
|
937
936
|
const amountXmr = facts.amountXmr;
|
|
938
937
|
if (!Number.isFinite(amountXmr) || amountXmr <= 0) {
|
|
939
|
-
pushLog(fresh, simElapsed(fresh), "Backend session not opened yet: amount of XMR is unknown", String((fresh && fresh.xmrAmount) ?? "
|
|
938
|
+
pushLog(fresh, simElapsed(fresh), "Backend session not opened yet: amount of XMR is unknown", String((fresh && fresh.xmrAmount) ?? "no"));
|
|
940
939
|
return;
|
|
941
940
|
}
|
|
942
|
-
if (facts.source !== "
|
|
941
|
+
if (facts.source !== "own record") {
|
|
943
942
|
pushLog(fresh, simElapsed(fresh), "Amount of XMR taken from the deal's other record", facts.source + ", " + String(amountXmr));
|
|
944
943
|
}
|
|
945
944
|
const share = watchOnlyShareFromWallet({ swapId: fresh.id, wallet: fresh.swapWallet });
|
|
@@ -960,10 +959,10 @@ function refreshLiveSession(id) {
|
|
|
960
959
|
applyLive(id, { xmrSessionError: opened.error });
|
|
961
960
|
}
|
|
962
961
|
} else if (Date.now() - (fresh.xmrSession.at || 0) >= pollInterval()) {
|
|
963
|
-
//
|
|
964
|
-
//
|
|
965
|
-
//
|
|
966
|
-
//
|
|
962
|
+
// WE ASK BY THE APP'S ID, not our own: see resolveServerSwapId. If the record was not found, we do NOT
|
|
963
|
+
// ask by our own id - the app will answer 404 and the screen will silently wait forever. Instead we
|
|
964
|
+
// create the watch afresh: the app itself ties it by address to the existing record and returns the same
|
|
965
|
+
// one, not a second truth about one deal.
|
|
967
966
|
const serverId = await resolveServerSwapId(fresh);
|
|
968
967
|
const polled = await fetchSession(serverId || id);
|
|
969
968
|
if (polled.ok) {
|
|
@@ -985,21 +984,21 @@ function refreshLiveSession(id) {
|
|
|
985
984
|
}
|
|
986
985
|
}
|
|
987
986
|
} catch (e) {
|
|
988
|
-
applyLive(id, { xmrSessionError: "
|
|
987
|
+
applyLive(id, { xmrSessionError: "session did not open: " + e.message });
|
|
989
988
|
} finally {
|
|
990
989
|
liveInFlight.delete(id);
|
|
991
990
|
}
|
|
992
991
|
})();
|
|
993
992
|
}
|
|
994
993
|
|
|
995
|
-
//
|
|
994
|
+
// The deals currently open on screen. The core polls the backend ONLY for them.
|
|
996
995
|
//
|
|
997
|
-
//
|
|
998
|
-
//
|
|
999
|
-
//
|
|
996
|
+
// Why: the tick used to poll all unfinished deals indiscriminately. After a few presses of the sign button they
|
|
997
|
+
// accumulated to eight, and the app itself flooded the proxy with requests - a 429 came back. And on the main
|
|
998
|
+
// page there is nothing to watch at all: there is no deal yet.
|
|
1000
999
|
//
|
|
1001
|
-
//
|
|
1002
|
-
//
|
|
1000
|
+
// An empty set (the default) means "poll nothing". Screens that need watching report their set themselves: the
|
|
1001
|
+
// deal list - all unfinished, the deal screen - one.
|
|
1003
1002
|
let liveFocusIds = new Set();
|
|
1004
1003
|
|
|
1005
1004
|
export function setLiveFocus(ids) {
|
|
@@ -1014,20 +1013,20 @@ export function tick() {
|
|
|
1014
1013
|
for (const swap of Object.values(s.swaps)) {
|
|
1015
1014
|
if (swap.settlement) continue;
|
|
1016
1015
|
|
|
1017
|
-
// Live
|
|
1018
|
-
//
|
|
1019
|
-
//
|
|
1016
|
+
// Live mode: a session on the backend. We create it on the first tick after the swap is created and then
|
|
1017
|
+
// poll no more often than API.pollMs. Not awaited: the screen must not wait for the network.
|
|
1018
|
+
// We poll only what is open on screen.
|
|
1020
1019
|
if (isLiveSource(swap.xmrSource) && liveFocusIds.has(String(swap.id))) refreshLiveSession(swap.id);
|
|
1021
1020
|
|
|
1022
1021
|
const view = derive(swap);
|
|
1023
1022
|
|
|
1024
|
-
//
|
|
1023
|
+
// the maker locks the XMR at the joint one-shot address (except the maker_no_lock scenario)
|
|
1025
1024
|
if (!isLiveSource(swap.xmrSource) && !swap.xmr && view.deadlines.lockAtSim !== null && view.sim >= view.deadlines.lockAtSim) {
|
|
1026
1025
|
const lock = chain.lockXmr({ swapId: swap.id, xmrAmount: swap.xmrAmount });
|
|
1027
1026
|
swap.xmr = { ...lock, reclaimed: false, sweepTxid: null };
|
|
1028
1027
|
|
|
1029
|
-
//
|
|
1030
|
-
//
|
|
1028
|
+
// In live mode we fix the node's real height at the lock moment: confirmations are counted from it
|
|
1029
|
+
// (a Monero block is ~2 minutes, so the deal really takes ~20 minutes).
|
|
1031
1030
|
if (swap.chainMode === "live") {
|
|
1032
1031
|
const snap = node.snapshot();
|
|
1033
1032
|
swap.xmr.lockHeight = snap ? snap.height : null;
|
|
@@ -1050,8 +1049,8 @@ export function tick() {
|
|
|
1050
1049
|
changed = true;
|
|
1051
1050
|
}
|
|
1052
1051
|
|
|
1053
|
-
// Live
|
|
1054
|
-
//
|
|
1052
|
+
// Live mode: the first seen XMR arrival is turned into a swap.xmr record with the REAL txid, so the rest of
|
|
1053
|
+
// the code (logs, steps, screens) works with it the same as with a mock lock.
|
|
1055
1054
|
if (isLiveSource(swap.xmrSource) && !swap.xmr && view.xmr && view.xmr.received > 0) {
|
|
1056
1055
|
swap.xmr = {
|
|
1057
1056
|
mock: false,
|
|
@@ -1073,8 +1072,8 @@ export function tick() {
|
|
|
1073
1072
|
changed = true;
|
|
1074
1073
|
}
|
|
1075
1074
|
|
|
1076
|
-
// Live
|
|
1077
|
-
//
|
|
1075
|
+
// Live mode: the withdrawal is confirmed by the sweep page (a watch-only wallet cannot see outgoing
|
|
1076
|
+
// transfers), so the final state is taken from the session status, and the hash is not invented.
|
|
1078
1077
|
if (isLiveSource(swap.xmrSource) && swap.xmrSession && swap.xmrSession.status === XMR_STATUS.SWEPT && !swap.settlement) {
|
|
1079
1078
|
const atSim = view.sim;
|
|
1080
1079
|
swap.settlement = {
|
|
@@ -1094,7 +1093,7 @@ export function tick() {
|
|
|
1094
1093
|
continue;
|
|
1095
1094
|
}
|
|
1096
1095
|
|
|
1097
|
-
// happy path:
|
|
1096
|
+
// happy path: after claim the user's wallet sweeps the XMR itself (litepaper §4.2)
|
|
1098
1097
|
if (!isLiveSource(swap.xmrSource) && view.can.sweep && view.phase === "success") {
|
|
1099
1098
|
const atSim = view.sim;
|
|
1100
1099
|
swap.xmr.sweepTxid = chain.sweepXmr({ swapId: swap.id }).txid;
|
|
@@ -1106,7 +1105,7 @@ export function tick() {
|
|
|
1106
1105
|
continue;
|
|
1107
1106
|
}
|
|
1108
1107
|
|
|
1109
|
-
// watchtower:
|
|
1108
|
+
// watchtower: if the user left and the money can be refunded - the watchtower does it
|
|
1110
1109
|
if (!isLiveSource(swap.xmrSource) && view.can.refund && view.refundLeftSim !== null && view.refundLeftSim <= -TIMING.watchtowerGrace) {
|
|
1111
1110
|
const atSim = view.sim;
|
|
1112
1111
|
const tx = chain.refundEscrow({ swapId: swap.id, by: "watchtower" });
|
|
@@ -1118,7 +1117,7 @@ export function tick() {
|
|
|
1118
1117
|
continue;
|
|
1119
1118
|
}
|
|
1120
1119
|
|
|
1121
|
-
//
|
|
1120
|
+
// the "user did not confirm" scenario: the maker takes the XMR back
|
|
1122
1121
|
if (!isLiveSource(swap.xmrSource) && swap.xmr && view.xmrReclaimed && !swap.xmr.reclaimed) {
|
|
1123
1122
|
swap.xmr.reclaimed = true;
|
|
1124
1123
|
pushLog(swap, view.sim, "Maker reclaimed the XMR", swap.xmr.txid);
|
|
@@ -1145,7 +1144,7 @@ export function startTicker(intervalMs = 400) {
|
|
|
1145
1144
|
if (timer) clearInterval(timer);
|
|
1146
1145
|
timer = setInterval(() => {
|
|
1147
1146
|
try { tick(); } catch (e) { console.error(e); }
|
|
1148
|
-
emit(); //
|
|
1147
|
+
emit(); // the screen redraws every tick: confirmations and timers run live
|
|
1149
1148
|
}, intervalMs);
|
|
1150
1149
|
}
|
|
1151
1150
|
|